UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

530 lines (529 loc) 19.4 kB
import { l as calculateCost, n as transformMessages, t as sanitizeSurrogates, u as clampThinkingLevel } from "./sanitize-unicode-CgzZKr22.js"; import { FinishReason, FunctionCallingConfigMode } from "@google/genai"; //#region src/llm/providers/google-shared.ts /** * Shared utilities for Google Generative AI and Google Vertex providers. */ /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". * * Protocol note (Gemini / Vertex AI thought signatures): * - `thought: true` is the definitive marker for thinking content (thought summaries). * - `thoughtSignature` is an encrypted representation of the model's internal thought process * used to preserve reasoning context across multi-turn interactions. * - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT * indicate the part itself is thinking content. * - For non-functionCall responses, the signature appears on the last part for context replay. * - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is; * do not merge/move signatures across parts. * * See: https://ai.google.dev/gemini-api/docs/thought-signatures */ function isThinkingPart(part) { return part.thought === true; } /** * Retain thought signatures during streaming. * * Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it. * This helper preserves the last non-empty signature for the current block. * * Note: this does NOT merge or move signatures across distinct response parts. It only prevents * a signature from being overwritten with `undefined` within the same streamed block. */ function retainThoughtSignature(existing, incoming) { if (typeof incoming === "string" && incoming.length > 0) return incoming; return existing; } const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/; function isValidThoughtSignature(signature) { if (!signature) return false; if (signature.length % 4 !== 0) return false; return base64SignaturePattern.test(signature); } /** * Only keep signatures from the same provider/model and with valid base64. */ function resolveThoughtSignature(isSameProviderAndModel, signature) { return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : void 0; } /** * Models via Google APIs that require explicit tool call IDs in function calls/responses. */ function requiresToolCallId(modelId) { return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-"); } function getGeminiMajorVersion(modelId) { const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); if (!match) return; return Number.parseInt(match[1], 10); } function supportsMultimodalFunctionResponse(modelId) { const geminiMajorVersion = getGeminiMajorVersion(modelId); if (geminiMajorVersion !== void 0) return geminiMajorVersion >= 3; return true; } /** * Convert internal messages to Gemini Content[] format. */ function convertMessages(model, context) { const contents = []; const normalizeToolCallId = (id) => { if (!requiresToolCallId(model.id)) return id; return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); }; const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId); for (const msg of transformedMessages) if (msg.role === "user") if (typeof msg.content === "string") contents.push({ role: "user", parts: [{ text: sanitizeSurrogates(msg.content) }] }); else { const parts = msg.content.map((item) => { if (item.type === "text") return { text: sanitizeSurrogates(item.text) }; return { inlineData: { mimeType: item.mimeType, data: item.data } }; }); if (parts.length === 0) continue; contents.push({ role: "user", parts }); } else if (msg.role === "assistant") { const parts = []; const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id; for (const block of msg.content) if (block.type === "text") { if (!block.text || block.text.trim() === "") continue; const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature); parts.push({ text: sanitizeSurrogates(block.text), ...thoughtSignature && { thoughtSignature } }); } else if (block.type === "thinking") { if (!block.thinking || block.thinking.trim() === "") continue; if (isSameProviderAndModel) { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature); parts.push({ thought: true, text: sanitizeSurrogates(block.thinking), ...thoughtSignature && { thoughtSignature } }); } else parts.push({ text: sanitizeSurrogates(block.thinking) }); } else if (block.type === "toolCall") { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); const part = { functionCall: { name: block.name, args: block.arguments ?? {}, ...requiresToolCallId(model.id) ? { id: block.id } : {} }, ...thoughtSignature && { thoughtSignature } }; parts.push(part); } if (parts.length === 0) continue; contents.push({ role: "model", parts }); } else if (msg.role === "toolResult") { const textResult = msg.content.filter((c) => c.type === "text").map((c) => c.text).join("\n"); const imageContent = model.input.includes("image") ? msg.content.filter((c) => c.type === "image") : []; const hasText = textResult.length > 0; const hasImages = imageContent.length > 0; const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : ""; const imageParts = imageContent.map((imageBlock) => ({ inlineData: { mimeType: imageBlock.mimeType, data: imageBlock.data } })); const includeId = requiresToolCallId(model.id); const functionResponsePart = { functionResponse: { name: msg.toolName, response: msg.isError ? { error: responseValue } : { output: responseValue }, ...hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }, ...includeId ? { id: msg.toolCallId } : {} } }; const lastContent = contents[contents.length - 1]; if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) lastContent.parts.push(functionResponsePart); else contents.push({ role: "user", parts: [functionResponsePart] }); if (hasImages && !modelSupportsMultimodalFunctionResponse) contents.push({ role: "user", parts: [{ text: "Tool result image:" }, ...imageParts] }); } return contents; } const JSON_SCHEMA_META_DECLARATIONS = new Set([ "$schema", "$id", "$anchor", "$dynamicAnchor", "$vocabulary", "$comment", "$defs", "definitions" ]); /** * Strip meta-declarations from a schema obj */ function sanitizeForOpenApi(schema) { if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return schema; const result = {}; for (const [key, value] of Object.entries(schema)) { if (JSON_SCHEMA_META_DECLARATIONS.has(key)) continue; result[key] = sanitizeForOpenApi(value); } return result; } /** * Convert tools to Gemini function declarations format. * * By default uses `parametersJsonSchema` which supports full JSON Schema (including * anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters` * field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude * models, where the API translates `parameters` into Anthropic's `input_schema`. */ function convertTools(tools, useParameters = false) { if (tools.length === 0) return; return [{ functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, ...useParameters ? { parameters: sanitizeForOpenApi(tool.parameters) } : { parametersJsonSchema: tool.parameters } })) }]; } /** * Map tool choice string to Gemini FunctionCallingConfigMode. */ function mapToolChoice(choice) { switch (choice) { case "auto": return FunctionCallingConfigMode.AUTO; case "none": return FunctionCallingConfigMode.NONE; case "any": return FunctionCallingConfigMode.ANY; default: return FunctionCallingConfigMode.AUTO; } } function createGoogleAssistantOutput(model, api = model.api) { return { role: "assistant", content: [], api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() }; } async function runGoogleGenerateContentLifecycle(params) { const { stream, model, output, options } = params; try { const client = params.createClient(); let requestParams = params.buildParams(); const nextParams = await options?.onPayload?.(requestParams, model); if (nextParams !== void 0) requestParams = nextParams; await consumeGoogleGenerateContentStream({ chunks: await client.models.generateContentStream(requestParams), model, output, stream, signal: options?.signal, nextToolCallId: params.nextToolCallId }); } catch (error) { for (const block of output.content) if ("index" in block) delete block.index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } } function buildGoogleGenerateContentParams(model, context, options = {}, configHooks) { const contents = convertMessages(model, context); const generationConfig = {}; if (options.temperature !== void 0) generationConfig.temperature = options.temperature; if (options.maxTokens !== void 0) generationConfig.maxOutputTokens = options.maxTokens; if (options.stop !== void 0 && options.stop.length > 0) generationConfig.stopSequences = options.stop; const config = { ...Object.keys(generationConfig).length > 0 && generationConfig, ...context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }, ...context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) } }; if (context.tools && context.tools.length > 0 && options.toolChoice) config.toolConfig = { functionCallingConfig: { mode: mapToolChoice(options.toolChoice) } }; else config.toolConfig = void 0; if (options.thinking?.enabled && model.reasoning) { const thinkingConfig = { includeThoughts: true }; if (options.thinking.level !== void 0) thinkingConfig.thinkingLevel = configHooks?.mapThinkingLevel ? configHooks.mapThinkingLevel(options.thinking.level) : options.thinking.level; else if (options.thinking.budgetTokens !== void 0) thinkingConfig.thinkingBudget = options.thinking.budgetTokens; config.thinkingConfig = thinkingConfig; } else if (model.reasoning && options.thinking && !options.thinking.enabled) config.thinkingConfig = configHooks?.getDisabledThinkingConfig ? configHooks.getDisabledThinkingConfig(model) : getDisabledGoogleThinkingConfig(model); if (options.signal) { if (options.signal.aborted) throw new Error("Request aborted"); config.abortSignal = options.signal; } return { model: model.id, contents, config }; } function buildGoogleSimpleThinking(model, options, config) { if (!options?.reasoning) return { enabled: false }; const clampedReasoning = clampThinkingLevel(model, options.reasoning); const effort = clampedReasoning === "off" || clampedReasoning === "max" ? "high" : clampedReasoning; if (isGemini3ProModel(model) || isGemini3FlashModel(model) || config?.includeGemma4ThinkingLevel && isGemma4Model(model)) return { enabled: true, level: getGoogleThinkingLevel(effort, model, { includeGemma4: config?.includeGemma4ThinkingLevel }) }; return { enabled: true, budgetTokens: getGoogleBudget(model, effort, options.thinkingBudgets, { useFlashLiteBudgets: config?.useFlashLiteBudgets }) }; } function getDisabledGoogleThinkingConfig(model, config) { const mapThinkingLevel = (level) => config?.mapThinkingLevel ? config.mapThinkingLevel(level) : level; if (isGemini3ProModel(model)) return { thinkingLevel: mapThinkingLevel("LOW") }; if (isGemini3FlashModel(model)) return { thinkingLevel: mapThinkingLevel("MINIMAL") }; if (config?.includeGemma4 && isGemma4Model(model)) return { thinkingLevel: mapThinkingLevel("MINIMAL") }; return { thinkingBudget: 0 }; } function isGemma4Model(model) { return /gemma-?4/.test(model.id.toLowerCase()); } function isGemini3ProModel(model) { return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); } function isGemini3FlashModel(model) { return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); } function getGoogleThinkingLevel(effort, model, config) { if (isGemini3ProModel(model)) switch (effort) { case "minimal": case "low": return "LOW"; case "medium": case "high": return "HIGH"; } if (config?.includeGemma4 && isGemma4Model(model)) switch (effort) { case "minimal": case "low": return "MINIMAL"; case "medium": case "high": return "HIGH"; } switch (effort) { case "minimal": return "MINIMAL"; case "low": return "LOW"; case "medium": return "MEDIUM"; case "high": return "HIGH"; } return "HIGH"; } function getGoogleBudget(model, effort, customBudgets, config) { if (customBudgets?.[effort] !== void 0) return customBudgets[effort]; if (model.id.includes("2.5-pro")) return { minimal: 128, low: 2048, medium: 8192, high: 32768 }[effort]; if (config?.useFlashLiteBudgets && model.id.includes("2.5-flash-lite")) return { minimal: 512, low: 2048, medium: 8192, high: 24576 }[effort]; if (model.id.includes("2.5-flash")) return { minimal: 128, low: 2048, medium: 8192, high: 24576 }[effort]; return -1; } /** * Map Gemini FinishReason to our StopReason. */ function mapStopReason(reason) { switch (reason) { case FinishReason.STOP: return "stop"; case FinishReason.MAX_TOKENS: return "length"; case FinishReason.BLOCKLIST: case FinishReason.PROHIBITED_CONTENT: case FinishReason.SPII: case FinishReason.SAFETY: case FinishReason.IMAGE_SAFETY: case FinishReason.IMAGE_PROHIBITED_CONTENT: case FinishReason.IMAGE_RECITATION: case FinishReason.IMAGE_OTHER: case FinishReason.RECITATION: case FinishReason.FINISH_REASON_UNSPECIFIED: case FinishReason.OTHER: case FinishReason.LANGUAGE: case FinishReason.MALFORMED_FUNCTION_CALL: case FinishReason.UNEXPECTED_TOOL_CALL: case FinishReason.NO_IMAGE: return "error"; default: throw new Error(`Unhandled stop reason: ${String(reason)}`); } } async function consumeGoogleGenerateContentStream(params) { params.stream.push({ type: "start", partial: params.output }); let currentBlock = null; const blocks = params.output.content; const blockIndex = () => blocks.length - 1; const endCurrentBlock = () => { if (!currentBlock) return; if (currentBlock.type === "text") params.stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: params.output }); else params.stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: params.output }); currentBlock = null; }; for await (const chunk of params.chunks) { params.output.responseId ||= chunk.responseId; const candidate = chunk.candidates?.[0]; if (candidate?.content?.parts) for (const part of candidate.content.parts) { if (part.text !== void 0) { const isThinking = isThinkingPart(part); if (!currentBlock || isThinking && currentBlock.type !== "thinking" || !isThinking && currentBlock.type !== "text") { endCurrentBlock(); if (isThinking) { currentBlock = { type: "thinking", thinking: "", thinkingSignature: void 0 }; params.output.content.push(currentBlock); params.stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: params.output }); } else { currentBlock = { type: "text", text: "" }; params.output.content.push(currentBlock); params.stream.push({ type: "text_start", contentIndex: blockIndex(), partial: params.output }); } } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; currentBlock.thinkingSignature = retainThoughtSignature(currentBlock.thinkingSignature, part.thoughtSignature); params.stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta: part.text, partial: params.output }); } else { currentBlock.text += part.text; currentBlock.textSignature = retainThoughtSignature(currentBlock.textSignature, part.thoughtSignature); params.stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: part.text, partial: params.output }); } } if (part.functionCall) { endCurrentBlock(); const providedId = part.functionCall.id; const toolCall = { type: "toolCall", id: !providedId || params.output.content.some((block) => block.type === "toolCall" && block.id === providedId) ? params.nextToolCallId(part.functionCall.name) : providedId, name: part.functionCall.name || "", arguments: part.functionCall.args ?? {}, ...part.thoughtSignature && { thoughtSignature: part.thoughtSignature } }; params.output.content.push(toolCall); params.stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: params.output }); params.stream.push({ type: "toolcall_delta", contentIndex: blockIndex(), delta: JSON.stringify(toolCall.arguments), partial: params.output }); params.stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: params.output }); } } if (candidate?.finishReason) { params.output.stopReason = mapStopReason(candidate.finishReason); if (params.output.content.some((block) => block.type === "toolCall")) params.output.stopReason = "toolUse"; } if (chunk.usageMetadata) { params.output.usage = { input: (chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0), output: (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, cacheWrite: 0, totalTokens: chunk.usageMetadata.totalTokenCount || 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; calculateCost(params.model, params.output.usage); } } endCurrentBlock(); if (params.signal?.aborted) throw new Error("Request was aborted"); if (params.output.stopReason === "aborted" || params.output.stopReason === "error") throw new Error("An unknown error occurred"); params.stream.push({ type: "done", reason: params.output.stopReason, message: params.output }); params.stream.end(); } //#endregion export { runGoogleGenerateContentLifecycle as a, getDisabledGoogleThinkingConfig as i, buildGoogleSimpleThinking as n, createGoogleAssistantOutput as r, buildGoogleGenerateContentParams as t };