Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
`MissingSessionID` and the message "OpenCode's free tier can only be used in OpenCode". Presence
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. |
Expand Down
4 changes: 2 additions & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 7 additions & 4 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/config/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/config/opencode-zen-upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { openCodeZenBaseUrl } 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-");
}

/** 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) ?? {};
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)) {
// 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];
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.");
}
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.");
}
}
1 change: 1 addition & 0 deletions src/providers/service-tier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ServiceTierCapabilityProvider = Pick<
| "modelAdapters"
| "baseUrl"
| "authMode"
| "googleMode"
| "apiKeyTransport"
| "chatServiceTier"
| "fastWire"
Expand Down
29 changes: 29 additions & 0 deletions src/types/opencode-zen.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 4 additions & 3 deletions src/types/wire.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -98,9 +100,7 @@ const WIRE_ADAPTER_PIN_PREFIXES: Readonly<Record<string, WirePinPrefixRule>> = 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;
Expand All @@ -113,6 +113,7 @@ function normalizedEndpoint(value: unknown): string | undefined {
}

function prefixPinsFor(providerName: string, provider: WirePinProvider | undefined): Readonly<Record<string, string>> | 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;
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.
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,
Expand Down
7 changes: 3 additions & 4 deletions structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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.

`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.
Expand Down
Loading
Loading