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:
@@ -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`). |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user