From 289ea08a39e2b34935ba6aa671340de4eff1cae4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 12:44:55 +0000 Subject: [PATCH] fix(providers): made gemini search model configurable Added providers.webSearchGeminiModel and GEMINI_SEARCH_MODEL so Gemini web_search requests use a selected grounding model while keeping gemini-2.5-flash as the fallback. Covered OAuth, Developer API, and missing modelVersion fallback paths in Gemini web search tests. Fixes #4312 --- docs/settings.md | 2 + docs/tools/web_search.md | 3 +- packages/coding-agent/CHANGELOG.md | 4 ++ .../src/config/settings-schema.ts | 10 ++++ packages/coding-agent/src/web/search/index.ts | 8 +++ .../src/web/search/providers/base.ts | 1 + .../src/web/search/providers/gemini.ts | 29 +++++++--- .../test/tools/web-search-gemini.test.ts | 53 +++++++++++++++++++ 8 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/settings.md b/docs/settings.md index fdf61ce83..7961c7a1b 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -597,6 +597,7 @@ providers: webSearch: auto image: auto fetch: auto + webSearchGeminiModel: gemini-2.5-flash tinyModel: online tinyModelDevice: default tinyModelDtype: default @@ -621,6 +622,7 @@ searxng: | Key | Type | Default | Values / notes | |---|---|---|---| | `providers.webSearch` | enum | `auto` | `auto` plus the configured search providers (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`). | +| `providers.webSearchGeminiModel` | string | _(unset)_ | Gemini model ID for Google Search grounding when `web_search` uses Gemini; defaults to `gemini-2.5-flash`, overridden by `GEMINI_SEARCH_MODEL`. | | `providers.image` | enum | `auto` | `auto`, `openai`, `antigravity`, `xai`, `gemini`, `openrouter`. | | `providers.fetch` | enum | `auto` | `auto`, `native`, `trafilatura`, `lynx`, `parallel`, `jina`. | | `providers.tinyModel` | enum | `online` | `online` or a local model (`lfm2-350m`, `qwen3-0.6b`, `gemma-270m`, `qwen2.5-0.5b`, `lfm2-700m`). | diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index b28892f08..679b4001b 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -104,8 +104,9 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec - `num_search_results` controls upstream API breadth only in API-key mode. `limit` is preserved separately as `num_results` and slices returned `sources` after parsing in both auth modes. - Output may include `answer`, `sources`, `citations`, `usage`, `model`, `requestId`, `authMode`. - **Gemini** — `packages/coding-agent/src/web/search/providers/gemini.ts` - - Availability: OAuth credentials in `agent.db` for `google-gemini-cli` or `google-antigravity`. + - Availability: OAuth credentials in `agent.db` for `google-gemini-cli` / `google-antigravity`, or a Google Developer API key. - Querying: SSE `streamGenerateContent` call with Google Search grounding enabled. Antigravity auth tries two fallback endpoints and retries `401/403/400 invalid auth` once after token refresh; `429/5xx` retry with exponential backoff and server-provided retry delay, capped by a `5 * 60 * 1000` ms rate-limit budget. + - Model: `providers.webSearchGeminiModel` selects the Gemini grounding model; `GEMINI_SEARCH_MODEL` overrides it. Defaults to `gemini-2.5-flash`. - `max_tokens` and `temperature` pass through as `generationConfig.maxOutputTokens` / `generationConfig.temperature`. - `limit` and `num_search_results` are collapsed together before dispatch. - Output may include `answer`, `sources`, `citations`, `searchQueries`, `usage`, `model`. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 64c3c57ae..d27eba45c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Gemini web_search to honor `providers.webSearchGeminiModel` / `GEMINI_SEARCH_MODEL` for both OAuth and Developer API grounding requests. ([#4312](https://github.com/can1357/oh-my-pi/issues/4312)) + ## [16.3.1] - 2026-07-02 ### Breaking Changes diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 291a480d8..2316034da 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4337,6 +4337,16 @@ export const SETTINGS_SCHEMA = { description: "Providers that web_search should never use, even as fallbacks", }, }, + "providers.webSearchGeminiModel": { + type: "string", + default: undefined, + ui: { + tab: "providers", + group: "Services", + label: "Gemini web_search model", + description: "Model ID for Gemini Google Search grounding. Defaults to gemini-2.5-flash.", + }, + }, "providers.antigravityEndpoint": { type: "enum", values: ["auto", "production", "sandbox"] as const, diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 344fcecaf..8e025f369 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -159,6 +159,13 @@ async function executeSearch( antigravityEndpointMode = undefined; } + let geminiModel: string | undefined; + try { + geminiModel = settings.get("providers.webSearchGeminiModel"); + } catch { + geminiModel = undefined; + } + const failures: Array<{ provider: SearchProvider; error: unknown }> = []; let lastProvider = providers[0]; for (const provider of providers) { @@ -176,6 +183,7 @@ async function executeSearch( authStorage, sessionId, antigravityEndpointMode, + geminiModel, }); if (!hasRenderableSearchContent(response)) { diff --git a/packages/coding-agent/src/web/search/providers/base.ts b/packages/coding-agent/src/web/search/providers/base.ts index 663d12e0c..b02d99978 100644 --- a/packages/coding-agent/src/web/search/providers/base.ts +++ b/packages/coding-agent/src/web/search/providers/base.ts @@ -52,6 +52,7 @@ export interface SearchParams { */ sessionId?: string; antigravityEndpointMode?: "auto" | "production" | "sandbox"; + geminiModel?: string; } /** Base class for web search providers. */ diff --git a/packages/coding-agent/src/web/search/providers/gemini.ts b/packages/coding-agent/src/web/search/providers/gemini.ts index 06a7c6726..27612ae77 100644 --- a/packages/coding-agent/src/web/search/providers/gemini.ts +++ b/packages/coding-agent/src/web/search/providers/gemini.ts @@ -33,6 +33,13 @@ const MAX_RETRIES = 3; const BASE_DELAY_MS = 1000; const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000; +function resolveGeminiSearchModel(configuredModel: string | undefined): string { + const envModel = Bun.env.GEMINI_SEARCH_MODEL?.trim(); + if (envModel) return envModel; + const model = configuredModel?.trim(); + return model || DEFAULT_MODEL; +} + const GEMINI_PROVIDERS = ["google-gemini-cli", "google-antigravity"] as const; type GeminiProviderId = (typeof GEMINI_PROVIDERS)[number]; @@ -55,6 +62,7 @@ export interface GeminiSearchParams extends GeminiToolParams { sessionId?: string; fetch?: FetchImpl; antigravityEndpointMode?: "auto" | "production" | "sandbox"; + geminiModel?: string; } export function buildGeminiRequestTools(params: GeminiToolParams): Array>> { @@ -160,13 +168,16 @@ interface CloudCodeResponseChunk { response?: GeminiModelResponse; } -async function parseGeminiSearchStream(body: ReadableStream): Promise { +async function parseGeminiSearchStream( + body: ReadableStream, + fallbackModel: string, +): Promise { const answerParts: string[] = []; const sources: SearchSource[] = []; const citations: SearchCitation[] = []; const searchQueries: string[] = []; const seenUrls = new Set(); - let model = DEFAULT_MODEL; + let model = fallbackModel; let usage: { inputTokens: number; outputTokens: number; totalTokens: number } | undefined; const reader = body.getReader(); @@ -287,6 +298,7 @@ async function parseGeminiSearchStream(body: ReadableStream): Promis */ async function callGeminiSearch( auth: GeminiAuth, + model: string, query: string, systemPrompt: string | undefined, maxOutputTokens: number | undefined, @@ -330,7 +342,7 @@ async function callGeminiSearch( const requestBody: Record = { project: auth.projectId, - model: DEFAULT_MODEL, + model, request: { contents: [ { @@ -414,11 +426,12 @@ async function callGeminiSearch( throw new SearchProviderError("gemini", "Gemini API returned no response body", 500); } - return parseGeminiSearchStream(response.body); + return parseGeminiSearchStream(response.body, model); } async function callGeminiDeveloperSearch( apiKey: string, + model: string, query: string, systemPrompt: string | undefined, maxOutputTokens: number | undefined, @@ -455,7 +468,7 @@ async function callGeminiDeveloperSearch( } const response = await fetchWithRetry( - () => `${DEVELOPER_API_ENDPOINT}/models/${DEFAULT_MODEL}:streamGenerateContent?alt=sse`, + () => `${DEVELOPER_API_ENDPOINT}/models/${model}:streamGenerateContent?alt=sse`, { method: "POST", headers: { @@ -487,13 +500,14 @@ async function callGeminiDeveloperSearch( throw new SearchProviderError("gemini", "Gemini API returned no response body", 500); } - return parseGeminiSearchStream(response.body); + return parseGeminiSearchStream(response.body, model); } /** * Executes a web search using Google Gemini with Google Search grounding. */ export async function searchGemini(params: GeminiSearchParams): Promise { + const selectedModel = resolveGeminiSearchModel(params.geminiModel); const seed = await findGeminiAuth(params.authStorage, params.sessionId, params.signal); let result: GeminiSearchResult; @@ -513,6 +527,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise { afterEach(() => { capturedRequest = null; + if (ORIGINAL_GEMINI_SEARCH_MODEL === undefined) { + delete Bun.env.GEMINI_SEARCH_MODEL; + } else { + Bun.env.GEMINI_SEARCH_MODEL = ORIGINAL_GEMINI_SEARCH_MODEL; + } }); function makeParams(query: string) { @@ -102,6 +110,48 @@ describe("searchGemini tools serialization", () => { usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, }); }); + + it("uses configured developer API model and reports it when modelVersion is absent", async () => { + const fetchMock = mockGeminiFetch(DEVELOPER_SSE_RESPONSE_WITHOUT_MODEL); + const response = await searchGemini({ + ...makeParams("developer api configured"), + authStorage: apiKeyAuthStorage, + geminiModel: "gemini-3.5-flash", + fetch: fetchMock, + }); + + expect(capturedRequest?.url).toBe( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + ); + expect(response.model).toBe("gemini-3.5-flash"); + }); + + it("uses configured OAuth model in the Cloud Code request body", async () => { + const fetchMock = mockGeminiFetch(); + await searchGemini({ + ...makeParams("oauth configured"), + geminiModel: "gemini-3.5-flash", + fetch: fetchMock, + }); + + expect(capturedRequest?.body).toMatchObject({ + model: "gemini-3.5-flash", + }); + }); + + it("lets GEMINI_SEARCH_MODEL override the configured Gemini model", async () => { + Bun.env.GEMINI_SEARCH_MODEL = "gemini-2.5-pro"; + const fetchMock = mockGeminiFetch(); + await searchGemini({ + ...makeParams("env configured"), + geminiModel: "gemini-3.5-flash", + fetch: fetchMock, + }); + + expect(capturedRequest?.body).toMatchObject({ + model: "gemini-2.5-pro", + }); + }); it("sends default googleSearch tool when no passthrough payloads are provided", async () => { const fetchMock = mockGeminiFetch(); await searchGemini({ ...makeParams("default tools"), fetch: fetchMock }); @@ -110,6 +160,9 @@ describe("searchGemini tools serialization", () => { expect(capturedRequest?.body?.request).toMatchObject({ tools: [{ googleSearch: {} }], }); + expect(capturedRequest?.body).toMatchObject({ + model: "gemini-2.5-flash", + }); }); it("passes through googleSearch payload into googleSearch tool", async () => {