From 15586665be063508bc18982a60e6a3a38281f24c Mon Sep 17 00:00:00 2001 From: norths7ar Date: Thu, 15 Oct 2026 16:19:08 +0800 Subject: [PATCH 1/2] fix(providers): support Gemini models on OpenCode Zen Route Zen Gemini requests through the native Google API and preserve gateway model IDs. Retain existing saved configurations when upgrading, with regression coverage for protocol routing and config loading. Co-authored-by: Georgiy Bukharov --- .../src/content/docs/guides/providers.md | 2 + .../docs/reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 4 +- src/adapters/google.ts | 11 +- src/config.ts | 2 + src/config/opencode-zen-upgrade.ts | 27 ++ src/providers/service-tier.ts | 1 + src/types/opencode-zen.ts | 29 +++ src/types/wire.ts | 7 +- structure/config.md | 2 +- structure/providers-and-adapters.md | 7 +- .../config-opencode-zen-upgrade.test.ts | 168 +++++++++++++ tests/fixtures/test-layout-expected.json | 4 +- .../opencode-zen-gemini-wire.test.ts | 237 ++++++++++++++++++ 14 files changed, 486 insertions(+), 17 deletions(-) create mode 100644 src/config/opencode-zen-upgrade.ts create mode 100644 src/types/opencode-zen.ts create mode 100644 tests/config/config-opencode-zen-upgrade.test.ts create mode 100644 tests/providers/opencode-zen-gemini-wire.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..91bd23720e2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -765,6 +765,8 @@ headers. That is separate from the keyless desktop quota OpenCode advertises synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). +Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. + **The keyless `opencode-free` tier is currently closed to third-party clients.** Zen refuses any request that arrives without an `x-opencode-session` header, answering with error type `MissingSessionID` and the message "OpenCode's free tier can only be used in OpenCode". Presence diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 50c3520b0df..ce39d9305ea 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -339,7 +339,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `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`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | -| `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. | +| `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 and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; 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. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..216767e6b42 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -718,7 +718,7 @@ "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", @@ -1290,7 +1290,7 @@ "opencode-go-quota.test.ts": "providers", "opencode-go-session-header.test.ts": "providers", "opencode-management-transport.test.ts": "providers", - "opencode-zen-deepseek-reasoning.test.ts": "providers", + "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-gemini-wire.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", "opengateway-provider.test.ts": "providers", "opengateway-key-validation.test.ts": "providers", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index a60bf565d67..576b39456de 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,3 +1,4 @@ +import { openCodeZenGeminiEndpoint } from "../types/opencode-zen"; import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame, debugProviderDiagnosticLazy } from "../lib/debug"; import { createToolCallIdAllocator } from "./tool-call-id"; @@ -983,7 +984,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning), provider.baseUrl, ).wireModelId - : provider.googleMode === "vertex" + : provider.googleMode === "vertex" || openCodeZenGeminiEndpoint(provider, parsed.modelId) !== undefined ? parsed.modelId : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); returnsThoughtSummaries = provider.googleMode === "cloud-code-assist" @@ -1202,10 +1203,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } - // ai-studio (default): Generative Language API + x-goog-api-key. - const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`; + // Zen accepts Gemini wire and x-goog-api-key at its own model endpoint. + const zenEndpoint = openCodeZenGeminiEndpoint(provider, routedModelId); + const endpoint = zenEndpoint ?? `${provider.baseUrl}/v1beta/models/${routedModelId}`; + const url = `${endpoint}:${method}${streamParam}`; const apiKey = provider.apiKey?.trim(); - if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key"); + if (!apiKey) throw new Error(`${zenEndpoint ? "OpenCode Zen" : "google (AI Studio)"} requires a non-empty API key`); headers["x-goog-api-key"] = apiKey; const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); diff --git a/src/config.ts b/src/config.ts index 282fc601947..8fc8841e9f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -162,6 +162,7 @@ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unloc import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; import { getDefaultConfig } from "./config/proxy-env"; import { configSchema } from "./config/schema/config-schema"; +import { sanitizeOpenCodeZenOverridesForLoad } from "./config/opencode-zen-upgrade"; import { hardenExistingSecret, normalizeApiKeyIds, @@ -222,6 +223,7 @@ export function loadConfig(): OcxConfig { try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); + sanitizeOpenCodeZenOverridesForLoad(parsed); sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts new file mode 100644 index 00000000000..5ac20835662 --- /dev/null +++ b/src/config/opencode-zen-upgrade.ts @@ -0,0 +1,27 @@ +import { openCodeZenBaseUrl } from "../types/opencode-zen"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +/** Older Zen configs allowed Chat/Responses overrides for Gemini. Repair only the loaded copy. */ +export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { + const providers = record(record(parsed)?.providers); + const provider = providers && Object.hasOwn(providers, "opencode-zen") + ? record(providers["opencode-zen"]) : undefined; + if (!provider || !openCodeZenBaseUrl(provider)) return; + const overrides = record(provider.modelAdapters); + if (!overrides) return; + let changed = false; + for (const [model, adapter] of Object.entries(overrides)) { + if (model.trim().toLowerCase().startsWith("gemini-") + && (adapter === "openai-chat" || adapter === "openai-responses")) { + delete overrides[model]; + changed = true; + } + } + if (!changed) return; + if (Object.keys(overrides).length === 0) delete provider.modelAdapters; + console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); +} diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index c435da8d6f6..1bb7acc8c4e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -33,6 +33,7 @@ type ServiceTierCapabilityProvider = Pick< | "modelAdapters" | "baseUrl" | "authMode" + | "googleMode" | "apiKeyTransport" | "chatServiceTier" | "fastWire" diff --git a/src/types/opencode-zen.ts b/src/types/opencode-zen.ts new file mode 100644 index 00000000000..0452a7bc966 --- /dev/null +++ b/src/types/opencode-zen.ts @@ -0,0 +1,29 @@ +/** Fixed-key OpenCode Zen destinations exposing the native Gemini endpoint. */ +export interface OpenCodeZenDestination { + readonly baseUrl?: unknown; + readonly authMode?: unknown; + readonly googleMode?: unknown; +} + +export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { + if (!provider || typeof provider.baseUrl !== "string") return undefined; + if (provider.authMode !== undefined && provider.authMode !== "key") return undefined; + if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; + try { + const url = new URL(provider.baseUrl.trim()); + const pathname = url.pathname.replace(/\/+$/, ""); + if (url.origin !== "https://opencode.ai" || url.username || url.password || url.search || url.hash) return undefined; + if (pathname !== "/zen/v1" && pathname !== "/inference/v1") return undefined; + return url.origin + pathname; + } catch { + return undefined; + } +} + +/** Keep the selected destination; Zen omits AI Studio's /v1beta segment. */ +export function openCodeZenGeminiEndpoint(provider: OpenCodeZenDestination, modelId: string): string | undefined { + const baseUrl = openCodeZenBaseUrl(provider); + return baseUrl && modelId.toLowerCase().startsWith("gemini-") + ? baseUrl + "/models/" + encodeURIComponent(modelId) + : undefined; +} diff --git a/src/types/wire.ts b/src/types/wire.ts index 59955f5bed1..92280d230dc 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,3 +1,5 @@ +import { openCodeZenBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; + /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so @@ -98,9 +100,7 @@ const WIRE_ADAPTER_PIN_PREFIXES: Readonly> = O }); /** Just enough of a provider config to tell whether it still points at the pinned endpoint. */ -export interface WirePinProvider { - readonly baseUrl?: unknown; -} +export interface WirePinProvider extends OpenCodeZenDestination {} function normalizedEndpoint(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -113,6 +113,7 @@ function normalizedEndpoint(value: unknown): string | undefined { } function prefixPinsFor(providerName: string, provider: WirePinProvider | undefined): Readonly> | undefined { + if (providerName === "opencode-zen" && openCodeZenBaseUrl(provider)) return { "gemini-": "google" }; if (!provider || !Object.hasOwn(WIRE_ADAPTER_PIN_PREFIXES, providerName)) return undefined; const rule = WIRE_ADAPTER_PIN_PREFIXES[providerName]!; return normalizedEndpoint(provider.baseUrl) === rule.endpoint ? rule.prefixes : undefined; diff --git a/structure/config.md b/structure/config.md index f803d408c5c..4a0ec4927e4 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-zen-upgrade.ts` removes saved Chat/Responses overrides for Gemini models on canonical keyed OpenCode Zen routes from the loaded copy. This lets older configurations adopt the native Gemini route without invalidating unrelated providers or accounts. It warns without rewriting the file; new writes still reject overrides of the pinned route. Other destinations, authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c522806f375..4358c59b26c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -564,15 +564,14 @@ destination. The input body, historical tool calls and results, and non-Meta req ## Shared type declarations -`src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`), -provider and account types (`src/types/provider.ts`, `src/types/accounts.ts`), and the internal -request shape (`src/types/request.ts`). Two files also own small resolvers that must agree at every -boundary. `src/types/tools.ts` owns tool-name identity: namespaced and dotted names, declared-name +`src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`), provider and account types (`src/types/provider.ts`, `src/types/accounts.ts`), and the internal request shape (`src/types/request.ts`). Small resolvers must also agree at every boundary. `src/types/tools.ts` owns tool-name identity: namespaced and dotted names, declared-name normalization, and `tool_choice` alias resolution, so every adapter matches a declared tool the same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-provider upstream HTTP-version pin, shared by config load, management writes and fetch, so no boundary accepts a value another rejects. It also hard-pins OpenCode Go's `claude-haiku-5-5`, `minimax-m2.5`, `minimax-m2.7`, `minimax-m3`, and `union-alpha` to Anthropic across direct and captured static policy resolution, even with Chat/Responses overrides. +For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth and AI Studio mode (both may be unset), no URL credentials, query or fragment, and optional trailing slashes. The adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. The pin does not apply to `opencode-free`, OpenCode Go, custom destinations, OAuth/forward auth, Vertex or Cloud Code Assist. + `src/types/config.ts` declares the optional per-phase `memoryModels` setting; `src/types/request.ts` carries the selected phase through combo handoffs without changing the public request model. [Memory phase routing](transports/responses-failover.md#memory-phase-routing) owns the selection rule. diff --git a/tests/config/config-opencode-zen-upgrade.test.ts b/tests/config/config-opencode-zen-upgrade.test.ts new file mode 100644 index 00000000000..11bd2dbdd51 --- /dev/null +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { getConfigPath, getDefaultConfig, loadConfig, validateConfigCandidate } from "../../src/config"; +import { sanitizeOpenCodeZenOverridesForLoad } from "../../src/config/opencode-zen-upgrade"; +import { createTempHome } from "../helpers/temp-home"; + +const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; +const WARNING = "OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."; + +function candidate(modelAdapters: unknown, overrides: Record = {}) { + return { + ...getDefaultConfig(), + defaultProvider: "opencode-zen", + providers: { + "opencode-zen": { + adapter: "openai-chat", baseUrl: BASES[0]!, authMode: "key", googleMode: "ai-studio", + apiKey: "synthetic-zen-upgrade-key", note: "Saved Zen provider", modelAdapters, ...overrides, + }, + "saved-gateway": { + adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "synthetic-other-key", + note: "Keep this provider", modelAdapters: { "gemini-3.7-flash": "openai-responses" }, + }, + }, + codexAccounts: [{ id: "saved-account", email: "fixture@example.test", alias: "Saved account", plan: "plus", isMain: false }], + codexAccountPriorities: { "saved-account": 25 }, + activeCodexAccountPinned: "saved-account", + pausedCodexAccountIds: ["saved-account"], + }; +} + +describe("OpenCode Zen saved-config upgrade", () => { + test.each(BASES)("loads old Gemini overrides at %s without losing providers, accounts or file bytes", baseUrl => { + const home = createTempHome("ocx-zen-config-upgrade-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const raw = candidate({ + "gemini-3.6-flash": "openai-chat", + "GeMiNi-3.7-Flash": "openai-responses", + " GEMINI-future ": "openai-chat", + "gpt-6.1-sol": "openai-responses", + }, { baseUrl }); + const bytes = Buffer.from(`\uFEFF${JSON.stringify(raw, null, 2)}\r\n`, "utf8"); + writeFileSync(getConfigPath(), bytes); + const filesBefore = readdirSync(home.root).sort(); + const loaded = loadConfig(); + + expect(loaded.defaultProvider).toBe("opencode-zen"); + expect(Object.keys(loaded.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(loaded.providers["opencode-zen"]).toMatchObject({ + adapter: "openai-chat", apiKey: raw.providers["opencode-zen"].apiKey, + note: "Saved Zen provider", modelAdapters: { "gpt-6.1-sol": "openai-responses" }, + }); + expect(loaded.providers["opencode-zen"].modelAdapters).toEqual({ "gpt-6.1-sol": "openai-responses" }); + expect(loaded.providers["saved-gateway"]).toMatchObject(raw.providers["saved-gateway"]); + expect(loaded.codexAccounts).toEqual(raw.codexAccounts); + expect(loaded.codexAccountPriorities).toEqual(raw.codexAccountPriorities); + expect(loaded.activeCodexAccountPinned).toBe(raw.activeCodexAccountPinned); + expect(loaded.pausedCodexAccountIds).toEqual(raw.pausedCodexAccountIds); + expect(readFileSync(getConfigPath())).toEqual(bytes); + expect(readdirSync(home.root).sort()).toEqual(filesBefore); + expect(warn.mock.calls.filter(call => call[0] === WARNING)).toEqual([[WARNING]]); + } finally { + warn.mockRestore(); + home.remove(); + } + }); + + test.each(["openai-chat", "openai-responses"])("removes an obsolete-only %s map when loading omitted auth/mode defaults", adapter => { + const home = createTempHome("ocx-zen-config-empty-map-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const raw = candidate({ " GeMiNi-3.7-flash ": adapter }, { authMode: undefined, googleMode: undefined }); + const bytes = JSON.stringify(raw); + writeFileSync(getConfigPath(), bytes); + const loaded = loadConfig(); + expect(loaded.providers["opencode-zen"]).toBeDefined(); + expect(Object.hasOwn(loaded.providers["opencode-zen"], "modelAdapters")).toBe(false); + expect(loaded.providers["saved-gateway"]).toMatchObject(raw.providers["saved-gateway"]); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { + warn.mockRestore(); + home.remove(); + } + }); + + test.each(["openai-chat", "openai-responses"])("strict candidate validation still rejects a new %s override without modifying it", adapter => { + for (const model of ["gemini-3.7-flash", "GeMiNi-future", " GEMINI-3.6-flash "]) { + const raw = candidate({ [model]: adapter }); + const original = structuredClone(raw); + const result = validateConfigCandidate(raw); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("modelAdapters"); + expect(result.error).toContain("only speaks one wire"); + } + expect(raw).toEqual(original); + } + }); +}); + +describe("OpenCode Zen load sanitizer boundaries", () => { + const exclusions: Array<[string, Record]> = [ + ["custom destination", { baseUrl: "https://gateway.example.test/v1" }], + ["Go destination", { baseUrl: "https://opencode.ai/zen/go/v1" }], + ["lookalike host", { baseUrl: "https://opencode.ai.evil.test/zen/v1" }], + ["query-bearing URL", { baseUrl: "https://opencode.ai/zen/v1?route=other" }], + ["OAuth", { authMode: "oauth" }], + ["forward auth", { authMode: "forward" }], + ["no auth", { authMode: "none" }], + ["Vertex", { googleMode: "vertex" }], + ["Cloud Code Assist", { googleMode: "cloud-code-assist" }], + ]; + test.each(exclusions)("preserves overrides and emits no upgrade warning for %s", (_label, overrides) => { + const raw = candidate({ " GeMiNi-3.7-flash ": "openai-responses" }, overrides); + const original = structuredClone(raw); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + expect(warn.mock.calls).toEqual([]); + } finally { warn.mockRestore(); } + }); + + test("only the canonical provider name is migrated", () => { + const raw = { providers: { "my-zen": candidate({ "gemini-3.7-flash": "openai-chat" }).providers["opencode-zen"] } }; + const original = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + }); + + test("invalid overrides remain visible to strict validation while obsolete allowed entries are removed", () => { + const retained = { "gemini-invalid": "google", "gemini-spaced": " openai-chat", "gemini-number": 42, + "unrelated-model": "bogus", "not-gemini-model": "openai-chat", " ": "openai-responses" }; + const raw = candidate({ ...retained, "gemini-3.7-flash": "openai-responses" }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual(retained); + expect(validateConfigCandidate(raw).ok).toBe(false); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { warn.mockRestore(); } + }); + + test("malformed containers and maps are left untouched", () => { + for (const raw of [undefined, null, false, [], {}, { providers: null }, { providers: [] }, + { providers: { "opencode-zen": null } }, { providers: { "opencode-zen": [] } }, + ...[undefined, null, "openai-chat", 42, ["openai-chat"]].map(map => candidate(map))]) { + const original = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + } + }); + + test("warns once with fixed text, removes an empty map and is idempotent", () => { + const raw = candidate({ "gemini-sensitive-fixture": "openai-chat", "gemini-other-fixture": "openai-responses" }, + { baseUrl: " https://opencode.ai/zen/v1/// " }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(Object.hasOwn(raw.providers["opencode-zen"], "modelAdapters")).toBe(false); + const sanitized = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(sanitized); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..032da2c55db 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -517,7 +517,7 @@ "compatibility-manifest.test.ts": "codex-integration", "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", "config-load-degrade.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", @@ -873,7 +873,7 @@ "opencode-go-luna-wire.test.ts": "providers", "opencode-go-muse-context.test.ts": "providers", "opencode-go-muse-vision.test.ts": "providers", "opencode-go-quota.test.ts": "providers", "opencode-go-session-header.test.ts": "providers", "opencode-management-transport.test.ts": "providers", - "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", + "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-gemini-wire.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", "opengateway-provider.test.ts": "providers", "opengateway-key-validation.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", "opper-provider.test.ts": "providers", "tokenlab-protocols.test.ts": "providers", diff --git a/tests/providers/opencode-zen-gemini-wire.test.ts b/tests/providers/opencode-zen-gemini-wire.test.ts new file mode 100644 index 00000000000..fb5dd4cdc59 --- /dev/null +++ b/tests/providers/opencode-zen-gemini-wire.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { resolveFastPolicy } from "../../src/providers/fastwire"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../src/providers/registry"; +import { resolveModelPolicy } from "../../src/providers/resolved-model-policy"; +import { captureFastPolicyAuthority, fastPolicyForModel, serviceTierSupportForModel } from "../../src/providers/service-tier"; +import { parseRequest } from "../../src/responses/parser"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { captureWireAdapterHardPinPrefixes, isWirePinnedModel, pinnedWireAdapter } from "../../src/types/wire"; +import { openCodeZenBaseUrl, openCodeZenGeminiEndpoint } from "../../src/types/opencode-zen"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; +const MODELS = ["gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "gemini-4-argon", "Gemini-future"]; +const INBOUNDS = ["responses", "chat", "anthropic"] as const; + +function zen(overrides: Partial = {}): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-zen")!), apiKey: "synthetic-zen-key", ...overrides }; +} + +function staticPolicy(providerName: string, provider: OcxProviderConfig, modelId: string, inboundWire: typeof INBOUNDS[number]) { + return resolveModelPolicy({ + providerName, provider, modelId, inboundWire, + registryEntry: getProviderRegistryEntry(providerName), + transportMatchedRegistry: providerMatchesRegistryTransport(providerName, provider), + }); +} + +function expectWire(providerName: string, provider: OcxProviderConfig, modelId: string, adapter: string) { + for (const inbound of INBOUNDS) { + const runtime = resolveWireProtocolOverride(providerName, modelId, provider, inbound); + const policy = staticPolicy(providerName, provider, modelId, inbound); + expect(runtime.adapter).toBe(adapter); + expect(resolveWireProtocolOverride(providerName, modelId, runtime, inbound).adapter).toBe(adapter); + expect(policy.model.adapter).toBe(adapter); + expect(resolveWireProtocolOverride(providerName, modelId, provider, inbound, policy).adapter).toBe(adapter); + expect(fastPolicyForModel(provider, modelId, providerName, inbound).adapter).toBe(adapter); + const authority = captureFastPolicyAuthority(providerName, provider, providerMatchesRegistryTransport(providerName, provider)); + expect(resolveFastPolicy(authority, modelId, inbound).adapter).toBe(adapter); + } +} + +describe("OpenCode Zen Gemini destination and wire policy", () => { + test.each(BASES)("pins Gemini models at %s across runtime, static and Fast policy", baseUrl => { + for (const model of MODELS) for (const override of ["openai-chat", "openai-responses"]) { + const provider = zen({ baseUrl, modelAdapters: { [model]: override }, supportsServiceTier: true }); + expectWire("opencode-zen", provider, model, "google"); + expect(isWirePinnedModel("opencode-zen", model, provider)).toBe(true); + expect(pinnedWireAdapter("opencode-zen", model, provider)).toBe("google"); + expect(staticPolicy("opencode-zen", provider, model, "responses").provenance.model.adapter).toBe("hard-pin"); + expect(serviceTierSupportForModel(provider, model, "opencode-zen")).toBe(false); + expect(fastPolicyForModel(provider, model, "opencode-zen").fastWire).toBeNull(); + } + }); + + test.each(BASES)("normalizes official URL %s with omitted or explicit key/AI Studio mode", baseUrl => { + for (const explicit of [false, true]) { + const provider = zen({ baseUrl: ` ${baseUrl}/// `, authMode: explicit ? "key" : undefined, + googleMode: explicit ? "ai-studio" : undefined }); + expect(openCodeZenBaseUrl(provider)).toBe(baseUrl); + expect(openCodeZenGeminiEndpoint(provider, "gemini-future/preview")).toBe(`${baseUrl}/models/gemini-future%2Fpreview`); + expectWire("opencode-zen", provider, "gemini-3.7-flash", "google"); + } + }); + + const unrelatedDestinations = [ + "https://custom.example.test/v1", "http://opencode.ai/zen/v1", "https://opencode.ai.evil.test/zen/v1", + "https://opencode.ai:8443/zen/v1", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1/extra", + "https://opencode.ai/ZEN/v1", "https://opencode.ai/zen/v1?route=other", "https://opencode.ai/zen/v1#other", + "https://user:pass" + "@opencode.ai/zen/v1", "https://opencode.ai@evil.test/zen/v1", "not a URL", + ]; + test.each(unrelatedDestinations)("does not impose the native wire on %s", baseUrl => { + const model = "gemini-3.7-flash"; + const provider = zen({ baseUrl, modelAdapters: { [model]: "openai-responses" } }); + expect(openCodeZenBaseUrl(provider)).toBeUndefined(); + expect(openCodeZenGeminiEndpoint(provider, model)).toBeUndefined(); + expect(captureWireAdapterHardPinPrefixes("opencode-zen", provider)).toEqual({}); + expect(isWirePinnedModel("opencode-zen", model, provider)).toBe(false); + expectWire("opencode-zen", provider, model, "openai-responses"); + }); + + test("non-key auth and non-AI Studio modes do not acquire a Zen pin", () => { + for (const changes of [ + { authMode: "oauth" }, { authMode: "forward" }, { authMode: "local" }, + { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, + ] as Partial[]) { + const provider = zen({ ...changes, modelAdapters: { "gemini-3.7-flash": "openai-responses" } }); + expect(openCodeZenBaseUrl(provider)).toBeUndefined(); + expect(openCodeZenGeminiEndpoint(provider, "gemini-3.7-flash")).toBeUndefined(); + expectWire("opencode-zen", provider, "gemini-3.7-flash", "openai-responses"); + } + expect(openCodeZenBaseUrl(undefined)).toBeUndefined(); + expect(openCodeZenBaseUrl({ baseUrl: 123 })).toBeUndefined(); + }); + + test("captured prefix authority is detached from later destination changes", () => { + const provider = zen({ modelAdapters: { "gemini-3.7-flash": "openai-responses" } }); + const prefixes = captureWireAdapterHardPinPrefixes("opencode-zen", provider); + const authority = captureFastPolicyAuthority("opencode-zen", provider, true); + provider.baseUrl = "https://custom.example.test/v1"; + expect(Object.isFrozen(prefixes)).toBe(true); + expect(prefixes).toEqual({ "gemini-": "google" }); + expect(resolveFastPolicy(authority, "gemini-3.7-flash").adapter).toBe("google"); + expect(fastPolicyForModel(provider, "gemini-3.7-flash", "opencode-zen").adapter).toBe("openai-responses"); + }); + + test("Zen siblings and other provider identities retain their existing wires", () => { + for (const model of ["claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { + expect(openCodeZenGeminiEndpoint(zen(), model)).toBeUndefined(); + expectWire("opencode-zen", zen(), model, "openai-chat"); + expectWire("opencode-zen", zen({ modelAdapters: { [model]: "openai-responses" } }), model, "openai-responses"); + } + for (const name of ["my-zen", "opencode-free", "opencode-go"]) { + expectWire(name, zen({ modelAdapters: { "gemini-3.7-flash": "openai-chat" } }), "gemini-3.7-flash", "openai-chat"); + } + const go = providerConfigSeed(getProviderRegistryEntry("opencode-go")!); + expectWire("opencode-go", go, "gemini-3.7-flash", "openai-chat"); + for (const model of ["claude-haiku-5-5", "minimax-m2.7", "union-alpha"]) { + expectWire("opencode-go", { ...go, modelAdapters: { [model]: "openai-responses" } }, model, "anthropic"); + } + }); +}); + +describe("OpenCode Zen Gemini native request", () => { + for (const baseUrl of BASES) for (const model of MODELS) for (const stream of [false, true]) { + test(`${baseUrl} preserves ${model} in ${stream ? "streaming" : "buffered"} native requests`, async () => { + const provider = resolveWireProtocolOverride("opencode-zen", model, zen({ baseUrl, apiKey: " synthetic-zen-key " })); + const adapter = withTestTranslatorBudget(createGoogleAdapter(provider)); + const request = await adapter.buildRequest(parseRequest({ model, input: "hello", stream, service_tier: "priority" })); + expect(request.url).toBe(`${baseUrl}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.method).toBe("POST"); + const headers = new Headers(request.headers); + expect(headers.get("x-goog-api-key")).toBe("synthetic-zen-key"); + expect(headers.has("authorization")).toBe(false); + const body = JSON.parse(request.body); + expect(body.contents).toContainEqual({ role: "user", parts: [{ text: "hello" }] }); + for (const key of ["messages", "input", "model", "stream", "service_tier"]) expect(body[key]).toBeUndefined(); + }); + } + + test("rejects an empty Zen key before any send", async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter(zen({ adapter: "google", apiKey: " " }))); + await expect(adapter.buildRequest(parseRequest({ model: "gemini-3.7-flash", input: "hello" }))) + .rejects.toThrow("OpenCode Zen requires a non-empty API key"); + }); + + test("direct AI Studio, custom Google and Vertex keep their URL and model rules", async () => { + for (const model of ["gemini-3.6-flash", "gemini-3.7-flash"]) for (const stream of [true, false]) { + for (const baseUrl of ["https://generativelanguage.googleapis.com", "https://custom.example.test/v1"]) { + const adapter = withTestTranslatorBudget(createGoogleAdapter({ adapter: "google", baseUrl, apiKey: "synthetic-google-key" })); + const request = await adapter.buildRequest(parseRequest({ model, input: "hello", stream })); + expect(request.url).toBe(`${baseUrl}/v1beta/models/${model}-tiered:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + } + const vertex = withTestTranslatorBudget(createGoogleAdapter(zen({ adapter: "google", googleMode: "vertex" }))); + const request = await vertex.buildRequest(parseRequest({ model, input: "hello", stream })); + expect(request.url).toBe(`https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + } + }); +}); + +describe("OpenCode Zen Gemini Responses translation", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + }); + + // Routing canonicalizes the fixed Zen preset to /zen/v1; both accepted endpoint + // spellings are exercised without that registry normalization in the adapter cases above. + for (const baseUrl of [BASES[0]!]) for (const stream of [true, false]) { + test(`${baseUrl} translates text and a two-turn tool continuation (${stream ? "SSE" : "JSON"})`, async () => { + const model = "gemini-3.7-flash"; + const requests: Array<{ url: string; headers: Headers; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + const parts = requests.length === 1 + ? [{ text: "Checking weather." }, { functionCall: { id: "zen_weather_call", name: "weather", args: { city: "Paris" } }, thoughtSignature: "synthetic-zen-tool-signature-0123456789" }] + : [{ text: "Paris is sunny." }]; + const payload = { candidates: [{ content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 } }; + return stream + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof fetch; + releaseSpendHome = acquireOwnedSpendHome(); + const config = { providers: { "opencode-zen": zen({ baseUrl, modelAdapters: { [model]: "openai-responses" } }) } } as unknown as OcxConfig; + const drive = async (input: unknown[]) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `opencode-zen/${model}`, input, stream, + tools: [{ type: "function", name: "weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }] }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + const text = await response.text(); + expect(response.status, text).toBe(200); + if (!stream) return JSON.parse(text); + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const completed = events.find(event => event.type === "response.completed"); + expect(completed).toBeDefined(); + return completed.response; + }; + const initial = { role: "user", content: "Check Paris weather" }; + const first = await drive([initial]); + const call = first.output.find((item: { type: string }) => item.type === "function_call"); + expect(call).toMatchObject({ name: "weather", arguments: '{"city":"Paris"}' }); + expect(typeof call.call_id).toBe("string"); + expect(call.call_id, JSON.stringify(first.output)).not.toBe(""); + expect(JSON.stringify(first.output)).toContain("Checking weather."); + const second = await drive([initial, ...first.output, { type: "function_call_output", call_id: call.call_id, output: "sunny" }]); + expect(second.status).toBe("completed"); + expect(JSON.stringify(second.output)).toContain("Paris is sunny."); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe(`${baseUrl}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.headers.get("x-goog-api-key")).toBe("synthetic-zen-key"); + expect(request.headers.has("authorization")).toBe(false); + expect(request.body.tools[0].functionDeclarations[0].name).toBe("weather"); + expect(request.body.messages).toBeUndefined(); + expect(request.body.input).toBeUndefined(); + } + const contents = requests[1]!.body.contents; + const callIndex = contents.findIndex((turn: { parts: Array> }) => turn.parts.some(part => part.functionCall)); + expect(callIndex).toBeGreaterThan(0); + expect(contents[callIndex]).toMatchObject({ role: "model", parts: expect.arrayContaining([ + expect.objectContaining({ functionCall: expect.objectContaining({ name: "weather", args: { city: "Paris" } }), thoughtSignature: "synthetic-zen-tool-signature-0123456789" }), + ]) }); + expect(contents[callIndex + 1]).toMatchObject({ role: "user", parts: expect.arrayContaining([ + expect.objectContaining({ functionResponse: expect.objectContaining({ name: "weather", response: { result: "sunny" } }) }), + ]) }); + }); + } +}); From fbf78532800920a4fd4ddd969225b722490ad7b0 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Thu, 15 Oct 2026 16:57:03 +0800 Subject: [PATCH 2/2] fix(config): preserve legacy Zen settings across config readers --- .../src/content/docs/guides/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- src/config/diagnostics.ts | 2 + src/config/opencode-zen-upgrade.ts | 45 +++++-- structure/config.md | 2 +- .../config-opencode-zen-upgrade.test.ts | 120 +++++++++++++++++- 6 files changed, 159 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 91bd23720e2..c967966d0e1 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -765,7 +765,7 @@ headers. That is separate from the keyless desktop quota OpenCode advertises synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). -Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. +Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Older Gemini Chat/Responses overrides and Responses-only hosted-tool preferences are ignored when reading saved configurations. **The keyless `opencode-free` tier is currently closed to third-party clients.** Zen refuses any request that arrives without an `x-opencode-session` header, answering with error type diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ce39d9305ea..959f7dd2676 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -339,7 +339,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `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`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | -| `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 and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | +| `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. 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 and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; 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. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index d91f8bd602b..2d7050e4e2e 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -18,6 +18,7 @@ import { isMissingPathError } from "./atomic-write"; import { getConfigDir, getConfigPath } from "./paths"; import { getDefaultConfig } from "./proxy-env"; import { salvageConfigCandidate } from "./salvage"; +import { sanitizeOpenCodeZenOverridesForLoad } from "./opencode-zen-upgrade"; import { sanitizeReasoningPinsForLoad, sanitizeRetryOn429ForLoad, @@ -740,6 +741,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { try { const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); + sanitizeOpenCodeZenOverridesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the // schema and send the caller a default-config fallback (the config command could then diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts index 5ac20835662..974e69ebe23 100644 --- a/src/config/opencode-zen-upgrade.ts +++ b/src/config/opencode-zen-upgrade.ts @@ -1,27 +1,52 @@ import { openCodeZenBaseUrl } from "../types/opencode-zen"; +import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } -/** Older Zen configs allowed Chat/Responses overrides for Gemini. Repair only the loaded copy. */ +function isGeminiModel(model: string): boolean { + return model.trim().toLowerCase().startsWith("gemini-"); +} + +/** Repair legacy Zen wire overrides and dependent preferences in file-backed reads only. */ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { const providers = record(record(parsed)?.providers); const provider = providers && Object.hasOwn(providers, "opencode-zen") ? record(providers["opencode-zen"]) : undefined; if (!provider || !openCodeZenBaseUrl(provider)) return; - const overrides = record(provider.modelAdapters); - if (!overrides) return; - let changed = false; + const overrides = record(provider.modelAdapters) ?? {}; + const preferences = record(provider.modelPreferHostedTools); + let preferencesChanged = false; + if (preferences) { + const unsupported = declaredUnsupportedHostedTools(provider as { unsupportedHostedTools?: readonly string[] }); + for (const [model, tools] of Object.entries(preferences)) { + // Capture the old wire before deleting overrides. An explicit non-Responses entry + // must not inherit a provider-wide Responses default for this migration. + const oldAdapter = Object.hasOwn(overrides, model) ? overrides[model] : provider.adapter; + if (!isGeminiModel(model) || oldAdapter !== "openai-responses" + || !Array.isArray(tools) || tools.length === 0 || tools.some(tool => tool !== "image_generation") + || isHostedToolUnsupportedForModel(model, "image_generation", undefined, unsupported)) continue; + delete preferences[model]; + preferencesChanged = true; + } + if (preferencesChanged && Object.keys(preferences).length === 0) delete provider.modelPreferHostedTools; + } + let overridesChanged = false; for (const [model, adapter] of Object.entries(overrides)) { - if (model.trim().toLowerCase().startsWith("gemini-") - && (adapter === "openai-chat" || adapter === "openai-responses")) { + // Retain invalid preference/wire pairs so repeated normalization cannot admit them. + if (preferences && Object.hasOwn(preferences, model)) continue; + if (isGeminiModel(model) && (adapter === "openai-chat" || adapter === "openai-responses")) { delete overrides[model]; - changed = true; + overridesChanged = true; } } - if (!changed) return; - if (Object.keys(overrides).length === 0) delete provider.modelAdapters; - console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); + if (overridesChanged) { + if (Object.keys(overrides).length === 0) delete provider.modelAdapters; + console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); + } + if (preferencesChanged) { + console.warn("OpenCode Zen Gemini models now use the native Google API; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); + } } diff --git a/structure/config.md b/structure/config.md index 4a0ec4927e4..6a18ea2beea 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-zen-upgrade.ts` removes saved Chat/Responses overrides for Gemini models on canonical keyed OpenCode Zen routes from the loaded copy. This lets older configurations adopt the native Gemini route without invalidating unrelated providers or accounts. It warns without rewriting the file; new writes still reject overrides of the pinned route. Other destinations, authentication modes and invalid override values retain normal validation. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-zen-upgrade.ts` normalizes saved Gemini overrides and their Responses-only hosted-tool preferences on canonical keyed OpenCode Zen routes. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers and accounts. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Other destinations, authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/tests/config/config-opencode-zen-upgrade.test.ts b/tests/config/config-opencode-zen-upgrade.test.ts index 11bd2dbdd51..bda564bc285 100644 --- a/tests/config/config-opencode-zen-upgrade.test.ts +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -2,6 +2,9 @@ import { describe, expect, spyOn, test } from "bun:test"; import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { getConfigPath, getDefaultConfig, loadConfig, validateConfigCandidate } from "../../src/config"; import { sanitizeOpenCodeZenOverridesForLoad } from "../../src/config/opencode-zen-upgrade"; +import { readConfigDiagnostics, readConfigFileSnapshot } from "../../src/config/diagnostics"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; import { createTempHome } from "../helpers/temp-home"; const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; @@ -19,6 +22,7 @@ function candidate(modelAdapters: unknown, overrides: Record = "saved-gateway": { adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "synthetic-other-key", note: "Keep this provider", modelAdapters: { "gemini-3.7-flash": "openai-responses" }, + modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] }, }, }, codexAccounts: [{ id: "saved-account", email: "fixture@example.test", alias: "Saved account", plan: "plus", isMain: false }], @@ -84,6 +88,77 @@ describe("OpenCode Zen saved-config upgrade", () => { } }); + test.each(BASES)("diagnostics, export and persisted edits preserve saved state at %s", async baseUrl => { + const home = createTempHome("ocx-zen-config-readers-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + const previousExit = process.exitCode; + try { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { baseUrl }); + const bytes = "\uFEFF" + JSON.stringify(raw, null, 2) + "\r\n"; + writeFileSync(getConfigPath(), bytes); + const snapshot = readConfigFileSnapshot(); + expect(snapshot.raw).toBe(bytes); + expect(snapshot.diagnostics.source).toBe("file"); + expect(snapshot.diagnostics.error).toBeNull(); + expect(snapshot.diagnostics.config).toEqual(loadConfig()); + const exported = home.path("export.json"); + expect(await handleConfigCommand(["export", exported, "--json"])).toBe(0); + expect(JSON.parse(readFileSync(exported, "utf8"))).toEqual(snapshot.diagnostics.config); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(await handleConfigCommand(["set", "port", "10201", "--json"])).toBe(0); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(persisted.port).toBe(10201); + expect(Object.keys(persisted.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(persisted.providers["opencode-zen"]).toMatchObject({ + apiKey: raw.providers["opencode-zen"].apiKey, note: "Saved Zen provider", + }); + expect(persisted.providers["opencode-zen"].modelAdapters).toBeUndefined(); + expect(persisted.providers["saved-gateway"]).toEqual(raw.providers["saved-gateway"]); + expect(persisted.codexAccounts).toEqual(raw.codexAccounts); + expect(persisted.codexAccountPriorities).toEqual(raw.codexAccountPriorities); + expect(persisted.activeCodexAccountPinned).toBe(raw.activeCodexAccountPinned); + expect(persisted.pausedCodexAccountIds).toEqual(raw.pausedCodexAccountIds); + expect(readConfigDiagnostics()).toMatchObject({ source: "file", error: null }); + } finally { + warn.mockRestore(); out.mockRestore(); err.mockRestore(); process.exitCode = previousExit; + await flushConfigDirHardeningAndReaps(home.root); + home.remove(); + } + }); + + test.each(["model", "provider"])("migrates only valid legacy Gemini hosted preferences from %s Responses", mode => { + const home = createTempHome("ocx-zen-hosted-upgrade-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const unrelated = mode === "model" ? { "gpt-6.1-sol": ["image_generation"] } : {}; + const raw = candidate(mode === "model" + ? { "gemini-3.7-flash": "openai-responses", "gpt-6.1-sol": "openai-responses" } : undefined, { + adapter: mode === "provider" ? "openai-responses" : "openai-chat", + modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"], ...unrelated }, + }); + const bytes = JSON.stringify(raw); + writeFileSync(getConfigPath(), bytes); + const loaded = loadConfig(); + expect(Object.keys(loaded.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(loaded.codexAccounts).toEqual(raw.codexAccounts); + expect(loaded.providers["opencode-zen"].modelPreferHostedTools).toEqual(mode === "model" ? unrelated : undefined); + expect(loaded.providers["saved-gateway"]).toEqual(raw.providers["saved-gateway"]); + expect(readConfigDiagnostics()).toMatchObject({ source: "file", error: null, config: loaded }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); home.remove(); } + }); + + test("new writes still reject Google-incompatible hosted preferences without mutating the candidate", () => { + const raw = candidate(undefined, { modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] } }); + const original = structuredClone(raw); + const result = validateConfigCandidate(raw); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("modelPreferHostedTools"); + expect(raw).toEqual(original); + }); + test.each(["openai-chat", "openai-responses"])("strict candidate validation still rejects a new %s override without modifying it", adapter => { for (const model of ["gemini-3.7-flash", "GeMiNi-future", " GEMINI-3.6-flash "]) { const raw = candidate({ [model]: adapter }); @@ -112,7 +187,9 @@ describe("OpenCode Zen load sanitizer boundaries", () => { ["Cloud Code Assist", { googleMode: "cloud-code-assist" }], ]; test.each(exclusions)("preserves overrides and emits no upgrade warning for %s", (_label, overrides) => { - const raw = candidate({ " GeMiNi-3.7-flash ": "openai-responses" }, overrides); + const raw = candidate({ " GeMiNi-3.7-flash ": "openai-responses" }, { + ...overrides, modelPreferHostedTools: { " GeMiNi-3.7-flash ": ["image_generation"] }, + }); const original = structuredClone(raw); const warn = spyOn(console, "warn").mockImplementation(() => {}); try { @@ -142,6 +219,47 @@ describe("OpenCode Zen load sanitizer boundaries", () => { } finally { warn.mockRestore(); } }); + test("malformed, denied and non-Responses Gemini preferences remain subject to validation", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const value of [null, "image_generation", [], ["web_search"], ["image_generation", 42]]) { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { + modelPreferHostedTools: { "gemini-3.7-flash": value }, + }); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"]).toMatchObject({ modelPreferHostedTools: { "gemini-3.7-flash": value } }); + expect(validateConfigCandidate(raw).ok).toBe(false); + } + for (const overrides of [ + { unsupportedHostedTools: ["image_gen"], modelAdapters: { "gemini-3.7-flash": "openai-responses" } }, + { modelAdapters: undefined }, + { adapter: "openai-responses", modelAdapters: { "gemini-3.7-flash": "openai-chat" } }, + ]) { + const prefs = { "gemini-3.7-flash": ["image_generation"] }; + const raw = candidate(undefined, { ...overrides, modelPreferHostedTools: prefs }); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"]).toMatchObject({ modelPreferHostedTools: prefs }); + const once = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(once); + expect(validateConfigCandidate(raw).ok).toBe(false); + } + } finally { warn.mockRestore(); } + }); + + test("malformed hosted-tool denials do not throw before load degradation", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const unsupportedHostedTools of [null, 42, "image_gen", {}, [null, 42]]) { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { + unsupportedHostedTools, modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] }, + }); + expect(() => sanitizeOpenCodeZenOverridesForLoad(raw)).not.toThrow(); + expect(raw.providers["opencode-zen"]).toMatchObject({ unsupportedHostedTools }); + expect(Object.hasOwn(raw.providers["opencode-zen"], "modelPreferHostedTools")).toBe(false); + } + } finally { warn.mockRestore(); } + }); test("malformed containers and maps are left untouched", () => { for (const raw of [undefined, null, false, [], {}, { providers: null }, { providers: [] }, { providers: { "opencode-zen": null } }, { providers: { "opencode-zen": [] } },