openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
530 lines (529 loc) • 19.4 kB
JavaScript
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 };