/** * 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[] } /** Called with each streamed content delta (final-prose streaming). */ export type OnToken = (delta: string) => void export interface ChatClient { /** When `onToken` is given, the response streams (SSE) and each content delta is emitted. */ complete(messages: ChatMessage[], tools?: ToolDecl[], onToken?: OnToken): 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, onToken) { 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 stream = !!onToken if (stream) body.stream = true const res = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: stream ? 'text/event-stream' : '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)}`) } if (stream && res.body) return readStream(res.body, onToken!) 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, })), } }, } } /** Streamed tool-call delta: name arrives first, arguments accumulate across chunks. */ interface RawToolDelta { index: number id?: string function?: { name?: string; arguments?: string } } /** Parse an OpenAI SSE stream: emit content deltas via onToken, accumulate the final result. */ async function readStream(body: ReadableStream, onToken: OnToken): Promise { const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' let content = '' const toolAcc: { id: string; name: string; arguments: string }[] = [] const handle = (data: string) => { if (data === '[DONE]') return let chunk: { choices?: { delta?: { content?: string; tool_calls?: RawToolDelta[] } }[] } try { chunk = JSON.parse(data) } catch { return } const delta = chunk.choices?.[0]?.delta if (!delta) return if (delta.content) { content += delta.content onToken(delta.content) } for (const tc of delta.tool_calls ?? []) { const slot = (toolAcc[tc.index] ??= { id: '', name: '', arguments: '' }) if (tc.id) slot.id = tc.id if (tc.function?.name) slot.name = tc.function.name if (tc.function?.arguments) slot.arguments += tc.function.arguments } } for (;;) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' for (const line of lines) { const t = line.trim() if (t.startsWith('data:')) handle(t.slice(5).trim()) } } if (buffer.trim().startsWith('data:')) handle(buffer.trim().slice(5).trim()) return { content, toolCalls: toolAcc.filter((t) => t.name).map((t) => ({ id: t.id, name: t.name, arguments: t.arguments })) } }