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
This commit is contained in:
roboomp
2026-07-02 12:44:55 +00:00
parent 0ea6ea630b
commit 289ea08a39
8 changed files with 103 additions and 7 deletions
+2
View File
@@ -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`). |
+2 -1
View File
@@ -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`.
+4
View File
@@ -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
@@ -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,
@@ -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)) {
@@ -52,6 +52,7 @@ export interface SearchParams {
*/
sessionId?: string;
antigravityEndpointMode?: "auto" | "production" | "sandbox";
geminiModel?: string;
}
/** Base class for web search providers. */
@@ -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<Record<string, Record<string, unknown>>> {
@@ -160,13 +168,16 @@ interface CloudCodeResponseChunk {
response?: GeminiModelResponse;
}
async function parseGeminiSearchStream(body: ReadableStream<Uint8Array>): Promise<GeminiSearchResult> {
async function parseGeminiSearchStream(
body: ReadableStream<Uint8Array>,
fallbackModel: string,
): Promise<GeminiSearchResult> {
const answerParts: string[] = [];
const sources: SearchSource[] = [];
const citations: SearchCitation[] = [];
const searchQueries: string[] = [];
const seenUrls = new Set<string>();
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<Uint8Array>): 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<string, unknown> = {
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<SearchResponse> {
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<SearchRe
projectId: access.projectId ?? seed.projectId,
isAntigravity,
},
selectedModel,
params.query,
params.system_prompt,
params.max_output_tokens,
@@ -539,6 +554,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
}
result = await callGeminiDeveloperSearch(
apiKey,
selectedModel,
params.query,
params.system_prompt,
params.max_output_tokens,
@@ -596,6 +612,7 @@ export class GeminiProvider extends SearchProvider {
authStorage: params.authStorage,
sessionId: params.sessionId,
fetch: params.fetch,
geminiModel: params.geminiModel,
});
}
}
@@ -7,6 +7,9 @@ const SSE_RESPONSE =
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Gemini answer"}]}}],"modelVersion":"gemini-2.5-flash"}}\n\n';
const DEVELOPER_SSE_RESPONSE =
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Developer answer"}]},"groundingMetadata":{"webSearchQueries":["latest Bun version"],"groundingChunks":[{"web":{"uri":"https://bun.sh","title":"Bun"}}],"groundingSupports":[{"segment":{"text":"Developer answer"},"groundingChunkIndices":[0]}]}}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7},"modelVersion":"gemini-2.5-flash"}\n\n';
const DEVELOPER_SSE_RESPONSE_WITHOUT_MODEL =
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Developer answer"}]},"groundingMetadata":{"webSearchQueries":["latest Bun version"],"groundingChunks":[{"web":{"uri":"https://bun.sh","title":"Bun"}}],"groundingSupports":[{"segment":{"text":"Developer answer"},"groundingChunkIndices":[0]}]}}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7}}\n\n';
const ORIGINAL_GEMINI_SEARCH_MODEL = Bun.env.GEMINI_SEARCH_MODEL;
type CapturedRequest = {
url: string;
@@ -64,6 +67,11 @@ describe("searchGemini tools serialization", () => {
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 () => {