diff --git a/src/plugin/transform/gemini.ts b/src/plugin/transform/gemini.ts index f98243ba..7bae9bba 100644 --- a/src/plugin/transform/gemini.ts +++ b/src/plugin/transform/gemini.ts @@ -1,26 +1,31 @@ /** * Gemini-specific Request Transformations - * + * * Handles Gemini model-specific request transformations including: * - Thinking config (camelCase keys, thinkingLevel for Gemini 3) * - Tool normalization (function/custom format) * - Schema transformation (JSON Schema -> Gemini Schema format) */ -import type { RequestPayload, ThinkingConfig, ThinkingTier, GoogleSearchConfig } from "./types"; +import type { + RequestPayload, + ThinkingConfig, + ThinkingTier, + GoogleSearchConfig, +} from "./types"; /** * Transform a JSON Schema to Gemini-compatible format. * Based on @google/genai SDK's processJsonSchema() function. - * + * * Key transformations: * - Converts type values to uppercase (object -> OBJECT) * - Removes unsupported fields like additionalProperties, $schema * - Recursively processes nested schemas (properties, items, anyOf, etc.) - * + * * @param schema - A JSON Schema object or primitive value * @returns Gemini-compatible schema - * + * * Fields that Gemini API rejects and must be removed from schemas. * Antigravity uses strict protobuf-backed JSON validation. */ @@ -61,7 +66,9 @@ export function toGeminiSchema(schema: unknown): unknown { // First pass: collect all property names for required validation const propertyNames = new Set(); if (inputSchema.properties && typeof inputSchema.properties === "object") { - for (const propName of Object.keys(inputSchema.properties as Record)) { + for (const propName of Object.keys( + inputSchema.properties as Record, + )) { propertyNames.add(propName); } } @@ -75,17 +82,26 @@ export function toGeminiSchema(schema: unknown): unknown { if (key === "type" && typeof value === "string") { // Convert type to uppercase for Gemini API result[key] = value.toUpperCase(); - } else if (key === "properties" && typeof value === "object" && value !== null) { + } else if ( + key === "properties" && + typeof value === "object" && + value !== null + ) { // Recursively transform nested property schemas const props: Record = {}; - for (const [propName, propSchema] of Object.entries(value as Record)) { + for (const [propName, propSchema] of Object.entries( + value as Record, + )) { props[propName] = toGeminiSchema(propSchema); } result[key] = props; } else if (key === "items" && typeof value === "object") { // Transform array items schema result[key] = toGeminiSchema(value); - } else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) { + } else if ( + (key === "anyOf" || key === "oneOf" || key === "allOf") && + Array.isArray(value) + ) { // Transform union type schemas result[key] = value.map((item) => toGeminiSchema(item)); } else if (key === "enum" && Array.isArray(value)) { @@ -98,8 +114,8 @@ export function toGeminiSchema(schema: unknown): unknown { // Filter required array to only include properties that exist // This fixes: "parameters.required[X]: property is not defined" if (propertyNames.size > 0) { - const validRequired = value.filter((prop) => - typeof prop === "string" && propertyNames.has(prop) + const validRequired = value.filter( + (prop) => typeof prop === "string" && propertyNames.has(prop), ); if (validRequired.length > 0) { result[key] = validRequired; @@ -120,6 +136,21 @@ export function toGeminiSchema(schema: unknown): unknown { result.items = { type: "STRING" }; } + if ( + result.type === "OBJECT" && + result.properties && + typeof result.properties === "object" && + Object.keys(result.properties as Record).length === 0 + ) { + result.properties = { + _placeholder: { + type: "BOOLEAN", + description: "Placeholder parameter. Always pass true.", + }, + }; + result.required = ["_placeholder"]; + } + return result; } @@ -151,10 +182,7 @@ export function isGemini25Model(model: string): boolean { */ export function isImageGenerationModel(model: string): boolean { const lower = model.toLowerCase(); - return ( - lower.includes("image") || - lower.includes("imagen") - ); + return lower.includes("image") || lower.includes("imagen"); } /** @@ -179,13 +207,15 @@ export function buildGemini25ThinkingConfig( ): ThinkingConfig { return { includeThoughts, - ...(typeof thinkingBudget === "number" && thinkingBudget > 0 ? { thinkingBudget } : {}), + ...(typeof thinkingBudget === "number" && thinkingBudget > 0 + ? { thinkingBudget } + : {}), }; } /** * Image generation config for Gemini image models. - * + * * Supported aspect ratios: "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9" */ export interface ImageConfig { @@ -195,16 +225,27 @@ export interface ImageConfig { /** * Valid aspect ratios for image generation. */ -const VALID_ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]; +const VALID_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]; /** * Build image generation config for Gemini image models. - * + * * Configuration is read from environment variables: * - OPENCODE_IMAGE_ASPECT_RATIO: Aspect ratio (e.g., "16:9", "4:3") - * + * * Defaults to 1:1 aspect ratio if not specified. - * + * * Note: Resolution setting is not currently supported by the Antigravity API. */ export function buildImageGenerationConfig(): ImageConfig { @@ -215,7 +256,9 @@ export function buildImageGenerationConfig(): ImageConfig { return { aspectRatio }; } - console.warn(`[gemini] Invalid aspect ratio "${aspectRatio}". Using default "1:1". Valid values: ${VALID_ASPECT_RATIOS.join(", ")}`); + console.warn( + `[gemini] Invalid aspect ratio "${aspectRatio}". Using default "1:1". Valid values: ${VALID_ASPECT_RATIOS.join(", ")}`, + ); // Default to 1:1 square aspect ratio return { aspectRatio: "1:1" }; @@ -224,12 +267,13 @@ export function buildImageGenerationConfig(): ImageConfig { /** * Normalize tools for Gemini models. * Ensures tools have proper function-style format. - * + * * @returns Debug info about tool normalization */ -export function normalizeGeminiTools( - payload: RequestPayload, -): { toolDebugMissing: number; toolDebugSummaries: string[] } { +export function normalizeGeminiTools(payload: RequestPayload): { + toolDebugMissing: number; + toolDebugSummaries: string[]; +} { let toolDebugMissing = 0; const toolDebugSummaries: string[] = []; @@ -237,106 +281,123 @@ export function normalizeGeminiTools( return { toolDebugMissing, toolDebugSummaries }; } - payload.tools = (payload.tools as unknown[]).map((tool: unknown, toolIndex: number) => { - const t = tool as Record; + payload.tools = (payload.tools as unknown[]).map( + (tool: unknown, toolIndex: number) => { + const t = tool as Record; - // Skip normalization for Google Search tools (both old and new API) - if (t.googleSearch || t.googleSearchRetrieval) { - return t; - } + // Skip normalization for Google Search tools (both old and new API) + if (t.googleSearch || t.googleSearchRetrieval) { + return t; + } - const newTool = { ...t }; - - const schemaCandidates = [ - (newTool.function as Record | undefined)?.input_schema, - (newTool.function as Record | undefined)?.parameters, - (newTool.function as Record | undefined)?.inputSchema, - (newTool.custom as Record | undefined)?.input_schema, - (newTool.custom as Record | undefined)?.parameters, - newTool.parameters, - newTool.input_schema, - newTool.inputSchema, - ].filter(Boolean); - - const placeholderSchema: Record = { - type: "OBJECT", - properties: { - _placeholder: { - type: "BOOLEAN", - description: "Placeholder. Always pass true.", + const newTool = { ...t }; + + const schemaCandidates = [ + (newTool.function as Record | undefined)?.input_schema, + (newTool.function as Record | undefined)?.parameters, + (newTool.function as Record | undefined)?.inputSchema, + (newTool.custom as Record | undefined)?.input_schema, + (newTool.custom as Record | undefined)?.parameters, + newTool.parameters, + newTool.input_schema, + newTool.inputSchema, + ].filter(Boolean); + + const placeholderSchema: Record = { + type: "OBJECT", + properties: { + _placeholder: { + type: "BOOLEAN", + description: "Placeholder. Always pass true.", + }, }, - }, - required: ["_placeholder"], - }; + required: ["_placeholder"], + }; - let schema = schemaCandidates[0] as Record | undefined; - const schemaObjectOk = schema && typeof schema === "object" && !Array.isArray(schema); - if (!schemaObjectOk) { - schema = placeholderSchema; - toolDebugMissing += 1; - } else { - // Transform existing schema to Gemini-compatible format - schema = toGeminiSchema(schema) as Record; - } + let schema = schemaCandidates[0] as Record | undefined; + const schemaObjectOk = + schema && typeof schema === "object" && !Array.isArray(schema); + if (!schemaObjectOk) { + schema = placeholderSchema; + toolDebugMissing += 1; + } else { + // Transform existing schema to Gemini-compatible format + schema = toGeminiSchema(schema) as Record; + } - const nameCandidate = - newTool.name || - (newTool.function as Record | undefined)?.name || - (newTool.custom as Record | undefined)?.name || - `tool-${toolIndex}`; + const nameCandidate = + newTool.name || + (newTool.function as Record | undefined)?.name || + (newTool.custom as Record | undefined)?.name || + `tool-${toolIndex}`; - // Always update function.input_schema with transformed schema - if (newTool.function && schema) { - (newTool.function as Record).input_schema = schema; - } + // Always update function.input_schema with transformed schema + if (newTool.function && schema) { + (newTool.function as Record).input_schema = schema; + } - // Always update custom.input_schema with transformed schema - if (newTool.custom && schema) { - (newTool.custom as Record).input_schema = schema; - } + // Always update custom.input_schema with transformed schema + if (newTool.custom && schema) { + (newTool.custom as Record).input_schema = schema; + } - // Create custom from function if missing - if (!newTool.custom && newTool.function) { - const fn = newTool.function as Record; - newTool.custom = { - name: fn.name || nameCandidate, - description: fn.description, - input_schema: schema, - }; - } + // Create custom from function if missing + if (!newTool.custom && newTool.function) { + const fn = newTool.function as Record; + newTool.custom = { + name: fn.name || nameCandidate, + description: fn.description, + input_schema: schema, + }; + } - // Create custom if both missing - if (!newTool.custom && !newTool.function) { - newTool.custom = { - name: nameCandidate, - description: newTool.description, - input_schema: schema, - }; + // Create custom if both missing + if (!newTool.custom && !newTool.function) { + newTool.custom = { + name: nameCandidate, + description: newTool.description, + input_schema: schema, + }; + + if ( + !newTool.parameters && + !newTool.input_schema && + !newTool.inputSchema + ) { + newTool.parameters = schema; + } + } - if (!newTool.parameters && !newTool.input_schema && !newTool.inputSchema) { - newTool.parameters = schema; + if (newTool.custom && !newTool.function) { + const c = newTool.custom as Record; + newTool.function = { + name: c.name || nameCandidate, + description: c.description, + input_schema: schema, + }; } - } - if (newTool.custom && !(newTool.custom as Record).input_schema) { - (newTool.custom as Record).input_schema = { - type: "OBJECT", - properties: {}, - }; - toolDebugMissing += 1; - } + if ( + newTool.custom && + !(newTool.custom as Record).input_schema + ) { + (newTool.custom as Record).input_schema = + placeholderSchema; + toolDebugMissing += 1; + } - toolDebugSummaries.push( - `idx=${toolIndex}, hasCustom=${!!newTool.custom}, customSchema=${!!(newTool.custom as Record | undefined)?.input_schema}, hasFunction=${!!newTool.function}, functionSchema=${!!(newTool.function as Record | undefined)?.input_schema}`, - ); + toolDebugSummaries.push( + `idx=${toolIndex}, hasCustom=${!!newTool.custom}, customSchema=${!!(newTool.custom as Record | undefined)?.input_schema}, hasFunction=${!!newTool.function}, functionSchema=${!!(newTool.function as Record | undefined)?.input_schema}`, + ); - // Strip custom wrappers for Gemini; only function-style is accepted. - if (newTool.custom) { - delete newTool.custom; - } + // Strip custom wrappers for Gemini; only function-style is accepted. + if (newTool.custom) { + delete newTool.custom; + } - return newTool; - }); + return newTool; + }, + ); return { toolDebugMissing, toolDebugSummaries }; } @@ -373,7 +434,13 @@ export function applyGeminiTransforms( payload: RequestPayload, options: GeminiTransformOptions, ): GeminiTransformResult { - const { model, tierThinkingBudget, tierThinkingLevel, normalizedThinking, googleSearch } = options; + const { + model, + tierThinkingBudget, + tierThinkingLevel, + normalizedThinking, + googleSearch, + } = options; // 1. Apply thinking config if needed if (normalizedThinking) { @@ -387,14 +454,18 @@ export function applyGeminiTransforms( ); } else { // Gemini 2.5 and others use numeric budget - const thinkingBudget = tierThinkingBudget ?? normalizedThinking.thinkingBudget; + const thinkingBudget = + tierThinkingBudget ?? normalizedThinking.thinkingBudget; thinkingConfig = buildGemini25ThinkingConfig( normalizedThinking.includeThoughts ?? true, thinkingBudget, ); } - const generationConfig = (payload.generationConfig ?? {}) as Record; + const generationConfig = (payload.generationConfig ?? {}) as Record< + string, + unknown + >; generationConfig.thinkingConfig = thinkingConfig; payload.generationConfig = generationConfig; } @@ -403,7 +474,7 @@ export function applyGeminiTransforms( // Uses the new googleSearch API for Gemini 2.0+ / Gemini 3 models // Note: The old googleSearchRetrieval with dynamicRetrievalConfig is deprecated // The new API doesn't support threshold - the model decides when to search automatically - if (googleSearch && googleSearch.mode === 'auto') { + if (googleSearch && googleSearch.mode === "auto") { const tools = (payload.tools as unknown[]) || []; if (!payload.tools) { payload.tools = tools; @@ -438,13 +509,13 @@ export interface WrapToolsResult { /** * Wrap tools array in Gemini's required functionDeclarations format. - * + * * Gemini/Antigravity API expects: * { tools: [{ functionDeclarations: [{ name, description, parameters }] }] } - * + * * NOT: * { tools: [{ function: {...}, parameters: {...} }] } - * + * * The wrapper-level 'parameters' field causes: * "Unknown name 'parameters' at 'request.tools[0]'" */ @@ -473,7 +544,9 @@ function isWebSearchTool(tool: Record): boolean { return false; } -export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapToolsResult { +export function wrapToolsAsFunctionDeclarations( + payload: RequestPayload, +): WrapToolsResult { if (!Array.isArray(payload.tools) || payload.tools.length === 0) { return { wrappedFunctionCount: 0, passthroughToolCount: 0 }; } @@ -502,11 +575,18 @@ export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapTo if (tool.functionDeclarations) { if (Array.isArray(tool.functionDeclarations)) { - for (const decl of tool.functionDeclarations as Array>) { + for (const decl of tool.functionDeclarations as Array< + Record + >) { functionDeclarations.push({ name: String(decl.name || `tool-${functionDeclarations.length}`), description: String(decl.description || ""), - parameters: (decl.parameters as Record) || { type: "OBJECT", properties: {} }, + parameters: toGeminiSchema( + (decl.parameters as Record) || { + type: "OBJECT", + properties: {}, + }, + ) as Record, }); } } @@ -518,34 +598,31 @@ export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapTo const name = String( tool.name || - fn?.name || - custom?.name || - `tool-${functionDeclarations.length}` + fn?.name || + custom?.name || + `tool-${functionDeclarations.length}`, ); const description = String( - tool.description || - fn?.description || - custom?.description || - "" + tool.description || fn?.description || custom?.description || "", ); - const schema = ( - fn?.input_schema || + const schema = (fn?.input_schema || fn?.parameters || fn?.inputSchema || custom?.input_schema || custom?.parameters || tool.parameters || tool.input_schema || - tool.inputSchema || - { type: "OBJECT", properties: {} } - ) as Record; + tool.inputSchema || { type: "OBJECT", properties: {} }) as Record< + string, + unknown + >; functionDeclarations.push({ name, description, - parameters: schema, + parameters: toGeminiSchema(schema) as Record, }); } @@ -566,7 +643,7 @@ export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapTo // Log warning: web search requested but can't be used with functions console.warn( "[gemini] web_search tool detected but cannot be combined with function declarations. " + - "Use the explicit google_search() tool call instead." + "Use the explicit google_search() tool call instead.", ); } @@ -574,6 +651,8 @@ export function wrapToolsAsFunctionDeclarations(payload: RequestPayload): WrapTo return { wrappedFunctionCount: functionDeclarations.length, - passthroughToolCount: passthroughTools.length + (hasWebSearchTool && functionDeclarations.length === 0 ? 1 : 0), + passthroughToolCount: + passthroughTools.length + + (hasWebSearchTool && functionDeclarations.length === 0 ? 1 : 0), }; }