diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2743d81961..2ba92c2b26 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -122,6 +122,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `escapeBuiltinToolNames?` | `boolean` | Escape built-in tool names for Anthropic-compatible gateways and restore them in returned calls. | | `anthropicEofTolerance?` | `boolean` | Let an Anthropic-compatible gateway complete a stream that ends before `message_stop`, only when visible text or a complete JSON-object tool input was received. Off by default. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | +| `directGeminiWireRenames?` | `boolean` | Google only. Applies only to direct AI Studio requests. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex preserves the requested model ID, and Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | | `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ab27089e7d..72914d9559 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -48,19 +48,17 @@ const GOOGLE_BREVITY_INSTRUCTION = [ ].join("\n"); /** - * Google renamed the current Gemini Flash generations on the Generative Language API, - * appending a `-tiered` suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). The - * old `gemini-3.7-flash` path 404s, so a saved config or registry entry naming the base - * id must be resolved here before it reaches the URL. The user-facing id is deliberately - * left alone: the picker, the catalog, the usage log and the price overlays all stay - * keyed on the base id, and only the wire path learns the new spelling. + * Some Google direct deployments expose current Gemini Flash generations with a `-tiered` + * wire suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). Keep the picker-visible id + * stable and make the mapping configurable for deployments that still serve the bare id. */ const GEMINI_DIRECT_WIRE_RENAMES: Record = { "gemini-3.7-flash": "gemini-3.7-flash-tiered", "gemini-3.6-flash": "gemini-3.6-flash-tiered", }; -function resolveDirectGeminiWireModelId(modelId: string): string { +function resolveDirectGeminiWireModelId(modelId: string, applyRenames: boolean): string { + if (!applyRenames) return modelId; return Object.hasOwn(GEMINI_DIRECT_WIRE_RENAMES, modelId) ? GEMINI_DIRECT_WIRE_RENAMES[modelId]! : modelId; @@ -147,7 +145,7 @@ function geminiToolResultText(content: string | OcxContentPart[]): string { function messagesToGeminiFormat( parsed: OcxParsedRequest, - routedModelId = parsed.modelId, + identityModelId: string, ): { systemInstruction?: unknown; contents: unknown[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. @@ -156,7 +154,7 @@ function messagesToGeminiFormat( ...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : []), GOOGLE_BREVITY_INSTRUCTION, - ].join("\n\n"), routedModelId); + ].join("\n\n"), identityModelId); const systemInstruction = { parts: [{ text: systemText }] }; const contents: unknown[] = []; @@ -396,8 +394,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte parsed.modelId, mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning), ).wireModelId - : resolveDirectGeminiWireModelId(parsed.modelId); - const { systemInstruction, contents } = messagesToGeminiFormat(parsed, routedModelId); + : provider.googleMode === "vertex" + ? parsed.modelId + : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); + // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. + const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId; + const { systemInstruction, contents } = messagesToGeminiFormat(parsed, identityModelId); const tools = toolsToGeminiFormat(parsed); const body: Record = { contents }; diff --git a/src/config.ts b/src/config.ts index d4c0a3a0f3..d879195e88 100644 --- a/src/config.ts +++ b/src/config.ts @@ -741,6 +741,7 @@ const providerConfigSchema = z.object({ upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES) .nullish() .transform(value => value ?? undefined), + directGeminiWireRenames: z.boolean().optional(), noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), diff --git a/src/types.ts b/src/types.ts index 565118b287..5564c514c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1392,8 +1392,15 @@ export interface OcxProviderConfig { * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1, * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation * (current behavior unchanged). Only meaningful for https: base URLs. - */ + */ upstreamHttpVersion?: UpstreamHttpVersion; + /** + * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids + * unchanged to the wire instead of applying the `-tiered` suffix (`gemini-3.7-flash` + * -> `gemini-3.7-flash-tiered`). Set this to `false` when the configured upstream still + * serves the bare ids. Absent (default) keeps the rename. + */ + directGeminiWireRenames?: boolean; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; /** diff --git a/tests/config.test.ts b/tests/config.test.ts index b949b9bd8b..c67ff8846d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -814,6 +814,38 @@ describe("opencodex config defaults", () => { expect(readConfigDiagnostics().error).toContain("responsesSnapshotRepair"); }); + test("direct Gemini wire rename opt-out is a boolean and round-trips", () => { + const base = { + port: 12345, + providers: { + google: { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + }, + }, + defaultProvider: "google", + }; + writeConfig({ + ...base, + providers: { + google: { ...base.providers.google, directGeminiWireRenames: false }, + }, + }); + const config = loadConfig(); + expect(config.providers.google.directGeminiWireRenames).toBe(false); + saveConfig(config); + expect(loadConfig().providers.google.directGeminiWireRenames).toBe(false); + + writeConfig({ + ...base, + providers: { + google: { ...base.providers.google, directGeminiWireRenames: "false" }, + }, + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("directGeminiWireRenames"); + }); + test("accepts a relative responsesPath", () => { writeResponsesPathConfig("/responses"); diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 467cf19c37..546ad41d6c 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -278,3 +278,108 @@ describe("google adapter — tool_choice on the wire", () => { }); }); }); + +describe("google adapter — direct -tiered wire renames", () => { + function renamedParsed(modelId: string): OcxParsedRequest { + return { + modelId, + stream: false, + options: {}, + context: { messages: [{ role: "user", content: "hi" }], tools: [] }, + } as unknown as OcxParsedRequest; + } + + function identityParsed(modelId: string): OcxParsedRequest { + return { + modelId, + stream: false, + options: {}, + context: { + systemPrompt: ["You are Codex, a coding agent based on GPT-5."], + messages: [{ role: "user", content: "hi" }], + tools: [], + }, + } as unknown as OcxParsedRequest; + } + + test("default maps the picker id to the -tiered wire id", async () => { + for (const modelId of ["gemini-3.7-flash", "gemini-3.6-flash"]) { + const { url } = await createGoogleAdapter(provider).buildRequest(renamedParsed(modelId)); + expect(url).toContain(`/v1beta/models/${modelId}-tiered:generateContent`); + } + }); + + test("directGeminiWireRenames: false keeps the bare wire id", async () => { + const adapter = createGoogleAdapter({ ...provider, directGeminiWireRenames: false }); + for (const modelId of ["gemini-3.7-flash", "gemini-3.6-flash"]) { + const { url } = await adapter.buildRequest(renamedParsed(modelId)); + expect(url).toContain(`/v1beta/models/${modelId}:generateContent`); + } + }); + + test("directGeminiWireRenames: true still maps to the -tiered wire id", async () => { + const adapter = createGoogleAdapter({ ...provider, directGeminiWireRenames: true }); + for (const modelId of ["gemini-3.7-flash", "gemini-3.6-flash"]) { + const request = await adapter.buildRequest(identityParsed(modelId)); + const { url } = request; + expect(url).toContain(`/v1beta/models/${modelId}-tiered:generateContent`); + const body = JSON.parse(request.body) as { + systemInstruction?: { parts?: Array<{ text?: string }> }; + }; + const systemText = body.systemInstruction?.parts?.[0]?.text ?? ""; + expect(systemText).toContain(`powered by the ${modelId}`); + expect(systemText).not.toContain(`${modelId}-tiered`); + } + }); + + test("directGeminiWireRenames does not affect Cloud Code Assist requests", async () => { + const ccaProvider = { + ...provider, + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "proj-123", + } as const; + for (const modelId of ["gemini-3.7-flash", "gemini-3.6-flash"]) { + const parsed = renamedParsed(modelId); + const defaultRequest = await createGoogleAdapter(ccaProvider).buildRequest(parsed); + const optOutRequest = await createGoogleAdapter({ ...ccaProvider, directGeminiWireRenames: false }) + .buildRequest(parsed); + expect(optOutRequest.url).toBe(defaultRequest.url); + // The envelope's requestId/sessionId are minted per request; compare the wire model only. + const defaultModel = JSON.parse(defaultRequest.body).model as string; + const optOutModel = JSON.parse(optOutRequest.body).model as string; + expect(optOutModel).toBe(defaultModel); + } + }); + + test("Cloud Code Assist identity follows a migrated wire model", async () => { + const ccaProvider = { + ...provider, + googleMode: "cloud-code-assist", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + project: "proj-123", + } as const; + const request = await createGoogleAdapter(ccaProvider).buildRequest(identityParsed("gemini-3.6-flash")); + const envelope = JSON.parse(request.body) as { + model: string; + request: { systemInstruction?: { parts?: Array<{ text?: string }> } }; + }; + const systemText = envelope.request.systemInstruction?.parts?.[0]?.text ?? ""; + + expect(envelope.model).toBe("gemini-3.7-flash-tiered"); + expect(systemText).toContain("powered by the gemini-3.7-flash-tiered"); + expect(systemText).not.toContain("powered by the gemini-3.6-flash."); + }); + + test("directGeminiWireRenames does not affect Vertex requests", async () => { + const vertexProvider = { ...provider, googleMode: "vertex" as const }; + for (const modelId of ["gemini-3.7-flash", "gemini-3.6-flash"]) { + const parsed = renamedParsed(modelId); + const defaultRequest = await createGoogleAdapter(vertexProvider).buildRequest(parsed); + const optOutRequest = await createGoogleAdapter({ ...vertexProvider, directGeminiWireRenames: false }) + .buildRequest(parsed); + expect(optOutRequest.url).toBe(defaultRequest.url); + expect(optOutRequest.body).toBe(defaultRequest.body); + } + }); +});