diff --git a/package-lock.json b/package-lock.json index 2ae61ac..fae8abe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-antigravity-auth", - "version": "1.2.8-beta.1", + "version": "1.2.9-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-antigravity-auth", - "version": "1.2.8-beta.1", + "version": "1.2.9-beta.5", "license": "MIT", "dependencies": { "@openauthjs/openauth": "^0.4.3", @@ -1976,4 +1976,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/plugin.ts b/src/plugin.ts index 06e823e..5cc56a1 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1276,6 +1276,10 @@ export const createAntigravityPlugin = (providerId: string) => async ( forceThinkingRecovery, { claudeToolHardening: config.claude_tool_hardening, + googleSearch: config.web_search ? { + mode: config.web_search.default_mode, + threshold: config.web_search.grounding_threshold + } : undefined, }, ); diff --git a/src/plugin/config/loader.ts b/src/plugin/config/loader.ts index a99099b..68f8c7b 100644 --- a/src/plugin/config/loader.ts +++ b/src/plugin/config/loader.ts @@ -173,6 +173,22 @@ function applyEnvOverrides(config: AntigravityConfig): AntigravityConfig { env.OPENCODE_ANTIGRAVITY_PID_OFFSET_ENABLED === "true" ? true : config.pid_offset_enabled, + + // Web Search (Gemini Grounding) overrides + // OPENCODE_ANTIGRAVITY_WEB_SEARCH=auto|off + // OPENCODE_ANTIGRAVITY_WEB_SEARCH_THRESHOLD=0.3 + web_search: { + default_mode: + env.OPENCODE_ANTIGRAVITY_WEB_SEARCH === "auto" + ? "auto" + : env.OPENCODE_ANTIGRAVITY_WEB_SEARCH === "off" + ? "off" + : config.web_search?.default_mode ?? "off", + grounding_threshold: + env.OPENCODE_ANTIGRAVITY_WEB_SEARCH_THRESHOLD + ? Math.min(1, Math.max(0, parseFloat(env.OPENCODE_ANTIGRAVITY_WEB_SEARCH_THRESHOLD) || 0.3)) + : config.web_search?.grounding_threshold ?? 0.3, + }, }; } diff --git a/src/plugin/config/schema.ts b/src/plugin/config/schema.ts index 60c3f20..718e415 100644 --- a/src/plugin/config/schema.ts +++ b/src/plugin/config/schema.ts @@ -296,6 +296,28 @@ export const AntigravityConfigSchema = z.object({ * @default true */ auto_update: z.boolean().default(true), + + // ========================================================================= + // Web Search (Gemini Grounding) + // ========================================================================= + + web_search: z.object({ + /** + * Default mode for web search when not specified by variant. + * - `auto`: Model decides when to search (dynamic retrieval). + * - `off`: Search is disabled by default. + * @default "off" + */ + default_mode: z.enum(['auto', 'off']).default('off'), + + /** + * Dynamic retrieval threshold (0.0 to 1.0). + * Higher values make the model search LESS often (requires higher confidence to trigger search). + * Only applies in 'auto' mode. + * @default 0.3 + */ + grounding_threshold: z.number().min(0).max(1).default(0.3), + }).optional(), }); export type AntigravityConfig = z.infer; @@ -344,4 +366,8 @@ export const DEFAULT_CONFIG: AntigravityConfig = { regeneration_rate_per_minute: 6, initial_tokens: 50, }, + web_search: { + default_mode: 'off', + grounding_threshold: 0.3, + }, }; diff --git a/src/plugin/request-helpers.ts b/src/plugin/request-helpers.ts index d582d23..becf970 100644 --- a/src/plugin/request-helpers.ts +++ b/src/plugin/request-helpers.ts @@ -5,6 +5,7 @@ import { EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, } from "../constants"; import { processImageData } from "./image-saver"; +import type { GoogleSearchConfig } from "./transform/types"; const log = createLogger("request-helpers"); @@ -784,6 +785,8 @@ export interface VariantThinkingConfig { thinkingBudget?: number; /** Whether to include thoughts in output */ includeThoughts?: boolean; + /** Google Search configuration */ + googleSearch?: GoogleSearchConfig; } /** @@ -803,23 +806,32 @@ export function extractVariantThinkingConfig( const google = providerOptions.google as Record | undefined; if (!google) return undefined; + const result: VariantThinkingConfig = {}; + // Gemini 3 native format: { google: { thinkingLevel: "high", includeThoughts: true } } if (typeof google.thinkingLevel === "string") { - return { - thinkingLevel: google.thinkingLevel, - includeThoughts: typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined, - }; + result.thinkingLevel = google.thinkingLevel; + result.includeThoughts = typeof google.includeThoughts === "boolean" ? google.includeThoughts : undefined; } // Budget-based format (Claude/Gemini 2.5): { google: { thinkingConfig: { thinkingBudget } } } if (google.thinkingConfig && typeof google.thinkingConfig === "object") { const tc = google.thinkingConfig as Record; if (typeof tc.thinkingBudget === "number") { - return { thinkingBudget: tc.thinkingBudget }; + result.thinkingBudget = tc.thinkingBudget; } } - return undefined; + // Extract Google Search config + if (google.googleSearch && typeof google.googleSearch === "object") { + const search = google.googleSearch as Record; + result.googleSearch = { + mode: search.mode === 'auto' || search.mode === 'off' ? search.mode : undefined, + threshold: typeof search.threshold === 'number' ? search.threshold : undefined, + }; + } + + return Object.keys(result).length > 0 ? result : undefined; } /** diff --git a/src/plugin/request.ts b/src/plugin/request.ts index f06323c..ac474d7 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -54,7 +54,7 @@ import { needsThinkingRecovery, } from "./thinking-recovery"; import { sanitizeCrossModelPayloadInPlace } from "./transform/cross-model-sanitizer"; -import { isGemini3Model, isImageGenerationModel, buildImageGenerationConfig } from "./transform"; +import { isGemini3Model, isImageGenerationModel, buildImageGenerationConfig, applyGeminiTransforms } from "./transform"; import { resolveModelWithTier, resolveModelWithVariant, @@ -62,6 +62,8 @@ import { isClaudeModel, isClaudeThinkingModel, CLAUDE_THINKING_MAX_OUTPUT_TOKENS, + type GoogleSearchConfig, + type ThinkingTier, } from "./transform"; import { detectErrorType } from "./recovery"; @@ -565,6 +567,8 @@ export function isGenerativeLanguageRequest(input: RequestInfo): input is string export interface PrepareRequestOptions { /** Enable Claude tool hardening (parameter signatures + system instruction). Default: true */ claudeToolHardening?: boolean; + /** Google Search configuration (global default) */ + googleSearch?: GoogleSearchConfig; } export function prepareAntigravityRequest( @@ -1084,120 +1088,21 @@ export function prepareAntigravityRequest( } requestPayload.tools = finalTools.concat(passthroughTools); } else { - // Default normalization for non-Claude models (Gemini) - // First, flatten any functionDeclarations format into individual tools - const flattenedTools: any[] = []; - requestPayload.tools.forEach((tool: any) => { - if (Array.isArray(tool.functionDeclarations) && tool.functionDeclarations.length > 0) { - // Flatten functionDeclarations into individual tool entries - tool.functionDeclarations.forEach((decl: any) => { - flattenedTools.push({ - name: decl.name, - description: decl.description, - // Convert parameters to input_schema for Gemini format - input_schema: decl.parameters || decl.parametersJsonSchema || decl.input_schema || decl.inputSchema, - }); - }); - } else { - flattenedTools.push(tool); - } + // Gemini-specific tool normalization and feature injection + // Resolve Google Search config: Variant takes precedence over global default + const effectiveSearchConfig: GoogleSearchConfig | undefined = + variantConfig?.googleSearch ?? options?.googleSearch; + + const geminiResult = applyGeminiTransforms(requestPayload, { + model: effectiveModel, + normalizedThinking: undefined, // Thinking config already applied above (lines 816-880) + tierThinkingBudget, + tierThinkingLevel: tierThinkingLevel as ThinkingTier | undefined, + googleSearch: effectiveSearchConfig, }); - - requestPayload.tools = flattenedTools.map((tool: any, toolIndex: number) => { - const newTool = { ...tool }; - - const schemaCandidates = [ - newTool.function?.input_schema, - newTool.function?.parameters, - newTool.function?.inputSchema, - newTool.custom?.input_schema, - newTool.custom?.parameters, - newTool.input_schema, - newTool.parameters, - newTool.inputSchema, - ].filter(Boolean); - - const placeholderSchema = { - type: "object", - properties: { - [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { - type: "boolean", - description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, - }, - }, - required: [EMPTY_SCHEMA_PLACEHOLDER_NAME], - }; - - let schema: any = schemaCandidates[0]; - const schemaObjectOk = schema && typeof schema === "object" && !Array.isArray(schema); - if (!schemaObjectOk) { - schema = placeholderSchema; - toolDebugMissing += 1; - } - - const nameCandidate = - newTool.name || - newTool.function?.name || - newTool.custom?.name || - `tool-${toolIndex}`; - - if (newTool.function && !newTool.function.input_schema && schema) { - newTool.function.input_schema = schema; - } - if (newTool.custom && !newTool.custom.input_schema && schema) { - newTool.custom.input_schema = schema; - } - if (!newTool.custom && newTool.function) { - newTool.custom = { - name: newTool.function.name || nameCandidate, - description: newTool.function.description, - input_schema: schema, - }; - } - 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.custom && !newTool.custom.input_schema) { - newTool.custom.input_schema = schema; - toolDebugMissing += 1; - } - - toolDebugSummaries.push( - `idx=${toolIndex}, hasCustom=${!!newTool.custom}, customSchema=${!!newTool.custom?.input_schema}, hasFunction=${!!newTool.function}, functionSchema=${!!newTool.function?.input_schema}`, - ); - - return newTool; - }); - - const normalizedTools = requestPayload.tools as any[]; - const geminiPlaceholderSchema = { - type: "object", - properties: { - [EMPTY_SCHEMA_PLACEHOLDER_NAME]: { - type: "boolean", - description: EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION, - }, - }, - required: [EMPTY_SCHEMA_PLACEHOLDER_NAME], - }; - const geminiDeclarations = normalizedTools.map((tool: any) => { - const rawSchema = tool.parameters || tool.input_schema || tool.function?.parameters || tool.function?.input_schema; - const cleanedSchema = rawSchema ? cleanJSONSchemaForAntigravity(rawSchema) : geminiPlaceholderSchema; - return { - name: tool.name || tool.function?.name, - description: tool.description || tool.function?.description, - parameters: cleanedSchema, - }; - }); - requestPayload.tools = [{ functionDeclarations: geminiDeclarations }]; + + toolDebugMissing = geminiResult.toolDebugMissing; + toolDebugSummaries.push(...geminiResult.toolDebugSummaries); } try { diff --git a/src/plugin/transform/gemini.test.ts b/src/plugin/transform/gemini.test.ts index fff0033..61ab429 100644 --- a/src/plugin/transform/gemini.test.ts +++ b/src/plugin/transform/gemini.test.ts @@ -489,6 +489,101 @@ describe("transform/gemini", () => { expect(thinkingConfig.includeThoughts).toBe(true); expect(thinkingConfig).not.toHaveProperty("thinkingBudget"); }); + + describe("Google Search (Grounding)", () => { + it("injects googleSearchRetrieval tool when mode is 'auto'", () => { + const payload: RequestPayload = { contents: [], tools: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + googleSearch: { mode: "auto", threshold: 0.3 }, + }); + const tools = payload.tools as unknown[]; + expect(tools).toHaveLength(1); + expect(tools[0]).toEqual({ + googleSearchRetrieval: { + dynamicRetrievalConfig: { + mode: "MODE_DYNAMIC", + dynamicThreshold: 0.3, + }, + }, + }); + }); + + it("uses custom threshold value", () => { + const payload: RequestPayload = { contents: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-flash", + googleSearch: { mode: "auto", threshold: 0.7 }, + }); + const tools = payload.tools as unknown[]; + const searchTool = tools[0] as Record; + const retrieval = searchTool.googleSearchRetrieval as Record; + const config = retrieval.dynamicRetrievalConfig as Record; + expect(config.dynamicThreshold).toBe(0.7); + }); + + it("defaults threshold to 0.3 when not specified", () => { + const payload: RequestPayload = { contents: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + googleSearch: { mode: "auto" }, + }); + const tools = payload.tools as unknown[]; + const searchTool = tools[0] as Record; + const retrieval = searchTool.googleSearchRetrieval as Record; + const config = retrieval.dynamicRetrievalConfig as Record; + expect(config.dynamicThreshold).toBe(0.3); + }); + + it("does not inject search tool when mode is 'off'", () => { + const payload: RequestPayload = { contents: [], tools: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + googleSearch: { mode: "off" }, + }); + const tools = payload.tools as unknown[]; + expect(tools).toHaveLength(0); + }); + + it("does not inject search tool when googleSearch is undefined", () => { + const payload: RequestPayload = { contents: [], tools: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + }); + const tools = payload.tools as unknown[]; + expect(tools).toHaveLength(0); + }); + + it("appends search tool to existing tools array", () => { + const payload: RequestPayload = { + contents: [], + tools: [ + { function: { name: "existing_tool", input_schema: { type: "object" } } }, + ], + }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + googleSearch: { mode: "auto", threshold: 0.5 }, + }); + const tools = payload.tools as unknown[]; + expect(tools).toHaveLength(2); + const lastTool = tools[1] as Record; + expect(lastTool).toHaveProperty("googleSearchRetrieval"); + }); + + it("search tool is not normalized (skipped by normalizeGeminiTools)", () => { + const payload: RequestPayload = { contents: [] }; + applyGeminiTransforms(payload, { + model: "gemini-3-pro", + googleSearch: { mode: "auto", threshold: 0.3 }, + }); + const tools = payload.tools as unknown[]; + const searchTool = tools[0] as Record; + expect(searchTool).toHaveProperty("googleSearchRetrieval"); + expect(searchTool).not.toHaveProperty("function"); + expect(searchTool).not.toHaveProperty("custom"); + }); + }); }); describe("isImageGenerationModel", () => { diff --git a/src/plugin/transform/gemini.ts b/src/plugin/transform/gemini.ts index ebe9cf7..61a7b2d 100644 --- a/src/plugin/transform/gemini.ts +++ b/src/plugin/transform/gemini.ts @@ -7,7 +7,7 @@ * - Schema transformation (JSON Schema -> Gemini Schema format) */ -import type { RequestPayload, ThinkingConfig, ThinkingTier } from "./types"; +import type { RequestPayload, ThinkingConfig, ThinkingTier, GoogleSearchConfig } from "./types"; /** * Transform a JSON Schema to Gemini-compatible format. @@ -205,6 +205,12 @@ export function normalizeGeminiTools( payload.tools = (payload.tools as unknown[]).map((tool: unknown, toolIndex: number) => { const t = tool as Record; + + // Skip normalization for Google Search Retrieval tool + if (t.googleSearchRetrieval) { + return t; + } + const newTool = { ...t }; const schemaCandidates = [ @@ -313,6 +319,8 @@ export interface GeminiTransformOptions { tierThinkingLevel?: ThinkingTier; /** Normalized thinking config from user settings */ normalizedThinking?: { includeThoughts?: boolean; thinkingBudget?: number }; + /** Google Search configuration */ + googleSearch?: GoogleSearchConfig; } export interface GeminiTransformResult { @@ -327,7 +335,7 @@ export function applyGeminiTransforms( payload: RequestPayload, options: GeminiTransformOptions, ): GeminiTransformResult { - const { model, tierThinkingBudget, tierThinkingLevel, normalizedThinking } = options; + const { model, tierThinkingBudget, tierThinkingLevel, normalizedThinking, googleSearch } = options; // 1. Apply thinking config if needed if (normalizedThinking) { @@ -353,6 +361,25 @@ export function applyGeminiTransforms( payload.generationConfig = generationConfig; } - // 2. Normalize tools + // 2. Apply Google Search (Grounding) if enabled + if (googleSearch && googleSearch.mode === 'auto') { + const tools = (payload.tools as unknown[]) || []; + if (!payload.tools) { + payload.tools = tools; + } + + // Add Google Search tool + // We cast to any[] to avoid TypeScript issues with the loose RequestPayload type + (payload.tools as any[]).push({ + googleSearchRetrieval: { + dynamicRetrievalConfig: { + mode: "MODE_DYNAMIC", + dynamicThreshold: googleSearch.threshold ?? 0.3, + }, + }, + }); + } + + // 3. Normalize tools return normalizeGeminiTools(payload); } diff --git a/src/plugin/transform/index.ts b/src/plugin/transform/index.ts index 29b47fe..263af83 100644 --- a/src/plugin/transform/index.ts +++ b/src/plugin/transform/index.ts @@ -14,6 +14,7 @@ export type { RequestPayload, ThinkingConfig, ResolvedModel, + GoogleSearchConfig, } from "./types"; // Model resolution diff --git a/src/plugin/transform/model-resolver.ts b/src/plugin/transform/model-resolver.ts index c26e1e1..d7fa559 100644 --- a/src/plugin/transform/model-resolver.ts +++ b/src/plugin/transform/model-resolver.ts @@ -5,7 +5,7 @@ * to their actual API model names and corresponding thinking configurations. */ -import type { ResolvedModel, ThinkingTier } from "./types"; +import type { ResolvedModel, ThinkingTier, GoogleSearchConfig } from "./types"; /** * Thinking tier budgets by model family. @@ -300,6 +300,7 @@ export function getModelFamily(model: string): "claude" | "gemini-flash" | "gemi */ export interface VariantConfig { thinkingBudget?: number; + googleSearch?: GoogleSearchConfig; } /** @@ -376,7 +377,17 @@ export function resolveModelWithVariant( ): ResolvedModel { const base = resolveModelWithTier(requestedModel); - if (!variantConfig?.thinkingBudget) { + if (!variantConfig) { + return base; + } + + // Apply Google Search config if present + if (variantConfig.googleSearch) { + base.googleSearch = variantConfig.googleSearch; + base.configSource = "variant"; + } + + if (!variantConfig.thinkingBudget) { return base; } diff --git a/src/plugin/transform/types.ts b/src/plugin/transform/types.ts index 7894ce9..8451411 100644 --- a/src/plugin/transform/types.ts +++ b/src/plugin/transform/types.ts @@ -79,6 +79,14 @@ export interface ThinkingConfig { include_thoughts?: boolean; } +/** + * Google Search Grounding configuration. + */ +export interface GoogleSearchConfig { + mode?: 'auto' | 'off'; + threshold?: number; +} + /** * Model resolution result with tier information. */ @@ -101,4 +109,6 @@ export interface ResolvedModel { explicitQuota?: boolean; /** Source of thinking config: "variant" (providerOptions) or "tier" (model suffix) */ configSource?: "variant" | "tier"; + /** Google Search configuration from variant or global config */ + googleSearch?: GoogleSearchConfig; }