/** * @factory/ai — Google (Gemini) adapter (ai-architecture §2.2). * * - `x-goog-api-key` header * - chat: `:streamGenerateContent?alt=sse` (SSE) over * `https://generativelanguage.googleapis.com/v1beta/models/{model}` * - roles: `user` / `model` (`assistant` maps to `model`); `system` maps to * the top-level `systemInstruction` * - tools: `functionDeclarations` (parameters are JSON Schema — no * translation needed beyond the wrapper); `toolConfig.functionCallingConfig` * - embed: `text-embedding-004` (768-dim) via `:batchEmbedContents` */ import type { ChatChunk, ChatMessage, ChatRequest, ChatResult, EmbedRequest, EmbedResult, ModelInfo, ProviderAdapter, ToolCall, } from "../types.js"; import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js"; export interface GoogleOptions extends HttpOptions { baseUrl?: string; apiKey: string; now?: () => number; } interface GeminiPart { text?: string; functionCall?: { name: string; args?: Record }; functionResponse?: { name: string; response?: Record }; } interface GeminiSseEvent { candidates?: Array<{ content?: { role?: string; parts?: GeminiPart[] }; finishReason?: string; }>; usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number; }; error?: { message?: string }; } interface GeminiBatchEmbedResponse { embeddings?: Array<{ values: number[] }>; } export class GoogleAdapter implements ProviderAdapter { readonly id = "google" as const; private readonly baseUrl: string; private readonly apiKey: string; private readonly http: HttpOptions; private readonly now: () => number; constructor(options: GoogleOptions) { this.baseUrl = (options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, ""); this.apiKey = options.apiKey; 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; } private headers(): Record { return { "content-type": "application/json", "x-goog-api-key": this.apiKey }; } private modelUrl(model: string, action: string): string { return `${this.baseUrl}/models/${encodeURIComponent(model)}:${action}`; } async models(): Promise { try { const res = await httpJson<{ models?: Array<{ name?: string }> }>( `${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, this.http, ); return (res.models ?? []).map((m) => { const id = (m.name ?? "").replace(/^models\//, ""); return toModelInfo(id, "google"); }); } catch { return []; } } async *chat(req: ChatRequest): AsyncIterable { const system = extractSystem(req.messages); const contents = req.messages .filter((m) => m.role !== "system") .map(mapContent) .filter((c) => c !== null); const body: Record = { contents, generationConfig: { maxOutputTokens: req.maxTokens, temperature: req.temperature, }, }; if (system) body.systemInstruction = { parts: [{ text: system }] }; if (req.tools && req.tools.length > 0) { body.tools = [ { functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters, })), }, ]; body.toolConfig = { functionCallingConfig: { mode: mapToolMode(req.toolChoice) } }; } 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.modelUrl(req.model, "streamGenerateContent")}?alt=sse`, { 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); } let promptTokens = 0; let outputTokens = 0; let finishReason: ChatResult["finishReason"] = "stop"; let toolIndex = 0; let hadToolCall = false; try { for await (const event of sseData(response, this.http, req.signal)) { if (event.error) { throw new ProviderHttpError(502, `provider error: ${event.error.message ?? "unknown"}`, true); } for (const candidate of event.candidates ?? []) { for (const part of candidate.content?.parts ?? []) { if (typeof part.text === "string" && part.text.length > 0) { yield { delta: part.text, done: false }; } if (part.functionCall) { hadToolCall = true; yield { toolCallDelta: { id: `call_${toolIndex++}`, name: part.functionCall.name, arguments: part.functionCall.args ?? {}, }, done: false, }; } } if (candidate.finishReason) { finishReason = mapFinish(candidate.finishReason); } } if (event.usageMetadata) { promptTokens = event.usageMetadata.promptTokenCount ?? 0; outputTokens = event.usageMetadata.candidatesTokenCount ?? 0; } } } catch (err) { // A caller abort passes through unwrapped (the gateway maps it to the // non-retryable `ai_aborted` error); provider error events + transport // 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); } // A functionCall is a tool-call finish even when Gemini reports // `finishReason: "STOP"` for that candidate (the parts loop above runs // before the finishReason line, so a naive STOP would hide the tool call). if (hadToolCall && finishReason !== "error") { finishReason = "tool_calls"; } yield { usage: { in: promptTokens, out: outputTokens, total: promptTokens + outputTokens }, finishReason, done: true, }; } async embed(req: EmbedRequest): Promise { const started = this.now(); const res = await httpJson( this.modelUrl(req.model, "batchEmbedContents"), { method: "POST", headers: this.headers(), body: JSON.stringify({ requests: req.texts.map((t) => ({ model: `models/${req.model}`, content: { parts: [{ text: t }] } })), }), }, { ...this.http, timeoutMs: this.http.timeoutMs ?? 10_000 }, ); const vectors = (res.embeddings ?? []).map((e) => e.values); const dimension = vectors[0]?.length ?? 0; return { model: req.model, dimension, vectors, usage: { tokens: 0 }, // Gemini usage for embeddings is per-batch, not per-token latencyMs: this.now() - started, provider: this.id, }; } 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 }; } } } // ── mapping helpers ──────────────────────────────────────────────────────────── function extractSystem(messages: ChatMessage[]): string | null { const parts: string[] = []; for (const m of messages) { if (m.role === "system" && typeof m.content === "string") parts.push(m.content); } return parts.length > 0 ? parts.join("\n\n") : null; } function mapContent(msg: ChatMessage): { role: "user" | "model"; parts: GeminiPart[] } | null { const role: "user" | "model" = msg.role === "assistant" ? "model" : "user"; const parts: GeminiPart[] = []; if (typeof msg.content === "string" && msg.content.length > 0) { parts.push({ text: msg.content }); } if (msg.role === "assistant" && msg.toolCalls) { for (const tc of msg.toolCalls) { parts.push({ functionCall: { name: tc.name, args: tc.arguments } }); } } if (msg.role === "tool") { parts.push({ functionResponse: { name: msg.name ?? "", response: { id: msg.toolCallId ?? "", result: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content) }, }, }); } return parts.length > 0 ? { role, parts } : null; } function mapToolMode(tc: ChatRequest["toolChoice"]): "AUTO" | "ANY" | "NONE" { if (tc === "none") return "NONE"; if (tc && typeof tc === "object") return "ANY"; return "AUTO"; } function mapFinish(reason: string): ChatResult["finishReason"] { if (reason === "STOP") return "stop"; if (reason === "MAX_TOKENS" || reason === "RECITATION") return "length"; if (reason === "SAFETY") return "error"; return "stop"; } function toModelInfo(id: string, provider: "google"): ModelInfo { const known: Partial> = { "gemini-2.0-flash": { in: 0.1, out: 0.4, ctx: 1_048_576, maxOut: 8_192 }, "gemini-1.5-pro": { in: 1.25, out: 5, ctx: 2_097_152, maxOut: 8_192 }, "text-embedding-004": { in: 0.025, out: 0, ctx: 2_048, maxOut: 0, embed: 768 }, }; const k = known[id]; const embed = k?.embed !== undefined; return { id, provider, kind: embed ? "embed" : "chat", contextWindow: k?.ctx ?? 32_768, maxOutput: k?.maxOut ?? 8_192, toolCalls: !embed, vision: !embed, pricePer1kIn: k?.in ?? 0, pricePer1kOut: k?.out ?? 0, dimension: k?.embed ?? 0, }; }