/** * One adapter for every OpenAI-compatible chat-completions provider * (OpenAI, Anthropic's compatibility layer, DeepSeek, Groq, OpenRouter and * any custom endpoint). The user's key travels with each request and is never * stored server-side. */ import { OllamaError, type OllamaMessage } from "./ollama"; /** Where a provider call goes: an OpenAI-compatible base URL plus the key. */ export type ProviderTarget = { baseUrl: string; apiKey: string; /** Shown in error copy ("OpenAI refused the key…"). */ label: string; }; /** * Extends OllamaError on purpose: every route and error card already reads * `.kind` off that class, and friendlyErrorMessage() returns this message * verbatim when `provider` is set (the copy already names the provider). */ export class ProviderError extends OllamaError { provider: string; constructor(kind: string, message: string, provider: string, options?: { status?: number }) { super(kind as never, message, options); this.name = "ProviderError"; this.provider = provider; } } function trimBase(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } function requestHeaders(target: ProviderTarget): Record { const headers: Record = { "Content-Type": "application/json", }; if (target.apiKey) { headers.Authorization = `Bearer ${target.apiKey}`; // Anthropic's own endpoints also accept (and tooling often expects) the // native header; harmless for the OpenAI-compatible layer. if (target.baseUrl.includes("anthropic.com")) { headers["x-api-key"] = target.apiKey; headers["anthropic-version"] = "2023-06-01"; } } return headers; } function failure(status: number, body: string, model: string, target: ProviderTarget): ProviderError { const detail = body.slice(0, 400); const hint = detail && /error/i.test(detail) ? ` (${detail.replace(/\s+/g, " ").slice(0, 200)})` : ""; if (status === 401 || status === 403) { return new ProviderError("auth", `${target.label} refused the API key. Check it in Settings → Providers.${hint}`, target.label, { status }); } if (status === 402) { return new ProviderError("usage_limit", `Your ${target.label} account has no credits left for "${model}".${hint}`, target.label, { status }); } if (status === 404) { return new ProviderError("model_missing", `${target.label} does not offer the model "${model}". Pick another one in the model list.${hint}`, target.label, { status }); } if (status === 429) { if (/quota|credit|insufficient|balance/i.test(detail)) { return new ProviderError("usage_limit", `Your ${target.label} account reached its usage limit.${hint}`, target.label, { status }); } return new ProviderError("throttled", `${target.label} is rate limiting requests right now. Try again in a moment.`, target.label, { status }); } if (status === 400 && /model/i.test(detail) && /(not found|unknown|invalid|does not exist)/i.test(detail)) { return new ProviderError("model_missing", `${target.label} does not recognise "${model}".${hint}`, target.label, { status }); } return new ProviderError("http", `${target.label} responded with HTTP ${status}.${hint}`, target.label, { status }); } async function runFetch( url: string, init: RequestInit, model: string, target: ProviderTarget, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const forwarded = init.signal; const onAbort = () => controller.abort(); if (forwarded) { if (forwarded.aborted) controller.abort(); else forwarded.addEventListener("abort", onAbort, { once: true }); } try { return await fetch(url, { ...init, signal: controller.signal }); } catch (error) { if (forwarded?.aborted) throw error; if (error instanceof Error && error.name === "AbortError") { throw new ProviderError("timeout", `${target.label} took too long to answer.`, target.label); } throw new ProviderError("unreachable", `Could not reach ${target.label}. Check your connection.`, target.label); } finally { clearTimeout(timer); forwarded?.removeEventListener("abort", onAbort); } } /** Every model the provider offers to this key. */ export async function providerListModels(target: ProviderTarget, timeoutMs = 12_000): Promise { const response = await runFetch(`${trimBase(target.baseUrl)}/models`, { method: "GET", headers: requestHeaders(target), cache: "no-store" }, "", target, timeoutMs); const text = await response.text().catch(() => ""); if (!response.ok) throw failure(response.status, text, "*", target); try { const payload = JSON.parse(text) as { data?: Array<{ id?: string }>; models?: Array<{ name?: string }> }; const ids = (payload.data ?? []).map((entry) => entry.id).filter((id): id is string => Boolean(id)); if (ids.length > 0) return ids.slice(0, 400); const names = (payload.models ?? []).map((entry) => entry.name).filter((id): id is string => Boolean(id)); return names.slice(0, 400); } catch { throw new ProviderError("http", `${target.label} sent a model list Freezmo could not read.`, target.label); } } type ChatPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }; function contentFor(user: string, images?: string[]): string | ChatPart[] { if (!images || images.length === 0) return user; const parts: ChatPart[] = [{ type: "text", text: user }]; for (const image of images.slice(0, 8)) { parts.push({ type: "image_url", image_url: { url: image } }); } return parts; } /** Non-streaming completion (generation, extraction, grading). */ export async function providerChatJson(input: { target: ProviderTarget; model: string; messages: OllamaMessage[]; temperature?: number; signal?: AbortSignal; timeoutMs?: number; }): Promise<{ content: string; model: string }> { const body = { model: input.model, messages: input.messages.map((message) => ({ role: message.role, content: message.images && message.images.length > 0 ? contentFor(message.content, message.images) : message.content, })), temperature: typeof input.temperature === "number" ? input.temperature : 0.3, stream: false, }; const response = await runFetch( `${trimBase(input.target.baseUrl)}/chat/completions`, { method: "POST", headers: requestHeaders(input.target), body: JSON.stringify(body), signal: input.signal, cache: "no-store" }, input.model, input.target, input.timeoutMs ?? 300_000, ); const text = await response.text().catch(() => ""); if (!response.ok) throw failure(response.status, text, input.model, input.target); try { const payload = JSON.parse(text) as { model?: string; choices?: Array<{ message?: { content?: unknown } }>; }; const content = payload.choices?.[0]?.message?.content; const flat = typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => (typeof part === "object" && part && "text" in part ? String((part as { text: unknown }).text) : "")).join("") : ""; if (!flat) throw new Error("empty"); return { content: flat, model: payload.model ?? input.model }; } catch { throw new ProviderError("http", `${input.target.label} sent an answer Freezmo could not read.`, input.target.label); } } /** Streaming chat: parses the provider's SSE deltas. */ export async function providerChatStream(input: { target: ProviderTarget; model: string; messages: OllamaMessage[]; signal?: AbortSignal; onToken?: (token: string) => void; onThinking?: (token: string) => void; }): Promise<{ model: string }> { const body = { model: input.model, messages: input.messages.map((message) => ({ role: message.role, content: message.images && message.images.length > 0 ? contentFor(message.content, message.images) : message.content, })), stream: true, }; const response = await runFetch( `${trimBase(input.target.baseUrl)}/chat/completions`, { method: "POST", headers: requestHeaders(input.target), body: JSON.stringify(body), signal: input.signal, cache: "no-store" }, input.model, input.target, 300_000, ); if (!response.ok) { const text = await response.text().catch(() => ""); throw failure(response.status, text, input.model, input.target); } if (!response.body) { throw new ProviderError("http", `${input.target.label} sent an empty stream.`, input.target.label); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let model = input.model; let sawDone = false; const handleLine = (line: string) => { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return; const data = trimmed.slice(5).trim(); if (!data || data === "[DONE]") { if (data === "[DONE]") sawDone = true; return; } let event: { model?: string; choices?: Array<{ delta?: { content?: unknown; reasoning?: unknown; reasoning_content?: unknown } }>; error?: { message?: string }; }; try { event = JSON.parse(data); } catch { return; } if (event.error?.message) throw new ProviderError("http", `${input.target.label}: ${event.error.message}`, input.target.label); if (event.model) model = event.model; const delta = event.choices?.[0]?.delta; if (!delta) return; const thinking = delta.reasoning_content ?? delta.reasoning; if (typeof thinking === "string" && thinking) input.onThinking?.(thinking); if (typeof delta.content === "string" && delta.content) input.onToken?.(delta.content); }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newline = buffer.indexOf("\n"); while (newline !== -1) { handleLine(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); } } handleLine(buffer); if (!sawDone) { // Some gateways close the stream without [DONE]; tokens already arrived, // so this is only worth a debug note. console.warn(`[freezmo] ${input.target.label} stream ended without [DONE]`); } return { model }; }