Skip to content
Merged
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
21 changes: 20 additions & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ provided, and the bridge infers the hosted backend family from the live row or t
model discovery is not an allowlist. The Zed access token has no refresh endpoint, so when Zed
revokes it, run `ocx login zed` again.

For Zed-hosted OpenAI models, OpenCodex translates `developer` instructions to `system` messages and removes OpenAI-private fields and unsupported built-in tools such as `web_search`. Function and custom tools remain available through the delegate; a tool choice with no supported tools left becomes `none`.

For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key),
opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request
(the Responses wire accepts the same field, but opencodex does not send it there today);
Expand Down Expand Up @@ -776,7 +778,21 @@ MiniMax model list is still the previous default receives it on the next start;
is left as it is; register the preview by hand with
`ocx models add minimax MiniMax-M3.1-Flash-Preview --context-window 1000000`.

**OpenCode Go** requires a stable session identifier for routing. OpenCodex sends
**OpenCode Go** routes `gemini-4-argon-contributor` through its Gemini API, including
streaming and tool-result continuations. The existing `opencode-go` preset keeps its
historical URL pinning: saved custom URLs, including `https://opencode.ai/inference/go/v1`,
route through the canonical `https://opencode.ai/zen/go/v1` endpoint and inherit Go defaults.
Gemini then uses Go's native model endpoint with key authentication. A differently named
custom provider keeps its configured URL and does not receive this Gemini pin.
Go selects protocols by exact model ID: GPT 6 Luna, GPT 5.6 Luna, Grok 4.6/4.7 and
Muse Spark Contributor use Responses; Claude Haiku 5.5, MiniMax M2.7/M3 and
Qwen3.7 Plus/3.8 Max/3.8 Flash use Anthropic Messages. Legacy MiniMax M2.5 and
Union Alpha also retain Messages. Models without a listed wire retain Chat.
New native pins apply to the pinned Go destination with key authentication.
Responses models still accept an explicit `modelAdapters` override.
Gemini uses `low`, `medium` and `high` thinking levels; higher requested efforts clamp to `high`.

OpenCode Go requires a stable session identifier for routing. OpenCodex sends
`claude-haiku-5-5`, like the MiniMax models, over the Anthropic Messages wire, matching
[OpenCode Go's endpoint table](https://opencode.ai/docs/go/#endpoints). OpenCodex derives
its Go session header from Codex thread/session headers, or from a client's
Expand Down Expand Up @@ -823,6 +839,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 Expand Up @@ -1191,6 +1209,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa
([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment.
- **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL.
- **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed.
- **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix.
- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server.
- **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied.
- **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,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
8 changes: 6 additions & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@
"catalog-modelalias-unique-sync.test.ts": "codex-integration",
"catalog-oauth-observation.test.ts": "codex-integration",
"catalog-opencode-go-context-window.test.ts": "codex-integration",
"catalog-opencode-go-static.test.ts": "codex-integration",
"catalog-remote-pull.test.ts": "codex-integration",
"catalog-retain-models.test.ts": "codex-integration",
"catalog-routed-comp-hash.test.ts": "codex-integration",
Expand Down Expand Up @@ -735,7 +736,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 @@ -1310,14 +1311,17 @@
"opencode-cli.test.ts": "providers",
"opencode-free-provider.test.ts": "providers",
"opencode-go-deepseek.test.ts": "providers",
"opencode-go-gemini-wire.test.ts": "providers",
"opencode-go-model-protocols.test.ts": "providers",
"registry-transport-alias.test.ts": "providers",
"opencode-go-grok46-responses.test.ts": "providers",
"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-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
25 changes: 20 additions & 5 deletions src/adapters/google.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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";
import { openCodeGoGeminiEndpoint } from "../types/opencode-go";
import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts";
import type {
AdapterEvent,
Expand Down Expand Up @@ -872,6 +874,7 @@ function invalidGoogleShapeEvent(
};
}

/** Build Gemini transport and response parsing for AI Studio, Vertex, Cloud Code Assist or Go. */
export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
const toolSchemaPolicy = provider.googleToolSchemaPolicy ?? "compatible";
const toolSchemaProfile = {
Expand Down Expand Up @@ -953,6 +956,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}
: {}),

/** Compile an admitted request into the selected Gemini endpoint, body and authentication. */
async buildRequest(parsed: OcxParsedRequest) {
// Structured-output admission runs FIRST, before messagesToGeminiFormat writes
// lastInjectedCallIds/lastReasoningReplayScope: a refused request must not leave
Expand Down Expand Up @@ -991,7 +995,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 @@ -1214,11 +1218,22 @@ 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}`;
// Go and Zen use separate guarded Gemini endpoints; AI Studio retains /v1beta.
const goEndpoint = openCodeGoGeminiEndpoint(provider, routedModelId);
const zenEndpoint = openCodeZenGeminiEndpoint(provider, routedModelId);
const endpoint = goEndpoint ?? 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");
headers["x-goog-api-key"] = apiKey;
if (!apiKey) throw new Error(`${goEndpoint ? "OpenCode Go" : zenEndpoint ? "OpenCode Zen" : "google (AI Studio)"} requires a non-empty API key`);
if (goEndpoint) {
// Console inference documents Bearer auth. Send only the selected Go key.
for (const name of Object.keys(headers)) {
if (["authorization", "x-goog-api-key", "x-api-key"].includes(name.toLowerCase())) delete headers[name];
}
headers["Authorization"] = `Bearer ${apiKey}`;
} else {
headers["x-goog-api-key"] = apiKey;
}

const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
reportToolSchemaLoss(compiled.toolSchemaLossReport);
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ const QODER_MCP_SERVER_NAME = "opencodex";
const QODER_MCP_SERVER_PATH = fileURLToPath(new URL("../coding-agent/mcp-server.ts", import.meta.url));

export function buildQoderChildEnv(profile: QoderProfile, apiKey: string, systemPrompt?: string): Record<string, string> {
const promptEnv = profile.region === "cn" ? "QODERCN_APPEND_SYSTEM_PROMPT" : "QODER_APPEND_SYSTEM_PROMPT";
return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey,
// Each build reads its own brand prefix with no fallback: qodercn ignores QODER_EXPOSE_TOKEN_USAGE.
const envPrefix = profile.region === "cn" ? "QODERCN_" : "QODER_";
const promptEnv = `${envPrefix}APPEND_SYSTEM_PROMPT`;
return { ...baseScopedEnv(), NO_COLOR: "1", [`${envPrefix}EXPOSE_TOKEN_USAGE`]: "true", [profile.tokenEnv]: apiKey,
...(systemPrompt ? { [promptEnv]: systemPrompt } : {}) };
}

Expand Down
27 changes: 26 additions & 1 deletion src/adapters/zed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ function delegateProvider(provider: OcxProviderConfig, zedProvider: ZedProvider)
return { ...common, adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", googleMode: "ai-studio" };
}
if (zedProvider === "open_ai") {
return { ...common, adapter: "openai-responses", baseUrl: "https://api.openai.com/v1" };
// The builder targets Zed's /completions envelope. Keep its destination set to Zed as well,
// so OpenAI-private Responses fields are stripped instead of being sent through to Zed.
return { ...common, adapter: "openai-responses", baseUrl: provider.baseUrl };
}
return { ...common, adapter: "openai-chat", baseUrl: "https://api.x.ai/v1" };
}
Expand Down Expand Up @@ -300,6 +302,29 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter {
}
}
if (!isRecord(providerRequest)) throw new Error("Zed delegate produced a non-object request body");
if (zedProvider === "open_ai" && Array.isArray(providerRequest.input)) {
// Zed's OpenAI Responses parser accepts user/assistant/system/tool roles but rejects
// OpenAI's newer developer role. System is the closest supported role for instructions.
providerRequest.input = providerRequest.input.map(item =>
isRecord(item) && item.type === "message" && item.role === "developer"
? { ...item, role: "system" }
: item,
);
}
if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) {
// Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search.
const supported = (tool: unknown) => isRecord(tool) && (tool.type === "function" || tool.type === "custom");
const tools = providerRequest.tools.filter(supported);
providerRequest.tools = tools;
const choice = providerRequest.tool_choice;
if (isRecord(choice) && choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
const allowed = choice.tools.filter(supported);
providerRequest.tool_choice = allowed.length ? { ...choice, tools: allowed } : "none";
} else if ((isRecord(choice) && !supported(choice)) || (choice === "required" && tools.length === 0)) {
// Match the shared hosted-tool policy when no supported selection remains.
providerRequest.tool_choice = "none";
}
}
delegate = { provider: zedProvider, adapter: selected };
const requestUrl = `${provider.baseUrl.replace(/\/+$/, "")}/completions`;
return {
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 @@ -20,6 +20,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 @@ -754,6 +755,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
Loading
Loading