Skip to content
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,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, 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.
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. Claude models on these API-key routes use Anthropic Messages automatically, regardless of `googleMode`. Older Gemini and Claude Chat/Responses overrides and Responses-only hosted-tool preferences are ignored when reading saved configurations.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
lidge-jun marked this conversation as resolved.
Resolved

**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
Expand Down
17 changes: 9 additions & 8 deletions src/config/opencode-zen-upgrade.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { openCodeZenBaseUrl } from "../types/opencode-zen";
import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "../types/opencode-zen";
import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";

function record(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
}

function isGeminiModel(model: string): boolean {
return model.trim().toLowerCase().startsWith("gemini-");
function isNativeZenModel(model: string, provider: OpenCodeZenDestination): boolean {
const folded = model.trim().toLowerCase();
return folded.startsWith("claude-") || (folded.startsWith("gemini-") && openCodeZenBaseUrl(provider) !== undefined);
}

/** 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;
if (!provider || !openCodeZenKeyBaseUrl(provider)) return;
const overrides = record(provider.modelAdapters) ?? {};
const preferences = record(provider.modelPreferHostedTools);
let preferencesChanged = false;
Expand All @@ -25,7 +26,7 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void {
// 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"
if (!isNativeZenModel(model, provider) || 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];
Expand All @@ -37,16 +38,16 @@ export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void {
for (const [model, adapter] of Object.entries(overrides)) {
// 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")) {
if (isNativeZenModel(model, provider) && (adapter === "openai-chat" || adapter === "openai-responses")) {
delete overrides[model];
overridesChanged = true;
}
}
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.");
console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; 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.");
console.warn("OpenCode Zen Gemini and Claude models now use their native APIs; legacy Responses-only hosted-tool preferences were ignored while loading config.json.");
}
}
12 changes: 9 additions & 3 deletions src/types/opencode-zen.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/** Fixed-key OpenCode Zen destinations exposing the native Gemini endpoint. */
/** Fixed-key OpenCode Zen destinations supporting native model protocols. */
export interface OpenCodeZenDestination {
readonly baseUrl?: unknown;
readonly authMode?: unknown;
readonly googleMode?: unknown;
}

export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined {
/** Match the official Zen destination and API-key authentication. */
export function openCodeZenKeyBaseUrl(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(/\/+$/, "");
Expand All @@ -20,6 +20,12 @@ export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined)
}
}

/** Gemini's native endpoint applies only in AI Studio mode. */
export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined {
if (provider?.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined;
return openCodeZenKeyBaseUrl(provider);
}

/** Keep the selected destination; Zen omits AI Studio's /v1beta segment. */
export function openCodeZenGeminiEndpoint(provider: OpenCodeZenDestination, modelId: string): string | undefined {
const baseUrl = openCodeZenBaseUrl(provider);
Expand Down
6 changes: 4 additions & 2 deletions src/types/wire.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isOpenCodeGoGeminiDestination, OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go";
import { openCodeZenBaseUrl, type OpenCodeZenDestination } from "./opencode-zen";
import { openCodeZenBaseUrl, openCodeZenKeyBaseUrl, type OpenCodeZenDestination } from "./opencode-zen";

/**
* Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the
Expand Down Expand Up @@ -106,7 +106,9 @@ const CREDENTIAL_PINNED_ADAPTERS: Readonly<Record<string, Readonly<Record<string

function prefixPinsFor(providerName: string, provider: WirePinProvider | undefined): Readonly<Record<string, string>> | undefined {
if (Object.hasOwn(CREDENTIAL_PINNED_ADAPTERS, providerName)) return CREDENTIAL_PINNED_ADAPTERS[providerName];
if (providerName === "opencode-zen" && openCodeZenBaseUrl(provider)) return { "gemini-": "google" };
if (providerName === "opencode-zen" && openCodeZenKeyBaseUrl(provider)) {
return openCodeZenBaseUrl(provider) ? { "gemini-": "google", "claude-": "anthropic" } : { "claude-": "anthropic" };
Comment thread
norths7ar marked this conversation as resolved.
Resolved
}
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;
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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.
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 and Claude 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,
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-pr
config load, management writes and fetch, so no boundary accepts a value another rejects. It also hard-pins OpenCode Go's
Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. The existing `opencode-go` preset pins saved URLs to its canonical endpoint; its inference alias supplies destination metadata and affinity to differently named rows. `src/types/opencode-go.ts` shares exact native pins (including Qwen3.7 Plus/3.8 Max/3.8 Flash) and overridable Responses defaults (including GPT 6 Luna); new native pins use the pinned canonical Go key destination even in pure policy captures of saved rows, while the five legacy name pins retain compatibility; unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. Every `dsh-account` model is pinned to the `dsh-account` adapter whatever `baseUrl` says, because that adapter is the [DSH account token's destination boundary](providers/dsh-account.md#destinations).

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/<encoded-model-id>: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.
For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google` and `claude-*` to `anthropic`, 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 (which may be unset), no URL credentials, query or fragment, and optional trailing slashes. Claude uses the existing Anthropic Messages adapter and does not depend on the Google mode setting. The Google adapter appends `/models/<encoded-model-id>:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. These pins do not apply to `opencode-free`, OpenCode Go, custom destinations or OAuth/forward auth. The Gemini pin additionally requires AI Studio mode (which may be unset), excluding Vertex and 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
Expand Down
26 changes: 25 additions & 1 deletion tests/config/config-opencode-zen-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,32 @@ import { handleConfigCommand } from "../../src/cli/config-command";
import { flushConfigDirHardeningAndReaps } from "../../src/config/paths";
import { createTempHome } from "../helpers/temp-home";

test("Claude saved overrides and Responses-only preferences migrate without changing unrelated models", () => {
const raw = candidate({ "claude-sonnet-5-5": "openai-chat", "claude-opus-5-5": "openai-responses", "qwen-model": "openai-chat" }, {
modelPreferHostedTools: { "claude-opus-5-5": ["image_generation"] },
});
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
sanitizeOpenCodeZenOverridesForLoad(raw);
expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "qwen-model": "openai-chat" });
expect(raw.providers["opencode-zen"].modelPreferHostedTools).toBeUndefined();
expect(validateConfigCandidate(raw).ok).toBe(true);
} finally { warn.mockRestore(); }
});

test.each(["vertex", "cloud-code-assist"])("Claude migration preserves Google mode %s and Gemini overrides", googleMode => {
const raw = candidate({ "claude-opus-5-5": "openai-responses", "gemini-3.7-flash": "openai-responses" }, { googleMode });
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
sanitizeOpenCodeZenOverridesForLoad(raw);
expect(raw.providers["opencode-zen"].modelAdapters).toEqual({ "gemini-3.7-flash": "openai-responses" });
expect(raw.providers["opencode-zen"].googleMode).toBe(googleMode);
expect(validateConfigCandidate(raw).ok).toBe(true);
} finally { warn.mockRestore(); }
});

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.";
const WARNING = "OpenCode Zen Gemini and Claude models now use their native APIs; obsolete Chat/Responses overrides were ignored while loading config.json.";

function candidate(modelAdapters: unknown, overrides: Record<string, unknown> = {}) {
return {
Expand Down
14 changes: 12 additions & 2 deletions tests/providers/opencode-zen-gemini-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ function expectWire(providerName: string, provider: OcxProviderConfig, modelId:
}

describe("OpenCode Zen Gemini destination and wire policy", () => {
test("Claude uses Messages across runtime, static and Fast policy", () => {
for (const baseUrl of BASES) for (const model of ["claude-sonnet-5-5", "claude-opus-5-5", "claude-haiku-5-5", "Claude-future"]) {
for (const googleMode of [undefined, "ai-studio", "vertex", "cloud-code-assist"] as const) for (const adapter of ["openai-chat", "openai-responses"] as const) {
expectWire("opencode-zen", zen({ baseUrl, googleMode, modelAdapters: { [model]: adapter } }), model, "anthropic");
}
expectWire("renamed-zen", zen({ baseUrl }), model, "openai-chat");
expectWire("opencode-zen", zen({ baseUrl: "https://custom.example.test/v1" }), model, "openai-chat");
expectWire("opencode-zen", zen({ baseUrl, authMode: "oauth" }), model, "openai-chat");
}
});
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 });
Expand Down Expand Up @@ -103,13 +113,13 @@ describe("OpenCode Zen Gemini destination and wire policy", () => {
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(prefixes).toEqual({ "gemini-": "google", "claude-": "anthropic" });
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"]) {
for (const model of ["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");
Expand Down
Loading