UNPKG

@earendil-works/pi-ai

Version:

Unified LLM API with automatic model discovery and provider configuration

508 lines • 21.2 kB
/** * Shared utilities for Google Generative AI and Google Vertex providers. */ import { FinishReason, FunctionCallingConfigMode, ThinkingLevel as GoogleSdkThinkingLevel, } from "@google/genai"; import { clampThinkingLevel } from "../models.js"; import { retryProviderRequest } from "../utils/provider-retry.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { collapseSystemMessages, withoutInitialSystemMessage } from "../utils/transcript.js"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.js"; import { transformMessages } from "./transform-messages.js"; const GOOGLE_SDK_THINKING_LEVEL_MAP = { THINKING_LEVEL_UNSPECIFIED: GoogleSdkThinkingLevel.THINKING_LEVEL_UNSPECIFIED, MINIMAL: GoogleSdkThinkingLevel.MINIMAL, LOW: GoogleSdkThinkingLevel.LOW, MEDIUM: GoogleSdkThinkingLevel.MEDIUM, HIGH: GoogleSdkThinkingLevel.HIGH, }; /** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */ export function resolveGoogleThinkingLevel(model, level) { const mapped = model.thinkingLevelMap?.[level]; const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level; switch (resolvedLevel) { case "minimal": case "low": case "medium": case "high": return resolvedLevel; default: throw new Error(`Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`); } } /** * Whether this model uses Gemini's discrete `thinkingLevel` control instead of * the token-based `thinkingBudget` control. Supported levels come from the * model's `thinkingLevelMap`; this only selects the Google wire format. */ export function usesGoogleThinkingLevel(model) { const id = model.id.toLowerCase(); return ( // Match Gemini 3 Pro/Flash IDs with or without a minor version, such as // gemini-3-flash-preview, gemini-3.1-pro-preview, and gemini-3.8-flash. /gemini-3(?:\.\d+)?-(?:pro|flash)/.test(id) || // Match Gemini 4 and later, such as gemini-4-argon (#10890). /gemini-(?:[4-9]|[1-9]\d)/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest" || // Match both hosted Gemma 4 naming forms: gemma-4-* and gemma4-*. /gemma-?4/.test(id)); } export function toGoogleThinkingLevel(level) { switch (level) { case "minimal": return "MINIMAL"; case "low": return "LOW"; case "medium": return "MEDIUM"; case "high": return "HIGH"; } } export function toGoogleSdkThinkingLevel(level) { return GOOGLE_SDK_THINKING_LEVEL_MAP[level]; } export function getDisabledGoogleThinkingConfig(model) { if (!usesGoogleThinkingLevel(model)) return { thinkingBudget: 0 }; const fallback = clampThinkingLevel(model, "off"); if (fallback === "off") return { thinkingBudget: 0 }; const resolvedLevel = resolveGoogleThinkingLevel(model, fallback); const apiLevel = toGoogleThinkingLevel(resolvedLevel); return { thinkingLevel: toGoogleSdkThinkingLevel(apiLevel) }; } /** * 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 */ export 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. */ export function retainThoughtSignature(existing, incoming) { if (typeof incoming === "string" && incoming.length > 0) return incoming; return existing; } // Thought signatures must be base64 for Google APIs (TYPE_BYTES). 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 : undefined; } /** * Models via Google APIs that require explicit tool call IDs in function calls/responses. */ export function requiresToolCallId(modelId) { const geminiMajorVersion = getGeminiMajorVersion(modelId); return (modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-") || (geminiMajorVersion !== undefined && geminiMajorVersion >= 3)); } function getGeminiMajorVersion(modelId) { const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); if (!match) return undefined; return Number.parseInt(match[1], 10); } function supportsMultimodalFunctionResponse(modelId) { const geminiMajorVersion = getGeminiMajorVersion(modelId); if (geminiMajorVersion !== undefined) { return geminiMajorVersion >= 3; } return true; } /** * Convert internal messages to Gemini Content[] format. */ export function convertMessages(model, context) { // Gemini has no mid-conversation system messages; the leading prompt is sent as systemInstruction. const conversation = withoutInitialSystemMessage(collapseSystemMessages(context).messages); 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(conversation, 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) }; } else { 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 = []; // Check if message is from same provider and model - only then keep thinking blocks const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id; for (const block of msg.content) { if (block.type === "text") { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature); // Skip empty text blocks — unless they carry a thought signature. Gemini can attach // the signature to a part whose visible text is empty and requires it echoed back; // dropping it breaks the reasoning chain and the model intermittently ends mid-task // turns with a thought-only STOP (empty completion, no tool call). if ((!block.text || block.text.trim() === "") && !thoughtSignature) continue; parts.push({ text: sanitizeSurrogates(block.text), ...(thoughtSignature && { thoughtSignature }), }); } else if (block.type === "thinking") { // Only keep as thinking block if same provider AND same model // Otherwise convert to plain text (no tags to avoid model mimicking them) if (isSameProviderAndModel) { const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature); // Same rule as text blocks: an empty thinking block is dropped only when it // carries no signature (mirrors the anthropic converter's handling). if ((!block.thinking || block.thinking.trim() === "") && !thoughtSignature) continue; parts.push({ thought: true, text: sanitizeSurrogates(block.thinking), ...(thoughtSignature && { thoughtSignature }), }); } else { // Cross-provider/model: the signature is unusable, empty blocks stay dropped. if (!block.thinking || block.thinking.trim() === "") continue; 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") { // Extract text and image content const textContent = msg.content.filter((c) => c.type === "text"); const textResult = textContent.map((c) => c.text).join("\n"); const imageContent = model.input.includes("image") ? msg.content.filter((c) => c.type === "image") : []; const hasText = textResult.trim().length > 0; const hasImages = imageContent.length > 0; // Gemini 3+ models support multimodal function responses with images nested inside // functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist / // Gemini < 3 still needs a separate user image turn. const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); // Use "output" key for success, "error" key for errors as per SDK documentation 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 } : {}), }, }; // Cloud Code Assist API requires all function responses to be in a single user turn. // Check if the last content is already a user turn with function responses and merge. 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], }); } // For Gemini < 3, add images in a separate user message 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", // pre-draft-2019-09 equivalent of $defs ]); /** * 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; } /** Schema keywords whose values map names to subschemas, so their keys are not schema keywords. */ const JSON_SCHEMA_NAME_MAPS = new Set([ "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies", ]); /** Keywords containing a subschema or an array of subschemas, not literal data. */ const JSON_SCHEMA_SUBSCHEMAS = new Set([ "additionalProperties", "unevaluatedProperties", "propertyNames", "items", "prefixItems", "additionalItems", "unevaluatedItems", "contains", "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "contentSchema", ]); /** * Rewrite string and number `const` values to single-value `enum`s. * Gemini ignores `const` in `parametersJsonSchema` but honors `enum`. */ function rewriteConstAsEnum(schema, isNameMap = false) { if (Array.isArray(schema)) return schema.map((item) => rewriteConstAsEnum(item)); if (typeof schema !== "object" || schema === null) return schema; // fromEntries preserves names such as __proto__ as own properties. const result = Object.fromEntries(Object.entries(schema).map(([key, value]) => { if (isNameMap || JSON_SCHEMA_SUBSCHEMAS.has(key)) return [key, rewriteConstAsEnum(value)]; if (JSON_SCHEMA_NAME_MAPS.has(key)) return [key, rewriteConstAsEnum(value, true)]; return [key, value]; })); if (!isNameMap && "const" in result && result.enum === undefined) { const value = result.const; if (typeof value === "string" || typeof value === "number") { delete result.const; result.enum = [value]; } } return result; } /** * Convert tools to Gemini function declarations format. * * By default uses `parametersJsonSchema` which supports full JSON Schema (including * anyOf, oneOf, etc.). Gemini ignores `const` there, so string and number `const` * values are sent as single-value `enum`s. 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`. */ export function convertTools(tools, useParameters = false, supportsStrictMode = true) { if (tools.length === 0) return undefined; return [ { functionDeclarations: tools.map((tool) => { const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode); const parameters = getJsonSchemaToolParameters(tool, strict); return { name: tool.name, description: tool.description, ...(useParameters ? { parameters: sanitizeForOpenApi(parameters) } : { parametersJsonSchema: rewriteConstAsEnum(parameters) }), }; }), }, ]; } /** Gemini 3+ enforces required function parameters in validated tool-calling modes. */ export function supportsGoogleStrictToolSampling(modelId) { const majorVersion = getGeminiMajorVersion(modelId); return majorVersion !== undefined && majorVersion >= 3; } /** Map tool choice string to Gemini FunctionCallingConfigMode. */ export function mapToolChoice(choice) { switch (choice) { case "auto": return FunctionCallingConfigMode.AUTO; case "none": return FunctionCallingConfigMode.NONE; case "any": return FunctionCallingConfigMode.ANY; default: return FunctionCallingConfigMode.AUTO; } } export function resolveGoogleFunctionCallingMode(tools, toolChoice, supportsStrictMode) { const useStrictMode = tools.some((tool) => resolveJsonSchemaStrictSampling(tool, supportsStrictMode) === true); if (toolChoice === "none" || toolChoice === "any") { return mapToolChoice(toolChoice); } if (useStrictMode) { return FunctionCallingConfigMode.VALIDATED; } return toolChoice ? mapToolChoice(toolChoice) : undefined; } /** * Map Gemini FinishReason to our StopReason. */ export 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.TOO_MANY_TOOL_CALLS: case FinishReason.NO_IMAGE: return "error"; default: { const _exhaustive = reason; throw new Error(`Unhandled stop reason: ${_exhaustive}`); } } } /** * Map string finish reason to our StopReason (for raw API responses). */ export function mapStopReasonString(reason) { switch (reason) { case "STOP": return "stop"; case "MAX_TOKENS": return "length"; default: return "error"; } } /** * Run a Google GenAI SDK request with the shared provider retry policy * (408/409/429/5xx with backoff, honoring retry-after), mirroring how the * Anthropic and OpenAI adapters wrap their initial request in * retryProviderRequest. The SDK's ApiError has a `status` property but no * `headers` property, and retryProviderRequest only retries errors that carry * both, so normalize the error by adding the missing `headers` before * rethrowing. */ export function retryGoogleRequest(request, options) { return retryProviderRequest(async () => { try { return await request(); } catch (error) { if (error instanceof Error && "status" in error && !("headers" in error)) { error.headers = undefined; } throw error; } }, { maxRetries: options?.maxRetries, maxRetryDelayMs: options?.maxRetryDelayMs, signal: options?.signal, }); } //# sourceMappingURL=google-shared.js.map