Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
/**
|
|
* Shared utilities for Google Generative AI and Google Vertex providers.
|
|
*/
|
|
|
|
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
|
import type {
|
|
Context,
|
|
ImageContent,
|
|
Model,
|
|
ModelThinkingLevel,
|
|
StopReason,
|
|
StreamOptions,
|
|
TextContent,
|
|
ThinkingLevel,
|
|
Tool,
|
|
} from "../types.ts";
|
|
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
|
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
|
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
|
import { transformMessages } from "./transform-messages.ts";
|
|
|
|
type GoogleApiType = "google-generative-ai" | "google-vertex";
|
|
|
|
/**
|
|
* Thinking level for Gemini 3 models.
|
|
* Mirrors Google's ThinkingLevel enum values.
|
|
*/
|
|
export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
|
export type ResolvedGoogleThinkingLevel = Exclude<ThinkingLevel, "xhigh" | "max">;
|
|
|
|
/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */
|
|
export function resolveGoogleThinkingLevel<T extends GoogleApiType>(
|
|
model: Model<T>,
|
|
level: ModelThinkingLevel,
|
|
): ResolvedGoogleThinkingLevel {
|
|
if (level === "off") return "high";
|
|
|
|
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)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: Pick<Part, "thought" | "thoughtSignature">): boolean {
|
|
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: string | undefined, incoming: string | undefined): string | undefined {
|
|
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: string | undefined): boolean {
|
|
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: boolean, signature: string | undefined): string | undefined {
|
|
return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
|
|
}
|
|
|
|
/**
|
|
* Models via Google APIs that require explicit tool call IDs in function calls/responses.
|
|
*/
|
|
export function requiresToolCallId(modelId: string): boolean {
|
|
const geminiMajorVersion = getGeminiMajorVersion(modelId);
|
|
return (
|
|
modelId.startsWith("claude-") ||
|
|
modelId.startsWith("gpt-oss-") ||
|
|
(geminiMajorVersion !== undefined && geminiMajorVersion >= 3)
|
|
);
|
|
}
|
|
|
|
function getGeminiMajorVersion(modelId: string): number | undefined {
|
|
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
|
|
if (!match) return undefined;
|
|
return Number.parseInt(match[1], 10);
|
|
}
|
|
|
|
function supportsMultimodalFunctionResponse(modelId: string): boolean {
|
|
const geminiMajorVersion = getGeminiMajorVersion(modelId);
|
|
if (geminiMajorVersion !== undefined) {
|
|
return geminiMajorVersion >= 3;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Convert internal messages to Gemini Content[] format.
|
|
*/
|
|
export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
|
|
const contents: Content[] = [];
|
|
const normalizeToolCallId = (id: string): string => {
|
|
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: Part[] = 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: Part[] = [];
|
|
// 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: 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 is TextContent => c.type === "text");
|
|
const textResult = textContent.map((c) => c.text).join("\n");
|
|
const imageContent = model.input.includes("image")
|
|
? msg.content.filter((c): c is ImageContent => c.type === "image")
|
|
: [];
|
|
|
|
const hasText = textResult.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: Part[] = imageContent.map((imageBlock) => ({
|
|
inlineData: {
|
|
mimeType: imageBlock.mimeType,
|
|
data: imageBlock.data,
|
|
},
|
|
}));
|
|
|
|
const includeId = requiresToolCallId(model.id);
|
|
const functionResponsePart: Part = {
|
|
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: unknown): unknown {
|
|
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
return schema;
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
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`.
|
|
*/
|
|
export function convertTools(
|
|
tools: Tool[],
|
|
useParameters = false,
|
|
supportsStrictMode = true,
|
|
): { functionDeclarations: Record<string, unknown>[] }[] | undefined {
|
|
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 as unknown) }
|
|
: { parametersJsonSchema: parameters }),
|
|
};
|
|
}),
|
|
},
|
|
];
|
|
}
|
|
|
|
/** Gemini 3+ enforces required function parameters in validated tool-calling modes. */
|
|
export function supportsGoogleStrictToolSampling(modelId: string): boolean {
|
|
const majorVersion = getGeminiMajorVersion(modelId);
|
|
return majorVersion !== undefined && majorVersion >= 3;
|
|
}
|
|
|
|
/** Map tool choice string to Gemini FunctionCallingConfigMode. */
|
|
export function mapToolChoice(choice: string): FunctionCallingConfigMode {
|
|
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: Tool[],
|
|
toolChoice: string | undefined,
|
|
supportsStrictMode: boolean,
|
|
): FunctionCallingConfigMode | undefined {
|
|
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: FinishReason): StopReason {
|
|
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: {
|
|
const _exhaustive: never = reason;
|
|
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map string finish reason to our StopReason (for raw API responses).
|
|
*/
|
|
export function mapStopReasonString(reason: string): StopReason {
|
|
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<T>(
|
|
request: () => Promise<T>,
|
|
options?: Pick<StreamOptions, "maxRetries" | "maxRetryDelayMs" | "signal">,
|
|
): Promise<T> {
|
|
return retryProviderRequest(
|
|
async () => {
|
|
try {
|
|
return await request();
|
|
} catch (error) {
|
|
if (error instanceof Error && "status" in error && !("headers" in error)) {
|
|
(error as { headers?: Headers }).headers = undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
{
|
|
maxRetries: options?.maxRetries,
|
|
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
signal: options?.signal,
|
|
},
|
|
);
|
|
}
|