import { GoogleGenAI, ApiError, type Content, type Part, type GenerateContentConfig, type GenerateContentResponse, type Model as GeminiModel, type Tool, type FunctionDeclaration, FunctionCallingConfigMode, type ThinkingConfig } from "@google/genai"; import { type AIProviderAdapter, type PolyModel, type UnifiedChatRequest, type UnifiedChatResponse, type UnifiedStreamEvent, type ValidationResult, type TokenEstimate, type UnifiedMessage, type FinishReason, PolyProviderError, modelKey, } from "@/lib/ai/core/types"; import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus } from "@/lib/ai/core/errors"; import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize"; import { collectStream } from "@/lib/ai/core/stream-utils"; import { isTextLike, inlineTextFile } from "@/lib/ai/core/content"; import { GEMINI_CATALOG, GEMINI_NON_CHAT } from "./catalog"; const DEFAULT_TIMEOUT_MS = 10 * 60_000; function client(apiKey: string, timeoutMs = DEFAULT_TIMEOUT_MS) { return new GoogleGenAI({ apiKey, httpOptions: { timeout: timeoutMs } }); } export function normalizeGeminiModel(m: GeminiModel): PolyModel | null { const id = (m.name ?? "").replace(/^models\//, ""); if (!id || GEMINI_NON_CHAT.test(id)) return null; const actions = m.supportedActions ?? []; if (actions.length && !actions.includes("generateContent")) return null; const cat = GEMINI_CATALOG.get(id); const thinking = Boolean(m.thinking); const gone = /^gemini-(1|2)\./.test(id); return { key: modelKey("gemini", id), id, provider: "gemini", displayName: cat?.displayName ?? m.displayName ?? id, family: cat?.family ?? "Gemini", capabilities: cat?.capabilities ?? { text: true, vision: true, audioInput: true, audioOutput: false, imageGeneration: false, video: true, reasoning: thinking, tools: true, structuredOutput: true, streaming: true, files: true, webSearch: true }, limits: { contextTokens: m.inputTokenLimit ?? cat?.limits?.contextTokens, maxOutputTokens: m.outputTokenLimit ?? cat?.limits?.maxOutputTokens }, parameters: cat?.parameters ?? { temperature: true, topP: true, topK: true, seed: true, stop: true, maxTokens: true, frequencyPenalty: false, presencePenalty: false, reasoningEffort: thinking, reasoningEffortLevels: thinking ? ["low", "medium", "high"] : undefined, thinkingBudget: false, temperatureRange: { min: 0, max: Math.min(2, m.maxTemperature ?? 2) } }, status: gone ? "deprecated" : cat?.status ?? (id.includes("preview") ? "preview" : "unknown"), pricing: cat?.pricing ?? null, metadata: { thinkingProfile: thinking ? { mode: "level", levels: ["low", "medium", "high"], none: null } : { mode: "none" }, ...(cat?.metadata ?? {}), version: m.version, description: m.description, defaultTemperature: m.temperature, sortWeight: cat?.sortWeight ?? 0 }, }; } // --------------------------------------------------------------------------- // Request translation // --------------------------------------------------------------------------- function toContents(messages: UnifiedMessage[]): Content[] { const out: Content[] = []; const push = (role: "user" | "model", parts: Part[]) => { if (!parts.length) return; const last = out[out.length - 1]; if (last && last.role === role) last.parts = [...(last.parts ?? []), ...parts]; else out.push({ role, parts }); }; for (const m of messages) { if (m.role === "system") continue; if (m.role === "tool") { const parts: Part[] = []; for (const p of m.content) if (p.type === "tool-result") parts.push({ functionResponse: { id: p.toolCallId, name: p.name, response: typeof p.result === "object" && p.result !== null ? (p.result as Record) : { result: p.result } } }); push("user", parts); continue; } const parts: Part[] = []; const toolResults: Part[] = []; for (const p of m.content) { switch (p.type) { case "text": if (p.text) parts.push({ text: p.text }); break; case "image": parts.push({ inlineData: { mimeType: p.mimeType, data: p.data } }); break; case "file": if (p.mimeType === "application/pdf") parts.push({ inlineData: { mimeType: "application/pdf", data: p.data } }); else if (isTextLike(p.mimeType, p.name)) parts.push({ text: inlineTextFile(p.name, p.data) }); else parts.push({ text: `[Attached file "${p.name}" (${p.mimeType}) — unsupported binary type]` }); break; case "tool-call": { // Gemini 3.x rejects replayed function calls without a thought signature; the documented // escape hatch for calls that came from another provider is `skip_thought_signature_validator`. const sig = (p.providerData as { thoughtSignature?: string } | undefined)?.thoughtSignature ?? "skip_thought_signature_validator"; parts.push({ functionCall: { id: p.id, name: p.name, args: p.arguments }, thoughtSignature: sig }); break; } case "tool-result": toolResults.push({ functionResponse: { id: p.toolCallId, name: p.name, response: typeof p.result === "object" && p.result !== null ? (p.result as Record) : { result: p.result } } }); break; case "reasoning": // Thought summaries are not replayed; signatures travel on function-call parts. break; } } push(m.role === "assistant" ? "model" : "user", parts); if (toolResults.length) push("user", toolResults); } while (out.length && out[0].role !== "user") out.shift(); return out; } function buildConfig(req: UnifiedChatRequest): GenerateContentConfig { const { settings } = filterSettings(req.settings, req.modelInfo); const meta = (req.modelInfo?.metadata ?? {}) as Record; const config: GenerateContentConfig = {}; if (req.system?.trim()) config.systemInstruction = req.system; if (settings.temperature !== undefined) config.temperature = settings.temperature; if (settings.topP !== undefined) config.topP = settings.topP; if (settings.topK !== undefined) config.topK = settings.topK; if (settings.seed !== undefined) config.seed = settings.seed; if (settings.stop?.length) config.stopSequences = settings.stop.slice(0, 5); if (settings.maxTokens !== undefined) config.maxOutputTokens = settings.maxTokens; // Thinking — per-family profile (see catalog.ts). Never send both level and budget. const profile = (meta.thinkingProfile as { mode: "level" | "budget" | "none"; levels?: string[]; none?: string | null } | undefined) ?? (req.modelInfo ? undefined : { mode: "none" as const }); if (req.modelInfo?.capabilities.reasoning && profile && profile.mode !== "none") { const effort = settings.reasoningEffort; const thinking: ThinkingConfig = { includeThoughts: settings.includeReasoning !== false }; let send = true; if (effort === "none") { thinking.includeThoughts = false; if (profile.none === "budget0") thinking.thinkingBudget = 0; else if (profile.none === "omit" || !profile.none) send = false; // off by default / cannot be disabled else (thinking as { thinkingLevel?: string }).thinkingLevel = profile.none; } else if (effort && profile.mode === "level") { const wanted = effort === "minimal" ? "minimal" : effort === "low" ? "low" : effort === "medium" ? "medium" : "high"; const levels = profile.levels ?? ["low", "medium", "high"]; const level = levels.includes(wanted) ? wanted : wanted === "minimal" ? levels[0] : levels[levels.length - 1]; (thinking as { thinkingLevel?: string }).thinkingLevel = level.toUpperCase(); } else if (effort && profile.mode === "budget") { thinking.thinkingBudget = effort === "minimal" || effort === "low" ? 1024 : effort === "medium" ? 8192 : 24_576; } else if (settings.thinkingBudget !== undefined && profile.mode === "budget") { thinking.thinkingBudget = settings.thinkingBudget; } if (send) config.thinkingConfig = thinking; } if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) { config.responseMimeType = "application/json"; config.responseJsonSchema = settings.responseFormat.schema; } else if (settings.responseFormat?.type === "json") { config.responseMimeType = "application/json"; } const tools: Tool[] = []; if (req.tools?.length) { tools.push({ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, parametersJsonSchema: t.parameters })) }); const tc = settings.toolChoice; if (tc === "none") config.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.NONE } }; else if (tc === "required") config.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.ANY } }; else if (tc && typeof tc === "object") config.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.ANY, allowedFunctionNames: [tc.name] } }; } // Function declarations cannot be combined with built-in tools on most models; prefer the user's functions. if (!req.tools?.length) { if (settings.webSearch) tools.push({ googleSearch: {} }); if (settings.codeExecution) tools.push({ codeExecution: {} }); } if (tools.length) config.tools = tools; return config; } function mapFinish(reason: string | undefined, sawTool: boolean): FinishReason { if (sawTool) return "tool-calls"; switch (reason) { case "STOP": return "stop"; case "MAX_TOKENS": return "length"; case "SAFETY": case "RECITATION": case "PROHIBITED_CONTENT": case "SPII": case "BLOCKLIST": case "IMAGE_SAFETY": return "content-filter"; case undefined: return "stop"; default: return "other"; } } // --------------------------------------------------------------------------- // Adapter // --------------------------------------------------------------------------- export const geminiAdapter: AIProviderAdapter = { id: "gemini", name: "Google Gemini", keyDocsUrl: "https://aistudio.google.com/apikey", keyPrefixHint: "AIza / AQ.", async validateApiKey(apiKey, signal): Promise { const t0 = Date.now(); try { const pager = await client(apiKey, 20_000).models.list({ config: { pageSize: 200, httpOptions: { timeout: 20_000 } } }); let n = 0; for await (const m of pager) { if (signal?.aborted) break; if (normalizeGeminiModel(m)) n++; } return { ok: true, modelsAvailable: n, latencyMs: Date.now() - t0 }; } catch (e) { return { ok: false, error: this.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 }; } }, async listModels(apiKey): Promise { try { const out: PolyModel[] = []; const pager = await client(apiKey, 30_000).models.list({ config: { pageSize: 200 } }); for await (const m of pager) { const n = normalizeGeminiModel(m); if (n) out.push(n); } return out; } catch (e) { throw this.normalizeError(e); } }, async chat(req): Promise { return collectStream("gemini", req.model, this.streamChat(req)); }, async *streamChat(req: UnifiedChatRequest): AsyncIterable { let stream: AsyncGenerator; try { stream = await client(req.apiKey, req.timeoutMs).models.generateContentStream({ model: req.model, contents: toContents(req.messages), config: { ...buildConfig(req), abortSignal: req.signal } }); } catch (e) { yield { type: "error", error: this.normalizeError(e).toJSON() }; return; } let started = false; let sawTool = false; let finish: string | undefined; let lastUsage: GenerateContentResponse["usageMetadata"] | undefined; let toolIdx = 0; const seenCitations = new Set(); let blockReason: string | undefined; try { for await (const chunk of stream) { if (!started) { started = true; yield { type: "start", id: chunk.responseId, model: chunk.modelVersion }; } if (chunk.promptFeedback?.blockReason) blockReason = chunk.promptFeedback.blockReason; const cand = chunk.candidates?.[0]; for (const part of cand?.content?.parts ?? []) { if (part.text !== undefined && part.text !== "") { if (part.thought) yield { type: "reasoning-delta", text: part.text }; else yield { type: "text-delta", text: part.text }; } if (part.functionCall) { sawTool = true; const id = part.functionCall.id ?? `call_${Date.now()}_${toolIdx++}`; const name = part.functionCall.name ?? ""; const argsText = JSON.stringify(part.functionCall.args ?? {}); yield { type: "tool-start", id, name }; yield { type: "tool-end", id, name, arguments: (part.functionCall.args ?? {}) as Record, argumentsText: argsText, providerData: part.thoughtSignature ? { thoughtSignature: part.thoughtSignature } : undefined }; } if (part.executableCode) yield { type: "server-tool", name: "code_execution", status: "started", data: { language: part.executableCode.language } }; if (part.codeExecutionResult) yield { type: "server-tool", name: "code_execution", status: "completed", data: { outcome: part.codeExecutionResult.outcome, output: part.codeExecutionResult.output?.slice(0, 2000) } }; } const gm = cand?.groundingMetadata; if (gm?.groundingChunks?.length) { if (gm.webSearchQueries?.length) yield { type: "server-tool", name: "web_search", status: "completed", data: { queries: gm.webSearchQueries } }; for (const g of gm.groundingChunks) { const uri = g.web?.uri; if (uri && !seenCitations.has(uri)) { seenCitations.add(uri); yield { type: "citation", citation: { url: uri, title: g.web?.title ?? g.web?.domain ?? undefined, source: "google_search" } }; } } } if (cand?.finishReason) finish = String(cand.finishReason); if (chunk.usageMetadata) lastUsage = chunk.usageMetadata; } if (blockReason && !finish) { yield { type: "error", error: { code: "CONTENT_REJECTED", message: `Prompt blocked (${blockReason})`, provider: "gemini", retryable: false, providerCode: blockReason } }; return; } const u = lastUsage; const input = u?.promptTokenCount ?? 0; const thoughts = u?.thoughtsTokenCount ?? 0; const output = (u?.candidatesTokenCount ?? 0) + thoughts; yield { type: "usage", usage: { inputTokens: input, outputTokens: output, cachedInputTokens: u?.cachedContentTokenCount ?? undefined, reasoningTokens: thoughts || undefined, totalTokens: u?.totalTokenCount ?? input + output } }; yield { type: "finish", reason: mapFinish(finish, sawTool) }; } catch (e) { yield { type: "error", error: this.normalizeError(e).toJSON() }; } }, async estimateTokens(req): Promise { try { const res = await client(req.apiKey, 20_000).models.countTokens({ model: req.model, contents: toContents(req.messages) }); return { inputTokens: res.totalTokens ?? 0, method: "provider" }; } catch { const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? ""); return { inputTokens: heuristicTokens(text), method: "heuristic" }; } }, normalizeError(error: unknown): PolyProviderError { if (error instanceof PolyProviderError) return error; if (error instanceof ApiError || (error && typeof error === "object" && "status" in error && typeof (error as { status: unknown }).status === "number")) { const e = error as { status: number; message: string }; let message = e.message ?? "Gemini error"; let googleStatus: string | undefined; // The SDK sometimes puts the raw JSON body in `message`. try { const parsed = JSON.parse(message) as { error?: { message?: string; status?: string } }; if (parsed?.error) { message = parsed.error.message ?? message; googleStatus = parsed.error.status; } } catch { /* plain text */ } let code = codeFromStatus(e.status); if (e.status === 401 || e.status === 403 || /api key not valid|api_key_invalid|unauthenticated|unregistered callers|permission_denied|API key/i.test(message)) code = "INVALID_API_KEY"; else if (e.status === 404 && /no longer available|not found|is not supported/i.test(message)) code = "MODEL_NOT_FOUND"; else if (e.status === 429) code = /limit: 0\b/.test(message) ? "INSUFFICIENT_CREDITS" : "RATE_LIMITED"; else if (e.status === 503 || googleStatus === "UNAVAILABLE" || /high demand/i.test(message)) code = "PROVIDER_UNAVAILABLE"; else if (e.status === 400) code = refineByMessage("INVALID_PARAMETER", message); else code = refineByMessage(code, message); const retryAfter = message.match(/retry in ([\d.]+)s/i); return new PolyProviderError({ code, message: code === "INVALID_API_KEY" ? "Invalid API key" : message.replace(/\s+/g, " ").slice(0, 600), provider: "gemini", status: e.status, retryable: (isRetryableStatus(e.status) || code === "PROVIDER_UNAVAILABLE") && code !== "INSUFFICIENT_CREDITS", retryAfterMs: retryAfter ? Math.min(60_000, Math.round(Number(retryAfter[1]) * 1000)) : undefined, providerCode: googleStatus, cause: error, }); } return normalizeGenericError("gemini", error); }, };