/** * @factory/ai — OpenAI-compatible adapter (ai-architecture §2.2). * * One adapter covers OpenAI, every OSS deployment (vLLM / Ollama / llama.cpp / * SGLang) and customer "custom" endpoints: anything speaking * `/chat/completions` (+ SSE) and `/embeddings` (and `/models`). Differences: * - `OpenAIAdapter` → baseUrl `https://api.openai.com/v1`, Bearer key * - `OssAdapter` → configured baseUrl, optional key, **dimension * discovered from a probe embed at first use** (never assumed — HNSW * needs the exact dim) * - `CustomAdapter` → like OSS + `extraHeaders`/`pathPrefix` * * Model list from `/models`, cached 10 min (arch §2.2). */ import type { ChatChunk, ChatMessage, ChatRequest, ChatResult, EmbedRequest, EmbedResult, ModelInfo, ProviderAdapter, ProviderId, ToolCall, ToolSpec, } from "../types.js"; import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js"; export interface OpenAICompatibleOptions extends HttpOptions { id: ProviderId; /** e.g. `https://api.openai.com/v1` (OpenAI), `http://ollama:11434/v1` (OSS). */ baseUrl: string; /** Bearer key; may be empty for local OSS. */ apiKey?: string; extraHeaders?: Record; /** OSS/custom: dimension is discovered, not assumed (arch §2.2). */ assumeDimension?: number; now?: () => number; } const TEN_MIN = 10 * 60 * 1000; interface OpenAIChatChunkRaw { choices?: Array<{ delta?: { content?: string | null; role?: string; tool_calls?: Array<{ index: number; id?: string; type?: string; function?: { name?: string; arguments?: string }; }>; }; finish_reason?: string | null; }>; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; } interface OpenAIEmbedResponse { data: Array<{ index: number; embedding: number[] }>; model: string; usage?: { prompt_tokens: number; total_tokens: number }; } interface OpenAIModelRaw { id: string; created?: number; object?: string; } export class OpenAICompatibleAdapter implements ProviderAdapter { readonly id: ProviderId; protected readonly baseUrl: string; protected readonly apiKey: string | undefined; protected readonly extraHeaders: Record; protected readonly http: HttpOptions; protected readonly now: () => number; private modelsCache: { at: number; models: ModelInfo[] } | null = null; private discoveredDimension: number | undefined; constructor(options: OpenAICompatibleOptions) { this.id = options.id; this.baseUrl = options.baseUrl.replace(/\/+$/, ""); this.apiKey = options.apiKey; this.extraHeaders = options.extraHeaders ?? {}; this.http = { ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(options.idleMs !== undefined ? { idleMs: options.idleMs } : {}), }; this.now = options.now ?? Date.now; if (options.assumeDimension !== undefined) this.discoveredDimension = options.assumeDimension; } protected headers(): Record { const h: Record = { "content-type": "application/json", ...this.extraHeaders }; if (this.apiKey) h.authorization = `Bearer ${this.apiKey}`; return h; } async models(): Promise { if (this.modelsCache && this.now() - this.modelsCache.at < TEN_MIN) return this.modelsCache.models; try { const res = await httpJson<{ data?: OpenAIModelRaw[] }>(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, this.http); const data = res.data ?? []; const models: ModelInfo[] = data.map((m) => toModelInfo(m.id, this.id)); this.modelsCache = { at: this.now(), models }; return models; } catch { // model listing is best-effort (some OSS endpoints lack /models) return this.modelsCache?.models ?? []; } } /** * The adapter's chat contract is streaming-only (arch §2.1 design note): * non-streaming callers collect the chunk stream. */ async *chat(req: ChatRequest): AsyncIterable { const body: Record = { model: req.model, messages: req.messages.map(mapMessage), stream: true, stream_options: { include_usage: true }, }; if (req.tools && req.tools.length > 0) { body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } })); body.tool_choice = mapToolChoice(req.toolChoice); } if (req.temperature !== undefined) body.temperature = req.temperature; if (req.maxTokens !== undefined) body.max_tokens = req.maxTokens; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.http.timeoutMs ?? 60_000); // TASK-0122: a caller abort (mid-request or mid-stream) aborts the fetch. const onCallerAbort = () => controller.abort(); if (req.signal) { if (req.signal.aborted) controller.abort(); else req.signal.addEventListener("abort", onCallerAbort, { once: true }); } let response: Response; try { const fetchImpl = this.http.fetchImpl ?? fetch; response = await fetchImpl(`${this.baseUrl}/chat/completions`, { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: controller.signal, }); } finally { clearTimeout(timer); if (req.signal) req.signal.removeEventListener("abort", onCallerAbort); } if (!response.ok) { const text = await response.text().catch(() => ""); throw new ProviderHttpError(response.status, `HTTP ${response.status}: ${text.slice(0, 200)}`, response.status === 429 || response.status >= 500); } // accumulate streamed tool-call fragments per index const toolFrags = new Map(); let finishReason: ChatResult["finishReason"] = "stop"; let usage = { in: 0, out: 0, total: 0 }; try { for await (const event of sseData(response, this.http, req.signal)) { const choice = event.choices?.[0]; if (choice) { const delta = choice.delta; if (delta?.content) yield { delta: delta.content, done: false }; if (delta?.tool_calls) { for (const tc of delta.tool_calls) { const frag = toolFrags.get(tc.index) ?? { id: "", name: "", args: "" }; if (tc.id) frag.id = tc.id; if (tc.function?.name) frag.name += tc.function.name; if (tc.function?.arguments) frag.args += tc.function.arguments; toolFrags.set(tc.index, frag); } } if (choice.finish_reason && choice.finish_reason !== "stop") { finishReason = mapFinishReason(choice.finish_reason); } } if (event.usage) { usage = { in: event.usage.prompt_tokens ?? 0, out: event.usage.completion_tokens ?? 0, total: event.usage.total_tokens ?? 0, }; } } } catch (err) { // A caller abort passes through UNWRAPPED (the gateway maps it to the // non-retryable `ai_aborted` error); other transport/idle failures // become retryable provider errors. if (err instanceof ProviderHttpError || (err instanceof Error && err.name === "AbortError")) throw err; throw new ProviderHttpError(502, err instanceof Error ? err.message : "stream failed", true); } // emit completed tool calls (deterministic index order) for (const [index, frag] of [...toolFrags.entries()].sort((a, b) => a[0] - b[0])) { const toolCall: ToolCall = { id: frag.id || `call_${index}`, name: frag.name, arguments: parseToolArguments(frag.args), }; yield { toolCallDelta: toolCall, done: false }; } yield { usage, finishReason, done: true }; } /** Embed texts. For OSS/custom the dimension is discovered on first use. */ async embed(req: EmbedRequest): Promise { const started = this.now(); const res = await httpJson( `${this.baseUrl}/embeddings`, { method: "POST", headers: this.headers(), body: JSON.stringify({ model: req.model, input: req.texts }), }, { ...this.http, timeoutMs: this.http.timeoutMs ?? 10_000 }, ); const sorted = [...res.data].sort((a, b) => a.index - b.index); const vectors = sorted.map((d) => d.embedding); const dimension = vectors[0]?.length ?? 0; if (dimension > 0) this.discoveredDimension = dimension; return { model: req.model, dimension, vectors, usage: { tokens: res.usage?.total_tokens ?? 0 }, latencyMs: this.now() - started, provider: this.id, }; } /** Discovered (or assumed) embedding dimension; undefined until an embed ran. */ get dimension(): number | undefined { return this.discoveredDimension; } async health(): Promise<{ ok: boolean; latencyMs: number }> { const started = this.now(); try { await httpJson(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, { ...this.http, timeoutMs: 5_000 }); return { ok: true, latencyMs: this.now() - started }; } catch { return { ok: false, latencyMs: this.now() - started }; } } } /** `OpenAIAdapter` — OpenAI's API with Bearer auth and known model table. */ export class OpenAIAdapter extends OpenAICompatibleAdapter { constructor(options: Omit & { baseUrl?: string } = {}) { super({ ...options, id: "openai", baseUrl: options.baseUrl ?? "https://api.openai.com/v1", }); } } /** `OssAdapter` — OpenAI-compatible self-hosted endpoint; dimension discovered. */ export class OssAdapter extends OpenAICompatibleAdapter { constructor(options: Omit) { super({ ...options, id: "oss" }); } } /** `CustomAdapter` — customer endpoint + extraHeaders (arch §2.2). */ export class CustomAdapter extends OpenAICompatibleAdapter { constructor(options: Omit) { super({ ...options, id: "custom" }); } } // ── mapping helpers ──────────────────────────────────────────────────────────── function mapMessage(msg: ChatMessage): Record { const out: Record = { role: msg.role, content: msg.content }; if (msg.toolCalls && msg.toolCalls.length > 0) { out.tool_calls = msg.toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, })); } if (msg.toolCallId) out.tool_call_id = msg.toolCallId; if (msg.name) out.name = msg.name; return out; } function mapToolChoice(tc: ChatRequest["toolChoice"]): unknown { if (tc === undefined) return undefined; if (tc === "auto") return "auto"; if (tc === "none") return "none"; return { type: "function", function: { name: tc.name } }; } function mapFinishReason(reason: string): ChatResult["finishReason"] { if (reason === "tool_calls" || reason === "function_call") return "tool_calls"; if (reason === "length") return "length"; if (reason === "error") return "error"; return "stop"; } function parseToolArguments(args: string): Record { if (!args.trim()) return {}; try { const parsed: unknown = JSON.parse(args); return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; } catch { return {}; } } /** Best-effort static model table (used when /models is unavailable). */ function toModelInfo(id: string, provider: ProviderId): ModelInfo { const embed = /embed/i.test(id); const known: Partial> = { "gpt-4o": { dim: 0, in: 2.5, out: 10, ctx: 128_000, maxOut: 16_384, tools: true, vision: true }, "gpt-4o-mini": { dim: 0, in: 0.15, out: 0.6, ctx: 128_000, maxOut: 16_384, tools: true, vision: true }, "text-embedding-3-small": { dim: 1536, in: 0.02, out: 0, ctx: 8_191, maxOut: 0, tools: false, vision: false }, "text-embedding-3-large": { dim: 3072, in: 0.13, out: 0, ctx: 8_191, maxOut: 0, tools: false, vision: false }, }; const k = known[id]; if (k) { return { id, provider, kind: embed ? "embed" : "chat", contextWindow: k.ctx ?? 128_000, maxOutput: k.maxOut ?? 4_096, toolCalls: k.tools ?? false, vision: k.vision ?? false, pricePer1kIn: k.in ?? 0, pricePer1kOut: k.out ?? 0, dimension: k.dim ?? 0, }; } // unknown model: conservative defaults (prices 0 → OSS-style free) return { id, provider, kind: embed ? "embed" : "chat", contextWindow: 32_000, maxOutput: 4_096, toolCalls: !embed, vision: false, pricePer1kIn: 0, pricePer1kOut: 0, dimension: 0, }; }