export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } export interface AiProvider { name: string; chat( messages: ChatMessage[], model: string, onToken: (token: string) => void, onDone: (full: string, usage?: { tokensIn: number; tokensOut: number }) => void, onError: (err: Error) => void, signal?: AbortSignal, ): void; } export function createProvider(provider: string, apiKey: string, baseUrl?: string): AiProvider | null { switch (provider) { case 'openai': return openai(apiKey, baseUrl); case 'anthropic': return anthropic(apiKey); case 'ollama': return ollama(baseUrl); default: return null; } } // ── SSE line parser ── async function readSSE(res: Response, onLine: (line: string) => void, signal?: AbortSignal) { const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = ''; while (true) { if (signal?.aborted) break; const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop()!; for (const line of lines) { if (line.startsWith('data: ') && line !== 'data: [DONE]') onLine(line.slice(6)); } } } // ── Providers (raw fetch, zero deps) ── function openai(apiKey: string, baseUrl = 'https://api.openai.com/v1'): AiProvider { return { name: 'openai', async chat(messages, model, onToken, onDone, onError, signal) { try { const res = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages, stream: true, stream_options: { include_usage: true } }), signal, }); if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await res.text()}`); let full = ''; let usage: { tokensIn: number; tokensOut: number } | undefined; await readSSE(res, (line) => { const j = JSON.parse(line); const t = j.choices?.[0]?.delta?.content; if (t) { full += t; onToken(t); } if (j.usage) usage = { tokensIn: j.usage.prompt_tokens, tokensOut: j.usage.completion_tokens }; }, signal); onDone(full, usage); } catch (e) { if (!signal?.aborted) onError(e instanceof Error ? e : new Error(String(e))); } }, }; } function anthropic(apiKey: string): AiProvider { return { name: 'anthropic', async chat(messages, model, onToken, onDone, onError, signal) { try { const sys = messages.find((m) => m.role === 'system'); const msgs = messages.filter((m) => m.role !== 'system'); const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json', }, body: JSON.stringify({ model, max_tokens: 4096, system: sys?.content, messages: msgs, stream: true }), signal, }); if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`); let full = ''; let usage: { tokensIn: number; tokensOut: number } | undefined; await readSSE(res, (line) => { const j = JSON.parse(line); if (j.type === 'content_block_delta' && j.delta?.text) { full += j.delta.text; onToken(j.delta.text); } if (j.type === 'message_delta' && j.usage) { usage = { tokensIn: j.usage?.input_tokens ?? 0, tokensOut: j.usage.output_tokens }; } if (j.type === 'message_start' && j.message?.usage) { usage = { tokensIn: j.message.usage.input_tokens, tokensOut: 0 }; } }, signal); onDone(full, usage); } catch (e) { if (!signal?.aborted) onError(e instanceof Error ? e : new Error(String(e))); } }, }; } function ollama(baseUrl = 'http://localhost:11434'): AiProvider { return { name: 'ollama', async chat(messages, model, onToken, onDone, onError, signal) { try { const res = await fetch(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages, stream: true }), signal, }); if (!res.ok) throw new Error(`Ollama ${res.status}`); const reader = res.body!.getReader(); const dec = new TextDecoder(); let full = ''; while (true) { const { done, value } = await reader.read(); if (done) break; for (const line of dec.decode(value, { stream: true }).split('\n').filter(Boolean)) { const j = JSON.parse(line); if (j.message?.content) { full += j.message.content; onToken(j.message.content); } if (j.done) { onDone(full, { tokensIn: j.prompt_eval_count ?? 0, tokensOut: j.eval_count ?? 0 }); return; } } } onDone(full); } catch (e) { if (!signal?.aborted) onError(e instanceof Error ? e : new Error(String(e))); } }, }; }