/** * Reginald's model client — a thin OpenAI-wire chat-completions client over an * injected fetch (the same seam the gitea client uses). `@commitea/core` stays * pure: no network, no globals. The desktop main process passes the real * `fetch`; tests pass a stub. Points at any OpenAI-compatible endpoint (LM * Studio, Ollama, the OpenAI API) — the model router picks which. */ import type { FetchLike } from '../gitea/types.js' /** Where a model lives + which model to ask for. */ export interface ModelConfig { /** OpenAI-compatible base, including the version segment, e.g. `http://localhost:1234/v1`. */ baseUrl: string model: string /** Bearer key; omitted for local servers that don't check it. */ apiKey?: string } /** A model's request to run a tool. `arguments` is a raw JSON string (OpenAI shape). */ export interface ToolCall { id: string name: string arguments: string } /** One turn in the conversation, in our normalized shape. */ export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool' content: string /** assistant turns that requested tools. */ toolCalls?: ToolCall[] /** tool turns: which call they answer. */ toolCallId?: string /** tool turns: the function name. */ name?: string } /** A tool the model may call — name, description, and a JSON-Schema parameter spec. */ export interface ToolDecl { name: string description: string parameters: object } export interface CompletionResult { content: string toolCalls: ToolCall[] } export interface ChatClient { complete(messages: ChatMessage[], tools?: ToolDecl[]): Promise } /** Map our message shape to the OpenAI wire shape. */ function toWireMessage(m: ChatMessage): Record { if (m.role === 'assistant' && m.toolCalls?.length) { return { role: 'assistant', content: m.content || null, tool_calls: m.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.arguments }, })), } } if (m.role === 'tool') { return { role: 'tool', tool_call_id: m.toolCallId, content: m.content } } return { role: m.role, content: m.content } } function toWireTool(t: ToolDecl): Record { return { type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } } } /** Shape of the one choice we read back. */ interface RawChoiceMessage { content: string | null tool_calls?: { id: string; function: { name: string; arguments: string } }[] } export function createChatClient(config: ModelConfig, fetchImpl: FetchLike): ChatClient { const url = `${config.baseUrl.replace(/\/+$/, '')}/chat/completions` return { async complete(messages, tools) { const body: Record = { model: config.model, messages: messages.map(toWireMessage), temperature: 0, } if (tools?.length) { body.tools = tools.map(toWireTool) body.tool_choice = 'auto' } const res = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), }, body: JSON.stringify(body), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`model completion failed (${res.status}): ${text.slice(0, 200)}`) } const json = (await res.json()) as { choices?: { message: RawChoiceMessage }[] } const msg = json.choices?.[0]?.message return { content: msg?.content ?? '', toolCalls: (msg?.tool_calls ?? []).map((tc) => ({ id: tc.id, name: tc.function.name, arguments: tc.function.arguments, })), } }, } }