From 84555f1cbf9074d087476eee586d95ad05c1c172 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Fri, 16 Oct 2026 09:57:46 +0900 Subject: [PATCH 1/7] fix(zed): map developer role to system and drop unsupported built-in tools for the OpenAI delegate (carry #7210) Carried from #7210. Co-authored-by: Andrew Wang --- src/adapters/zed.ts | 19 ++++++++++++- structure/providers-and-adapters.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 31 +++++++++++++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 61f748d0449..5769417e809 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -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" }; } @@ -300,6 +302,21 @@ 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. + providerRequest.tools = providerRequest.tools.filter(tool => + isRecord(tool) && (tool.type === "function" || tool.type === "custom"), + ); + } delegate = { provider: zedProvider, adapter: selected }; const requestUrl = `${provider.baseUrl.replace(/\/+$/, "")}/completions`; return { diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a8c63a4cdc2..00ade56965d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -209,7 +209,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index f7178289175..ce081c5cee8 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -269,7 +269,7 @@ describe("Zed Hosted AI provider", () => { content: [{ type: "text", text: "hello zed" }], }); }); - test("normalizes OpenAI string input to sequence blocks with input_text for Zed backend", async () => { + test("normalizes OpenAI input and removes Codex-only fields for Zed backend", async () => { const responses = [ jsonResponse({ default_organization_id: "org-1" }), jsonResponse({ token: "llm-token-1" }), @@ -287,18 +287,43 @@ describe("Zed Hosted AI provider", () => { authMode: "oauth", apiKey: "access-token", }; - const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello zed openai", stream: true }); + const parsed = parseRequest({ + model: "gpt-6.1-sol", + input: [ + { type: "message", role: "developer", content: [{ type: "input_text", text: "Follow the user request." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "hello zed openai" }] }, + ], + stream: true, + access_programs: { cyber: ["standard"] }, + tools: [ + { type: "web_search", search_context_size: "medium" }, + { type: "function", name: "synthetic_tool", parameters: { type: "object", properties: {} } }, + ], + }); parsed._zedAuthContext = { userId: "user-1" }; const adapter = withTestTranslatorBudget(createZedAdapter(provider)); const request = await adapter.buildRequest(parsed); - const body = JSON.parse(request.body) as { provider: string; provider_request: { input: Array<{ type: string; role: string; content: unknown }> } }; + const body = JSON.parse(request.body) as { + provider: string; + provider_request: { + input: Array<{ type: string; role: string; content: unknown }>; + tools: Array<{ type: string }>; + }; + }; expect(body.provider).toBe("open_ai"); expect(body.provider_request.input[0]).toEqual({ + type: "message", + role: "system", + content: [{ type: "input_text", text: "Follow the user request." }], + }); + expect(body.provider_request.input[1]).toEqual({ type: "message", role: "user", content: [{ type: "input_text", text: "hello zed openai" }], }); + expect(body.provider_request).not.toHaveProperty("access_programs"); + expect(body.provider_request.tools).toEqual([{ type: "function", name: "synthetic_tool", parameters: { type: "object", properties: {} } }]); }); }); From 4f8ff9c0e82081423e2d1c2e804b6504f5715a62 Mon Sep 17 00:00:00 2001 From: cubebox <58514883+juzijia@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:57:54 +0900 Subject: [PATCH 2/7] fix(qoder): expose token usage from Qoder CLI (carry #7066) Carried from #7066. Co-authored-by: cubebox <58514883+juzijia@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 1 + src/adapters/qoder/adapter.ts | 6 ++++-- structure/providers-and-adapters.md | 2 +- tests/providers/qoder-adapter.test.ts | 19 ++++++++++++++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index af1854528e0..3c77509ad1d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1191,6 +1191,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. diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index dc9a9f44475..e0254fbb7bd 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -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 { - 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 } : {}) }; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 00ade56965d..4e703eb2d0f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -78,7 +78,7 @@ name/namespace components and wire names over 512 UTF-8 bytes before staging. It 256 KiB per serialized definition, and 2 MiB for the serialized array (including brackets and separators); the comma-joined `--allowed-tools` argument is separately capped at 8 KiB of UTF-8, counting full `mcp____` names and commas. Invalid catalogs return a fixed local 400 without spawning Qoder. Valid wire identities, order and schemas are unchanged, apart from the Responses-only encrypted marker stripped during projection. Duplicate and ambiguous wire identities remain the Responses parser's responsibility. Qoder appends the -folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. +folded system prompt through its documented scoped `QODER_APPEND_SYSTEM_PROMPT` or `QODERCN_APPEND_SYSTEM_PROMPT` child environment, never through command-line arguments or inherited vendor variables. `src/adapters/qoder/adapter.ts` sets the region's usage switch (`QODER_EXPOSE_TOKEN_USAGE` or `QODERCN_EXPOSE_TOKEN_USAGE`; each CLI build reads only its own prefix) to `true` only in Qoder's child environment, so the shared usage parser receives the CLI's token counts instead of zeros. Coding-agent stdout is framed as bounded JSONL directly from decoded stream segments. The framer tracks the current line's UTF-8 byte count incrementally, searches each decoded segment once, and diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 7791824ef80..bba85c4ffff 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -458,8 +458,12 @@ describe("qoder adapter", () => { test("uses only the Global PAT and disables tools, MCP, settings hooks, and persistence", () => { const env = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "qoder-pat"); + expect(env.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); expect(env.QODER_PERSONAL_ACCESS_TOKEN).toBe("qoder-pat"); - expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual(["QODER_PERSONAL_ACCESS_TOKEN"]); + expect(Object.keys(env).filter(key => key.startsWith("QODER"))).toEqual([ + "QODER_EXPOSE_TOKEN_USAGE", + "QODER_PERSONAL_ACCESS_TOKEN", + ]); const args = buildQoderArgs(parsed({ options: { reasoning: "high" } }), provider()); expect(args[args.indexOf("--tools") + 1]).toBe(""); expect(args[args.indexOf("--setting-sources") + 1]).toBe(""); @@ -496,6 +500,7 @@ describe("qoder adapter", () => { expect(args.join(" ")).not.toContain(secretDeveloper); expect(args).not.toContain("--append-system-prompt-file"); expect(childEnv.QODER_APPEND_SYSTEM_PROMPT).toBe(`${secretSystem}\n\n${secretDeveloper}`); + expect(childEnv.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); }); test("never inherits an ambient Qoder prompt for either region", () => { @@ -525,8 +530,17 @@ describe("qoder adapter", () => { const globalEnv = buildQoderChildEnv(QODER_GLOBAL_PROFILE, "global-pat"); const cnEnv = buildQoderChildEnv(QODER_CN_PROFILE, "cn-pat"); + expect(globalEnv.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(globalEnv.QODERCN_EXPOSE_TOKEN_USAGE).toBeUndefined(); expect(globalEnv.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); expect(globalEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + // The CN build derives every variable from a QODERCN_ prefix and has no QODER_ fallback for this switch. + expect(cnEnv.QODERCN_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(cnEnv.QODER_EXPOSE_TOKEN_USAGE).toBeUndefined(); + expect(Object.keys(cnEnv).filter(key => key.startsWith("QODER"))).toEqual([ + "QODERCN_EXPOSE_TOKEN_USAGE", + "QODERCN_PERSONAL_ACCESS_TOKEN", + ]); expect(cnEnv.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); expect(cnEnv.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); @@ -548,8 +562,11 @@ describe("qoder adapter", () => { expect(spawned).toHaveLength(2); const global = spawned.find(item => item.executable.endsWith("/qoder"))!; const cn = spawned.find(item => item.executable.endsWith("/qodercn"))!; + expect(global.env.QODER_EXPOSE_TOKEN_USAGE).toBe("true"); expect(global.env.QODER_PERSONAL_ACCESS_TOKEN).toBe("global-pat"); expect(global.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBeUndefined(); + expect(cn.env.QODERCN_EXPOSE_TOKEN_USAGE).toBe("true"); + expect(cn.env.QODER_EXPOSE_TOKEN_USAGE).toBeUndefined(); expect(cn.env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe("cn-pat"); expect(cn.env.QODER_PERSONAL_ACCESS_TOKEN).toBeUndefined(); }); From 3a82866fa055e406624309a48cc5a4c1bb43b0a1 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Fri, 16 Oct 2026 09:58:14 +0900 Subject: [PATCH 3/7] fix(providers): route OpenCode Go Gemini through its native wire (carry #7099) Carried from #7099. Co-authored-by: Georgiy Bukharov --- .../src/content/docs/guides/providers.md | 16 +- scripts/test-layout/layout.json | 4 + src/adapters/google.ts | 21 +- src/protocols/provider-summary.ts | 3 +- src/providers/opencode-go-transport.ts | 4 +- src/providers/registry/entries-core.ts | 15 +- src/providers/registry/types.ts | 2 +- src/providers/service-tier.ts | 3 +- src/types/opencode-go.ts | 61 ++++++ src/types/wire.ts | 53 ++--- structure/catalog.md | 2 +- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 4 +- structure/providers/google.md | 24 +++ structure/transports/inventory.md | 3 + structure/transports/responses-wire-shapes.md | 4 +- .../catalog-opencode-go-static.test.ts | 49 +++++ tests/fixtures/test-layout-expected.json | 4 + .../providers/opencode-go-gemini-wire.test.ts | 203 ++++++++++++++++++ .../opencode-go-model-protocols.test.ts | 87 ++++++++ .../registry-transport-alias.test.ts | 27 +++ 21 files changed, 531 insertions(+), 60 deletions(-) create mode 100644 src/types/opencode-go.ts create mode 100644 tests/codex-integration/catalog-opencode-go-static.test.ts create mode 100644 tests/providers/opencode-go-gemini-wire.test.ts create mode 100644 tests/providers/opencode-go-model-protocols.test.ts create mode 100644 tests/providers/registry-transport-alias.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 3c77509ad1d..63c86b2b24f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -776,7 +776,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 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9925e111815..94c2d63ebb1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", @@ -1310,6 +1311,9 @@ "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", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 4702086ea41..5f9d94532fb 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ 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, @@ -872,6 +873,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 = { @@ -953,6 +955,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 @@ -1214,11 +1217,21 @@ 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 speaks Gemini on its own endpoint; ordinary AI Studio keeps /v1beta and its key header. + const goEndpoint = openCodeGoGeminiEndpoint(provider, routedModelId); + const endpoint = goEndpoint ?? `${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" : "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); diff --git a/src/protocols/provider-summary.ts b/src/protocols/provider-summary.ts index c4fbc383729..9d6d7fb8fe5 100644 --- a/src/protocols/provider-summary.ts +++ b/src/protocols/provider-summary.ts @@ -55,12 +55,13 @@ function usableModelId(model: unknown): model is string { function candidateModelIds(name: string, provider: Readonly): string[] { const registry = PROVIDER_REGISTRY.find(entry => entry.id === name); const ids = new Set(); + /** Collect valid candidate IDs without exceeding the protocol-summary scan limit. */ const add = (model: unknown) => { if (ids.size < MODEL_SCAN_LIMIT && usableModelId(model)) ids.add(model); }; for (const model of Object.keys(provider.modelAdapters ?? {})) add(model); for (const model of Object.keys(registry?.modelWireDefaults ?? {})) add(model); - for (const model of Object.keys(captureWireAdapterHardPins(name))) add(model); + for (const model of Object.keys(captureWireAdapterHardPins(name, provider))) add(model); add(provider.defaultModel); for (const model of provider.models ?? []) add(model); return [...ids].sort((left, right) => left.localeCompare(right)); diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts index 9090ca604dd..31aaaf47ebd 100644 --- a/src/providers/opencode-go-transport.ts +++ b/src/providers/opencode-go-transport.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { OcxProviderConfig } from "../types"; import { registryEntryForProviderDestination } from "./registry"; +import { isOpenCodeGoGeminiDestination } from "../types/opencode-go"; export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session"; @@ -44,7 +45,8 @@ export function resolveOpenCodeGoTransport( Pick & Partial>, ): T { - if (registryEntryForProviderDestination(destinationProvider)?.id !== "opencode-go") return provider; + if (registryEntryForProviderDestination(destinationProvider)?.id !== "opencode-go" + && !(provider.adapter === "google" && isOpenCodeGoGeminiDestination(provider))) return provider; if (!sessionLane) return provider; if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider; diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 9236a7258e9..24c66507899 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1,3 +1,4 @@ +import { OPENCODE_GO_BASE_URL, OPENCODE_GO_RESPONSES_DEFAULTS } from "../../types/opencode-go"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "../kiro-models"; import { DSH_ACCOUNT_MODELS, @@ -854,7 +855,8 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ escapeBuiltinToolNames: true, }, { - id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: OPENCODE_GO_BASE_URL, + destinationAliases: [{ adapter: "openai-chat", baseUrl: "https://opencode.ai/inference/go/v1" }], authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", // Zen Go can close a Chat stream after a fully assembled function call without sending @@ -876,13 +878,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ - modelWireDefaults: { - "gpt-5.6-luna": "openai-responses", - "grok-4.6": "openai-responses", - "grok-4.7": "openai-responses", - "muse-spark-1.3-contributor": "openai-responses", - "muse-spark-1.2-contributor": "openai-responses", - }, + modelWireDefaults: OPENCODE_GO_RESPONSES_DEFAULTS, modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, // Zen Go discovers only the gateway id, so carry DeepSeek's official 1M V4.1 @@ -939,6 +935,9 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ }, modelReasoningEfforts: { "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, + // Gemini's native thinkingLevel ladder; higher Codex efforts clamp to high. + // https://ai.google.dev/gemini-api/docs/models/gemini-4-argon + "gemini-4-argon-contributor": ["low", "medium", "high"], "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.7": ["low", "medium", "high", "xhigh"], "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 56a8c82752f..f5939c98601 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -213,7 +213,7 @@ export interface ProviderRegistryEntry { */ chatCompletionsPath?: string; /** - * Endpoints this entry used to live at, kept so a saved custom provider that still points + * Canonical or historical endpoints, kept so a saved custom provider that still points * at one keeps receiving this row's metadata through `registryEntryForProviderDestination`. * Destination matching is by adapter plus normalized base URL, so moving a row's wire or * prefix would otherwise orphan every config a user wrote against the old address. diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index c435da8d6f6..4e3aa884add 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" @@ -119,7 +120,7 @@ function buildFastPolicyAuthority( : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), - hardPins: captureWireAdapterHardPins(providerName), + hardPins: captureWireAdapterHardPins(providerName, provider), hardPinPrefixes: captureWireAdapterHardPinPrefixes(providerName, provider), registryWireDefaults: cloneRegistryWireDefaults(registry?.modelWireDefaults), }); diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts new file mode 100644 index 00000000000..ecdea8c6013 --- /dev/null +++ b/src/types/opencode-go.ts @@ -0,0 +1,61 @@ +/** Exact-model protocols from https://opencode.ai/v2/docs/console/go/#endpoints (2026-10-15). + * Unknown models retain Chat. Responses entries are overridable registry defaults; + * the native wires are hard pins because modelAdapters cannot select those adapters. + */ +export const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"; + +export const OPENCODE_GO_MODEL_WIRES: Readonly> = Object.freeze({ + "gemini-4-argon-contributor": "google", + "claude-haiku-5-5": "anthropic", + "minimax-m2.7": "anthropic", + "minimax-m3": "anthropic", + "qwen3.7-plus": "anthropic", + "qwen3.8-max": "anthropic", + "qwen3.8-flash": "anthropic", + "gpt-6-luna": "openai-responses", + "gpt-5.6-luna": "openai-responses", + "grok-4.6": "openai-responses", + "grok-4.7": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + // Retain previously supported Go models absent from the current endpoint table (#4847). + "minimax-m2.5": "anthropic", + "union-alpha": "anthropic", +}); + +/** Compatibility: these five pins predate destination scoping. New native pins require Go. */ +export const OPENCODE_GO_LEGACY_NAME_PINS: ReadonlySet = new Set([ + "claude-haiku-5-5", "minimax-m2.5", "minimax-m2.7", "minimax-m3", "union-alpha", +]); + +export const OPENCODE_GO_RESPONSES_DEFAULTS = Object.freeze(Object.fromEntries( + Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([, wire]) => wire === "openai-responses"), +)); + +export interface OpenCodeGoDestination { + readonly baseUrl?: unknown; + readonly authMode?: unknown; + readonly googleMode?: unknown; +} + +/** A provider name alone must never authorize redirecting a credential to OpenCode. */ +export function isOpenCodeGoGeminiDestination(provider: OpenCodeGoDestination | undefined): boolean { + if (!provider || typeof provider.baseUrl !== "string") return false; + if (provider.authMode !== undefined && provider.authMode !== "key") return false; + if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return false; + try { + const url = new URL(provider.baseUrl.trim()); + return url.origin === "https://opencode.ai" + && !url.username && !url.password && !url.search && !url.hash + && ["/zen/go/v1", "/inference/go/v1"].includes(url.pathname.replace(/\/+$/, "")); + } catch { + return false; + } +} + +/** Return the fixed Gemini endpoint only for the exact model and an eligible Go destination. */ +export function openCodeGoGeminiEndpoint(provider: OpenCodeGoDestination, modelId: string): string | undefined { + return Object.hasOwn(OPENCODE_GO_MODEL_WIRES, modelId) && OPENCODE_GO_MODEL_WIRES[modelId] === "google" && isOpenCodeGoGeminiDestination(provider) + ? `https://opencode.ai/inference/go/v1/models/${encodeURIComponent(modelId)}` + : undefined; +} diff --git a/src/types/wire.ts b/src/types/wire.ts index fb4286bce85..691081fed42 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,3 +1,5 @@ +import { isOpenCodeGoGeminiDestination, OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go"; + /** * 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 @@ -40,35 +42,16 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ "openai-responses", ]); -/** - * Providers whose listed model ids must be driven over the Anthropic wire even when - * the provider's configured adapter says otherwise — the upstream only speaks - * Anthropic for these models. - */ -const ANTHROPIC_WIRE_MODELS: Record> = { - "opencode-go": new Set([ - "claude-haiku-5-5", - "minimax-m2.5", - "minimax-m2.7", - "minimax-m3", - // OpenCode's catalog identifies Union Alpha as @ai-sdk/anthropic while the - // provider defaults to OpenAI-compatible; direct Chat returns 500 and direct - // Messages reaches the session check (#4847). - "union-alpha", - ]), -}; - -function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { - return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) - ? ANTHROPIC_WIRE_MODELS[providerName] - : undefined; -} - -/** Detached provider-local hard-pin table for pure wire-policy resolution. */ -export function captureWireAdapterHardPins(providerName: string): Readonly> { - const models = anthropicWireModelsForProvider(providerName); - if (!models) return Object.freeze({}); - return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); +/** Native Go wire pins share the exact-model table with registry Responses defaults. */ +export function captureWireAdapterHardPins(providerName: string, provider?: WirePinProvider): Readonly> { + if (providerName !== "opencode-go") return Object.freeze({}); + // The existing Go preset pins saved URLs to this endpoint before dispatch. Pure policy + // captures must use that same destination even when handed an unnormalized config row. + const destination = provider && { ...provider, baseUrl: OPENCODE_GO_BASE_URL }; + return Object.freeze(Object.fromEntries(Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([modelId, wire]) => + (wire === "anthropic" && OPENCODE_GO_LEGACY_NAME_PINS.has(modelId)) + || (wire !== "openai-responses" && isOpenCodeGoGeminiDestination(destination)), + ))); } interface WirePinPrefixRule { @@ -98,9 +81,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 OpenCodeGoDestination {} function normalizedEndpoint(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -159,13 +140,11 @@ function prefixedWireAdapter(providerName: string, modelId: string, provider: Wi * adapter" would pass on the first pass and then let the override win on the second. */ export function isWirePinnedModel(providerName: string, modelId: string, provider?: WirePinProvider): boolean { - return (anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false) - || prefixedWireAdapter(providerName, modelId, provider) !== undefined; + return pinnedWireAdapter(providerName, modelId, provider) !== undefined; } /** The wire a pinned model must use, or undefined when the model is not pinned. */ export function pinnedWireAdapter(providerName: string, modelId: string, provider?: WirePinProvider): string | undefined { - return anthropicWireModelsForProvider(providerName)?.has(modelId) - ? "anthropic" - : prefixedWireAdapter(providerName, modelId, provider); + const pins = captureWireAdapterHardPins(providerName, provider); + return Object.hasOwn(pins, modelId) ? pins[modelId] : prefixedWireAdapter(providerName, modelId, provider); } diff --git a/structure/catalog.md b/structure/catalog.md index 73e477eb99d..0a0dd072092 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -226,7 +226,7 @@ A Devin live row spreads its measured `inputModalities` before value survives only when none of them applies. Devin live rows collapse by catalog family (so `swe-1-6-fast` stays its own row), read their ladder from the family effort axis (including `minimal`), and carry the effective enabled default member's effort as `defaultReasoningEffort` when the family marks a default; `swe-1-6` is marked text-only because it drops images without an error. For `liveModels: false`, a static provider publishes the ordered union of `models` and `retainModels`. When `models` is absent or empty, its configured `defaultModel` seeds that -union before retained ids; a nonempty explicit list does not import a different default. +union before retained ids; a nonempty explicit list does not import a different default. The existing `opencode-go` preset retains name-based registry ownership: saved custom URLs are pinned to its canonical endpoint at routing, and omitted/empty models inherit its default unless explicitly overridden (`tests/codex-integration/catalog-opencode-go-static.test.ts`). Kiro keeps this static union as its floor and merges cached account model IDs and input limits; gathering neither refreshes tokens nor calls management, and runtime `/models` discovery remains disabled. Without any default or configured/retained ids, the static result stays empty. The existing forward-auth native path remains separate. Static gathering does not refresh OAuth or call diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 1e2dd83a989..80d0e00c138 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -170,7 +170,7 @@ provenance collapses onto four public sources in `protocolAdapterSource`: `hard- Model overrides are resolved for a Responses client. The candidates are the explicit `modelAdapters` keys, the registry's `modelWireDefaults` keys, the exact-id hard pins -(`captureWireAdapterHardPins`), the default model and the listed models, at most 1024 of them; a +(`captureWireAdapterHardPins`, using the historically pinned canonical destination for the Go preset), the default model and the listed models, at most 1024 of them; a model is listed when its source is not `provider-default` or its adapter differs from the provider's. The list is sorted, capped at `PROTOCOL_PROVIDER_OVERRIDE_LIMIT` (64) and marked `modelOverridesTruncated` past it. Prefix pins cannot be enumerated, so only a listed model they diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 4e703eb2d0f..47e825af01d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters GCP JSON storage in `src/providers/api-keys.ts` and durable-row snapshot/post-commit cleanup in `src/providers/gcp-credential-cleanup.ts` follow [Vertex credential markers](providers/google.md#vertex-credential-markers); malformed blank marker accounts do not block cleanup of valid accounts. -Combo target `metered` metadata and configured-key opt-in follow the [runtime consent contract](runtime.md#request-local-target-compatibility). Subscription-to-Cursor-to-key regression coverage in `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` checks unchanged healthy subscription wire bodies, effort, key authentication and cache controls, and structured runTurn 429 propagation. Configured spend ceilings bind adapter inference executors to the [seed and physical-start contract](transports/responses-spend.md#historical-pool-continuity-and-rollback). Zed completion and auth-recovery replays claim separately; token acquisition stays outside inference accounting. Claude CLI, CodeBuddy and Qoder each count one invocation toward the frozen request limit, seed before spawn and retain a producer through completion. Child-internal retries/tool turns are not separately counted; reported actual usage settles in full, including amounts above the seed estimate. +Combo target `metered` metadata and configured-key opt-in follow the [runtime consent contract](runtime.md#request-local-target-compatibility). Subscription-to-Cursor-to-key regression coverage in `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` checks unchanged healthy subscription wire bodies, effort, key authentication and cache controls, and structured runTurn 429 propagation. OpenCode Go's exact Gemini pin and credential destination guard follow the [Go Gemini transport contract](providers/google.md#opencode-go-gemini-transport). Configured spend ceilings bind adapter inference executors to the [seed and physical-start contract](transports/responses-spend.md#historical-pool-continuity-and-rollback). Zed completion and auth-recovery replays claim separately; token acquisition stays outside inference accounting. Claude CLI, CodeBuddy and Qoder each count one invocation toward the frozen request limit, seed before spawn and retain a producer through completion. Child-internal retries/tool turns are not separately counted; reported actual usage settles in full, including amounts above the seed estimate. Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request. Claude CLI replay, client tool capture and usage admission follow [Claude CLI replay](providers/claude-cli-replay.md). Anthropic account pause, model routes, quota labels, and the separate `anthropic2` pool (its helpers, quota, reset grants and management surfaces) follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. The DeepSeek Harness account provider follows the [DSH account contract](providers/dsh-account.md). @@ -564,7 +564,7 @@ boundary. `src/types/tools.ts` owns tool-name identity: namespaced and dotted na 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. 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). +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). `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 diff --git a/structure/providers/google.md b/structure/providers/google.md index b287f7255e9..074909d87dd 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -29,6 +29,30 @@ spellings. `src/providers/key-store.ts` leaves GCP markers stored during ordinar store/restore and refuses to restore GCP JSON through a keychain alias; the shared classification in `src/providers/api-key-resolve.ts` reports those markers as OS-stored credentials. +## OpenCode Go Gemini transport + +`src/types/opencode-go.ts` declares the shared exact-model Go protocol table and scopes its Google pins to fixed-key +HTTPS OpenCode Go destinations (`/zen/go/v1` or `/inference/go/v1`), excluding userinfo, +query, fragment, nonstandard ports and Google OAuth/Vertex modes. `src/types/wire.ts` +selects `google` for the table's Gemini model on the `opencode-go` row; runtime, static policy, Fast +policy and protocol summaries share the pin. User-configurable adapter overrides stay +limited to the two OpenAI wires. The existing `opencode-go` preset keeps historical name-based +ownership and pins saved custom or inference URLs to `/zen/go/v1` before dispatch. Pure policy +captures apply the same canonical destination. A differently named custom Chat provider keeps +its configured URL and does not acquire the Google pin. The registry recognizes `/inference/go/v1` +as a destination alias for metadata and affinity on differently named rows. + +`src/adapters/google.ts` sends the compiled Gemini body to +`https://opencode.ai/inference/go/v1/models/gemini-4-argon-contributor`, with +`:generateContent` or `:streamGenerateContent?alt=sse`, using the selected key as Bearer +authorization. The existing Go session helper adds affinity for the settled Google wire. +An empty key fails locally with an OpenCode Go diagnostic; AI Studio keeps its own diagnostic. +The preset declares Gemini's `low`/`medium`/`high` thinking-level ladder; higher requested +Codex efforts clamp to `high` through the existing effort resolver. +AI Studio, Vertex and Antigravity retain their own transport contracts. Mocked dispatch +and thought-signature continuation coverage lives in `tests/providers/opencode-go-gemini-wire.test.ts`; +these fixtures do not attest live upstream availability. + ## Google thought-text visibility boundary Google-family parts with `thought: true` stay separate from assistant output. After a CCA diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index d892a8ddd3e..620386c14b9 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,5 +1,8 @@ # Transport Inventory +OpenCode Go's Gemini model uses the [Go Gemini transport contract](../providers/google.md#opencode-go-gemini-transport) +through the Google adapter and the default server fetch path. + Meta Muse's registry-owned compatibility User-Agent takes precedence over an incoming client User-Agent; explicit provider headers still win. See the [Muse provider contract](../providers-and-adapters.md). Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 8709593b69a..97f29f08eea 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -203,7 +203,7 @@ and Muse against both response formats. The canonical OpenCode Go transport derives `x-opencode-session` from the existing hashed session lane and the final per-model wire protocol. One conversation keeps one opaque affinity value within -each protocol across ingress surfaces, retries, and key rotation, while Anthropic, Responses, and +each protocol across ingress surfaces, retries, and key rotation, while Gemini, Anthropic, Responses, and Chat turns use separate namespaces and sibling subagents remain distinct. Destination recognition uses the original routed provider while the generated hash uses the settled adapter, so selecting an Anthropic hard pin cannot make the canonical Go destination disappear from transport recognition. @@ -211,7 +211,7 @@ An operator-supplied header wins case-insensitively. Renamed providers are cover fixed key-auth destination still matches the registry; custom and lookalike URLs receive nothing. OpenCode Go's exact `claude-haiku-5-5`, `minimax-m2.5`, `minimax-m2.7`, `minimax-m3`, and `union-alpha` model ids are hard-pinned to Anthropic from every inbound; Haiku uses Messages in Go's [endpoint table](https://opencode.ai/docs/go/#endpoints). -Sibling models retain their Chat or Responses selection. This wire choice and the session namespace +The exact `gemini-4-argon-contributor` follows the [Go Gemini transport contract](../providers/google.md#opencode-go-gemini-transport). Siblings retain Chat or Responses. This wire choice and the session namespace do not assert upstream availability after the Messages endpoint accepts the session header. `src/adapters/openai-responses/web-search.ts` also drops the provider-rejected `search_content_types` and `indexed_web_access` fields from plain `web_search` tools while diff --git a/tests/codex-integration/catalog-opencode-go-static.test.ts b/tests/codex-integration/catalog-opencode-go-static.test.ts new file mode 100644 index 00000000000..54d433cbd13 --- /dev/null +++ b/tests/codex-integration/catalog-opencode-go-static.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels } from "../../src/codex/catalog"; +import { enrichProviderFromRegistry } from "../../src/providers/derive"; +import { providerMatchesRegistryTransportWithStaticGuards } from "../../src/providers/static-model-discovery"; +import { routedProviderConfig } from "../../src/router"; +import type { OcxProviderConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +describe("static opencode-go catalog destination ownership (#7097)", () => { + for (const models of [undefined, [] as string[]]) { + const label = models === undefined ? "omitted" : "empty"; + for (const destination of [ + { baseUrl: "https://opencode.ai/zen/go/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://opencode.ai/inference/go/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://custom.invalid/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://custom.invalid/v1", defaultModel: "custom-model", expected: ["custom-model"] }, + ]) { + test(`${label} models at ${destination.baseUrl} with ${destination.defaultModel ?? "no explicit default"}`, async () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: destination.baseUrl, apiKey: "sk-test", liveModels: false, + ...(models === undefined ? {} : { models }), + ...(destination.defaultModel === undefined ? {} : { defaultModel: destination.defaultModel }), + }; + expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(true); + const enriched = structuredClone(provider); + enrichProviderFromRegistry("opencode-go", enriched); + expect(enriched.baseUrl).toBe(destination.baseUrl); + expect(enriched.defaultModel).toBe(destination.expected[0]); + expect(enriched.models).toEqual(models); + const routed = routedProviderConfig("opencode-go", enriched); + expect(routed.baseUrl).toBe("https://opencode.ai/zen/go/v1"); + expect(routed.defaultModel).toBe(destination.expected[0]); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("static Go catalog gathering must not fetch models"); + }) as typeof fetch; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const gathered = await gatherRoutedModels({ providers: { "opencode-go": provider } }, { providerModelOutcomes: outcomes }); + expect(gathered.map(model => `${model.provider}/${model.id}`)) + .toEqual(destination.expected.map(id => `opencode-go/${id}`)); + expect(outcomes).toEqual([{ provider: "opencode-go", state: "authoritative" }]); + expect(fetchCalls).toBe(0); + }); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f5047535934..c151f45c5ab 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -262,6 +262,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-remote-pull.test.ts": "codex-integration", + "catalog-opencode-go-static.test.ts": "codex-integration", "catalog-retain-models.test.ts": "codex-integration", "catalog-routed-comp-hash.test.ts": "codex-integration", "catalog-seed-window-fill.test.ts": "codex-integration", "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", @@ -909,6 +910,9 @@ "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.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-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", diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts new file mode 100644 index 00000000000..12652a3d6fd --- /dev/null +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { fastPolicyForModel } from "../../src/providers/service-tier"; +import { isOpenCodeGoGeminiDestination } from "../../src/types/opencode-go"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import { buildProtocolProviderSummary } from "../../src/protocols/provider-summary"; +import { parseRequest } from "../../src/responses/parser"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxConfig, type OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const MODEL = "gemini-4-argon-contributor"; +const ENDPOINT = `https://opencode.ai/inference/go/v1/models/${MODEL}`; +const BASES = ["https://opencode.ai/zen/go/v1", "https://opencode.ai/inference/go/v1", "https://custom.invalid/v1"]; +const SIGNATURE = "synthetic-gemini-thought-signature-for-tool-continuation"; + +/** Build a synthetic URL with embedded credentials to exercise the destination rejection. */ +function endpointWithUserInfo(): string { + const url = new URL(BASES[0]!); + url.username = "test"; + url.password = "test"; + return url.href; +} + +/** Seed the canonical Go fixture, with overrides for destination and authentication cases. */ +function go(overrides: Partial = {}): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "test-key", ...overrides }; +} + +describe("OpenCode Go Gemini wire (#7097)", () => { + test("runtime, static policy, Fast and protocol summary agree on the exact pin", () => { + for (const baseUrl of BASES) { + const configured = go({ baseUrl, modelAdapters: { [MODEL]: "openai-responses" } }); + expect(captureRouteStaticPolicy("opencode-go", MODEL, configured).model.adapter).toBe("google"); + expect(fastPolicyForModel(configured, MODEL, "opencode-go").adapter).toBe("google"); + const provider = routedProviderConfig("opencode-go", configured); + expect(provider.baseUrl).toBe(BASES[0]!); + for (const inbound of ["responses", "chat", "anthropic"] as const) { + const policy = captureRouteStaticPolicy("opencode-go", MODEL, provider, undefined, inbound); + expect(policy.model.adapter).toBe("google"); + expect(policy.provenance.model.adapter).toBe("hard-pin"); + const resolved = resolveWireProtocolOverride("opencode-go", MODEL, provider, inbound); + expect(resolved.adapter).toBe("google"); + expect(resolveWireProtocolOverride("opencode-go", MODEL, resolved, inbound).adapter).toBe("google"); + expect(fastPolicyForModel(provider, MODEL, "opencode-go", inbound).adapter).toBe("google"); + } + const summary = buildProtocolProviderSummary({ providers: { "opencode-go": go({ baseUrl, models: [] }) } } as OcxConfig, "opencode-go"); + expect(summary?.modelOverrides).toContainEqual({ model: MODEL, adapter: "google", source: "hard-pin" }); + } + expect(MODEL_ADAPTER_OVERRIDE_ALLOWED.has("google")).toBe(false); + }); + + test("sibling models and another provider name keep their existing wires", () => { + for (const [model, adapter] of [ + ["glm-5.2", "openai-chat"], ["gpt-5.6-luna", "openai-responses"], + ["muse-spark-1.3-contributor", "openai-responses"], ["minimax-m3", "anthropic"], + ["claude-haiku-5-5", "anthropic"], ["gemini-future", "openai-chat"], + ]) expect(resolveWireProtocolOverride("opencode-go", model!, go()).adapter).toBe(adapter!); + expect(resolveWireProtocolOverride("custom-go", MODEL, go()).adapter).toBe("openai-chat"); + }); + + test.each([ + { baseUrl: "https://custom.invalid/v1" }, + { baseUrl: "https://opencode.ai.evil.invalid/zen/go/v1" }, + { baseUrl: "https://opencode.ai/zen/v1" }, + { baseUrl: "https://opencode.ai/zen/go/v1?key=fixture" }, + { baseUrl: "https://opencode.ai/zen/go/v1#fragment" }, + { baseUrl: endpointWithUserInfo() }, + { baseUrl: "http://opencode.ai/zen/go/v1" }, + { baseUrl: "https://opencode.ai:8443/zen/go/v1" }, + { authMode: "oauth" }, { authMode: "forward" }, + { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, + ] satisfies Partial[])("does not pin an unrelated destination or auth mode: %j", overrides => { + const provider = go(overrides); + expect(isOpenCodeGoGeminiDestination(provider)).toBe(false); + const name = overrides.baseUrl ? "custom-go" : "opencode-go"; + expect(resolveWireProtocolOverride(name, MODEL, provider).adapter).toBe("openai-chat"); + expect(captureRouteStaticPolicy(name, MODEL, provider).model.adapter).not.toBe("google"); + expect(fastPolicyForModel(provider, MODEL, name).adapter).not.toBe("google"); + if (overrides.baseUrl) expect(routedProviderConfig(name, provider).baseUrl).toBe(overrides.baseUrl); + }); + + test("AI Studio keeps its own endpoint and API-key header", async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter({ + adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "studio-key", + })); + const request = await adapter.buildRequest(parseRequest({ model: MODEL, input: "ping", stream: false })); + expect(request.url).toBe(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`); + expect(new Headers(request.headers).get("x-goog-api-key")).toBe("studio-key"); + expect(new Headers(request.headers).has("authorization")).toBe(false); + await expect(withTestTranslatorBudget(createGoogleAdapter({ + adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: " ", + })).buildRequest(parseRequest({ model: MODEL, input: "ping" }))) + .rejects.toThrow("google (AI Studio) requires a non-empty API key"); + }); + + test("Go normalizes endpoint spelling, owns its key header, and preserves an explicit session", async () => { + const provider = go({ baseUrl: " https://OPENCODE.AI:443/zen/go/v1/// ", headers: { + authorization: "Bearer stale-fixture", "X-Goog-Api-Key": "stale-fixture", "X-Opencode-Session": "operator-session", + } }); + const resolved = resolveWireProtocolOverride("opencode-go", MODEL, provider); + expect(resolved.adapter).toBe("google"); + const adapter = withTestTranslatorBudget(createGoogleAdapter(resolved)); + const request = await adapter.buildRequest(parseRequest({ model: MODEL, input: "ping", stream: false })); + expect(request.url).toBe(`${ENDPOINT}:generateContent`); + const headers = new Headers(request.headers); + expect(headers.get("authorization")).toBe("Bearer test-key"); + expect(headers.has("x-goog-api-key")).toBe(false); + expect(headers.get("x-opencode-session")).toBe("operator-session"); + await expect(withTestTranslatorBudget(createGoogleAdapter({ ...resolved, apiKey: " " })) + .buildRequest(parseRequest({ model: MODEL, input: "ping" }))).rejects.toThrow("OpenCode Go requires a non-empty API key"); + }); +}); + +describe("OpenCode Go Gemini Responses dispatch", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + afterEach(() => { globalThis.fetch = originalFetch; releaseSpendHome?.(); releaseSpendHome = undefined; }); + + for (const baseUrl of BASES) for (const stream of [false, true]) { + test(`Gemini request, completion and tool continuation (${baseUrl}, stream=${stream})`, async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; headers: Headers; body: any }> = []; + 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 + ? [{ functionCall: { id: "call_probe", name: "probe", args: { path: "src" } }, thoughtSignature: SIGNATURE }] + : [{ text: "Done" }]; + const body = { candidates: [{ content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 3, totalTokenCount: 13 } }; + return stream + ? new Response(`data: ${JSON.stringify(body)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(body); + }) as typeof fetch; + const config = { providers: { "opencode-go": go({ baseUrl }) } } as OcxConfig; + /** Dispatch one turn and return the completed Responses object from JSON or SSE. */ + const send = async (input: unknown[]) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "thread-id": "gemini-fixture-thread" }, + body: JSON.stringify({ model: `opencode-go/${MODEL}`, input, stream, store: false, + reasoning: { effort: stream ? "xhigh" : "high" }, + tools: [{ type: "function", name: "probe", parameters: { type: "object", properties: { path: { type: "string" } } } }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + if (!stream) return JSON.parse(text); + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + expect(events.some(event => event.type === "response.failed" || event.type === "error")).toBe(false); + return events.find(event => event.type === "response.completed")?.response; + }; + const input = [{ role: "user", content: "Inspect src using probe" }]; + const first = await send(input); + expect(first?.status).toBe("completed"); + const call = first.output.find((item: any) => item.type === "function_call"); + expect(call).toMatchObject({ name: "probe", arguments: '{"path":"src"}' }); + const second = await send([...input, ...first.output, { type: "function_call_output", call_id: call.call_id, output: "found files" }]); + expect(second?.status).toBe("completed"); + expect(JSON.stringify(second.output)).toContain("Done"); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe(`${ENDPOINT}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.headers.get("authorization")).toBe("Bearer test-key"); + expect(request.headers.has("x-goog-api-key")).toBe(false); + expect(request.headers.get("x-opencode-session")).toMatch(/^ocx_[a-f0-9]{32}$/); + expect(request.body.contents[0]).toMatchObject({ role: "user" }); + expect(request.body.messages).toBeUndefined(); + expect(request.body.reasoning_effort).toBeUndefined(); + expect(request.body.generationConfig.thinkingConfig.thinkingLevel).toBe("high"); + expect(request.body.tools[0].functionDeclarations[0].name).toBe("probe"); + } + expect(requests[1]!.headers.get("x-opencode-session")).toBe(requests[0]!.headers.get("x-opencode-session")); + const contents = requests[1]!.body.contents; + const callIndex = contents.findIndex((turn: any) => turn.parts.some((part: any) => part.functionCall)); + expect(contents[callIndex].parts[0]).toMatchObject({ functionCall: { name: "probe", args: { path: "src" } }, thoughtSignature: SIGNATURE }); + expect(contents[callIndex + 1].parts[0]).toMatchObject({ functionResponse: { name: "probe", response: { result: "found files" } } }); + }); + } + + test("a differently named custom provider sends its key only to its configured destination", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `custom-go/${MODEL}`, input: "ping", stream: false }), + }), { providers: { "custom-go": { adapter: "openai-chat", baseUrl: "https://custom.invalid/v1", apiKey: "custom-key" } } } as OcxConfig, + { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe("https://custom.invalid/v1/chat/completions"); + expect(requests[0]!.headers.get("authorization")).toBe("Bearer custom-key"); + expect(requests[0]!.headers.has("x-opencode-session")).toBe(false); + }); +}); diff --git a/tests/providers/opencode-go-model-protocols.test.ts b/tests/providers/opencode-go-model-protocols.test.ts new file mode 100644 index 00000000000..c1ff8db4897 --- /dev/null +++ b/tests/providers/opencode-go-model-protocols.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import { fastPolicyForModel } from "../../src/providers/service-tier"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const BASES = ["https://opencode.ai/zen/go/v1", "https://opencode.ai/inference/go/v1", "https://custom.invalid/v1"]; +const ROUTES = [ + ["gpt-6-luna", "openai-responses", "responses"], + ["gpt-5.6-luna", "openai-responses", "responses"], + ["claude-haiku-5-5", "anthropic", "messages"], + ["glm-5.2", "openai-chat", "chat/completions"], + ["qwen3.7-plus", "anthropic", "messages"], + ["qwen3.8-max", "anthropic", "messages"], + ["qwen3.8-flash", "anthropic", "messages"], +] as const; +function go(baseUrl = BASES[0]): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "test-key", baseUrl: baseUrl! }; +} + +describe("OpenCode Go documented per-model protocols (2026-10-15)", () => { + for (const baseUrl of BASES) for (const [model, adapter] of ROUTES) test(`${model} resolves consistently (${baseUrl})`, () => { + const provider = routedProviderConfig("opencode-go", go(baseUrl)); + expect(provider.baseUrl).toBe(BASES[0]!); + for (const inbound of ["responses", "chat", "anthropic"] as const) { + expect(resolveWireProtocolOverride("opencode-go", model, provider, inbound).adapter).toBe(adapter); + expect(captureRouteStaticPolicy("opencode-go", model, provider, undefined, inbound).model.adapter).toBe(adapter); + expect(fastPolicyForModel(provider, model, "opencode-go", inbound).adapter).toBe(adapter); + } + }); + + for (const model of ["qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash"]) { + test(`${model} does not pin differently named custom providers or non-key auth`, () => { + for (const overrides of [{ baseUrl: "https://custom.invalid/v1" }, { authMode: "oauth" }, { authMode: "forward" }] as const) { + const provider = { ...go(), ...overrides, modelAdapters: { [model]: "openai-responses" } }; + const name = "baseUrl" in overrides ? "custom-go" : "opencode-go"; + expect(resolveWireProtocolOverride(name, model, provider).adapter).toBe("openai-responses"); + expect(captureRouteStaticPolicy(name, model, provider).model.adapter).toBe("openai-responses"); + expect(fastPolicyForModel(provider, model, name).adapter).toBe("openai-responses"); + } + }); + } + + test("Luna retains an explicit Chat opt-out and unknown siblings remain Chat", () => { + const provider = { ...go(), modelAdapters: { "gpt-6-luna": "openai-chat" } }; + expect(resolveWireProtocolOverride("opencode-go", "gpt-6-luna", provider).adapter).toBe("openai-chat"); + for (const model of ["qwen-next", "gemini-next", "gpt-next", "glm-5.3-flash"]) + expect(resolveWireProtocolOverride("opencode-go", model, go()).adapter).toBe("openai-chat"); + }); +}); + +const originalFetch = globalThis.fetch; +let releaseSpendHome: (() => void) | undefined; +afterEach(() => { globalThis.fetch = originalFetch; releaseSpendHome?.(); releaseSpendHome = undefined; }); +for (const baseUrl of BASES) for (const [model, adapter, path] of ROUTES) test(`${model} dispatches ${path} (${baseUrl})`, async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; body: any; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body)), headers: new Headers(init?.headers) }); + return Response.json(adapter === "anthropic" + ? { id: "msg_fixture", type: "message", role: "assistant", model, content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", usage: { input_tokens: 2, output_tokens: 1 } } + : adapter === "openai-chat" + ? { choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] } + : { id: "resp_fixture", object: "response", status: "completed", model, + output: [{ type: "message", id: "msg_fixture", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "ok", annotations: [] }] }], usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 } }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "thread-id": "protocol-fixture" }, + body: JSON.stringify({ model: `opencode-go/${model}`, input: "ping", stream: false }), + }), { providers: { "opencode-go": go(baseUrl) } } as OcxConfig, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok"); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe(`${BASES[0]}/${path}`); + expect(requests[0]!.body.model).toBe(model); + if (adapter === "anthropic") expect(requests[0]!.headers.get("x-api-key")).toBe("test-key"); + else expect(requests[0]!.headers.get("authorization")).toBe("Bearer test-key"); + expect(requests[0]!.headers.get("x-opencode-session")).toMatch(/^ocx_[a-f0-9]{32}$/); + if (adapter !== "openai-responses") expect(requests[0]!.body.messages[0].role).toBe("user"); + else expect(requests[0]!.body.input).toBeDefined(); +}); diff --git a/tests/providers/registry-transport-alias.test.ts b/tests/providers/registry-transport-alias.test.ts new file mode 100644 index 00000000000..912206ccc4f --- /dev/null +++ b/tests/providers/registry-transport-alias.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import type { OcxProviderConfig } from "../../src/types"; + +test("a metadata alias on another adapter does not grant registry transport ownership", () => { + // Volcengine's canonical row moved to Responses; saved Chat rows remain Chat. + const provider: OcxProviderConfig = { + adapter: "openai-chat", authMode: "key", apiKey: "test-key", + baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", + }; + expect(registryEntryForProviderDestination(provider)?.id).toBe("volcengine-coding-plan"); + expect(providerMatchesRegistryTransport("volcengine-coding-plan", provider)).toBe(false); + expect(routedProviderConfig("volcengine-coding-plan", provider).adapter).toBe("openai-chat"); + expect(captureRouteStaticPolicy("volcengine-coding-plan", "doubao-seed-2.0-code", provider).provider.adapter).toBe("openai-chat"); +}); + +test("the Go inference alias identifies renamed destinations while the preset remains pinned", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", authMode: "key", apiKey: "test-key", + baseUrl: "https://opencode.ai/inference/go/v1", + }; + expect(registryEntryForProviderDestination(provider)?.id).toBe("opencode-go"); + expect(providerMatchesRegistryTransport("opencode-go", provider)).toBe(true); + expect(routedProviderConfig("opencode-go", provider).baseUrl).toBe("https://opencode.ai/zen/go/v1"); + expect(routedProviderConfig("renamed-go", provider).baseUrl).toBe(provider.baseUrl); +}); From a5d035f777c57d6158503e4ee5d72e2cbeedf921 Mon Sep 17 00:00:00 2001 From: norths7ar Date: Fri, 16 Oct 2026 09:58:52 +0900 Subject: [PATCH 4/7] fix(providers): support Gemini models on OpenCode Zen (carry #7155) Carried from #7155. Co-authored-by: norths7ar --- .../src/content/docs/guides/providers.md | 2 + .../docs/reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 4 +- src/adapters/google.ts | 10 +- src/config.ts | 2 + src/config/diagnostics.ts | 2 + src/config/opencode-zen-upgrade.ts | 52 ++++ src/types/opencode-zen.ts | 29 ++ src/types/wire.ts | 4 +- structure/config.md | 2 +- structure/providers-and-adapters.md | 7 +- .../config-opencode-zen-upgrade.test.ts | 286 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 4 +- .../opencode-zen-gemini-wire.test.ts | 237 +++++++++++++++ 14 files changed, 628 insertions(+), 15 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 63c86b2b24f..b9a1d3b87ac 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -837,6 +837,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 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 289a9e5ae1d..5d84ae29f8a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -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. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 94c2d63ebb1..df7c31ab3c9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -736,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", @@ -1321,7 +1321,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 5f9d94532fb..0bed1927387 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"; @@ -994,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" @@ -1217,12 +1218,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } - // Go speaks Gemini on its own endpoint; ordinary AI Studio keeps /v1beta and its key header. + // Go and Zen use separate guarded Gemini endpoints; AI Studio retains /v1beta. const goEndpoint = openCodeGoGeminiEndpoint(provider, routedModelId); - const endpoint = goEndpoint ?? `${provider.baseUrl}/v1beta/models/${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(`${goEndpoint ? "OpenCode Go" : "google (AI Studio)"} requires a non-empty API key`); + 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)) { 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/diagnostics.ts b/src/config/diagnostics.ts index aca8261db5e..f0a82f02c3c 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -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, @@ -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 diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts new file mode 100644 index 00000000000..974e69ebe23 --- /dev/null +++ b/src/config/opencode-zen-upgrade.ts @@ -0,0 +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; +} + +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."); + } +} 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 691081fed42..c00c010e854 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,4 +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"; /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the @@ -81,7 +82,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 extends OpenCodeGoDestination {} +export interface WirePinProvider extends OpenCodeGoDestination, OpenCodeZenDestination {} function normalizedEndpoint(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -105,6 +106,7 @@ const CREDENTIAL_PINNED_ADAPTERS: Readonly> | undefined { if (Object.hasOwn(CREDENTIAL_PINNED_ADAPTERS, providerName)) return CREDENTIAL_PINNED_ADAPTERS[providerName]; + 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 a2bc0723340..d838b521f73 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` 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/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 47e825af01d..27ab13b0b35 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -557,15 +557,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 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/: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..bda564bc285 --- /dev/null +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -0,0 +1,286 @@ +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"]; +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" }, + modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] }, + }, + }, + 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(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 }); + 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, modelPreferHostedTools: { " GeMiNi-3.7-flash ": ["image_generation"] }, + }); + 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, 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": [] } }, + ...[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 c151f45c5ab..95461ec7542 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -547,7 +547,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", @@ -916,7 +916,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 aca0bd0e63a6faf4c39339e4803fbd700f417c45 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 09:59:21 +0900 Subject: [PATCH 5/7] docs(zed): explain hosted OpenAI request compatibility Follow-up to carry #7210. Co-authored-by: Andrew Wang --- docs-site/src/content/docs/guides/providers.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b9a1d3b87ac..8785080ad9d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -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. + 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); From 7202ec9b18943004218f681691d6edc5ecce444c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 10:00:47 +0900 Subject: [PATCH 6/7] fix(zed): reconcile tool choices after filtering built-ins Follow-up to the source review on #7210; preserve custom tools and shared hosted-tool policy. Co-authored-by: Andrew Wang --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/zed.ts | 14 +++++++-- structure/providers-and-adapters.md | 2 +- tests/providers/zed-hosted-provider.test.ts | 31 +++++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8785080ad9d..97e33fa2861 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -302,7 +302,7 @@ 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. +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 diff --git a/src/adapters/zed.ts b/src/adapters/zed.ts index 5769417e809..41a2f4857ee 100644 --- a/src/adapters/zed.ts +++ b/src/adapters/zed.ts @@ -313,9 +313,17 @@ export function createZedAdapter(provider: OcxProviderConfig): ProviderAdapter { } if (zedProvider === "open_ai" && Array.isArray(providerRequest.tools)) { // Zed's Responses decoder supports function/custom tools, not Codex's built-in web_search. - providerRequest.tools = providerRequest.tools.filter(tool => - isRecord(tool) && (tool.type === "function" || tool.type === "custom"), - ); + 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`; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 27ab13b0b35..3748f10962d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -209,7 +209,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge; optional `minMaxOutputTokens` raises only smaller explicit positive integer output budgets. Unset preserves caller caps. Serialization and pre-admission spend estimates share `effectiveAzureOutputBudget`; see the [spend contract](transports/responses-spend.md#durable-spend-reservations). | | `src/adapters/cursor.ts`, `src/adapters/cursor/` | Cursor protobuf transport: discovery, request builder, event decoding, MCP, thread continuity, native-exec policy. | -| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | +| `src/adapters/zed.ts`, `src/providers/zed.ts`, `src/oauth/zed.ts` | Zed Hosted AI native-app RSA login, account-scoped short-lived LLM-token exchange, bounded live model discovery, and provider-envelope translation through the existing Anthropic, Google, Responses, and Chat builders. Buffered delivery retains events under the shared translator budget and cancels delegated streams on early exit. The Responses delegate applies destination compatibility, strips OpenAI-private fields and unsupported built-in tools, reconciles removed tool choices through the shared hosted-tool policy, and maps OpenAI `developer` messages to Zed-supported `system` messages before wrapping the request. Experimental and unofficial; the Zed terms risk is intentionally surfaced in user docs and release review. | | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. Assistant reasoning replays as ChatMessagePrompt #11 thinking, #12 signature and #18 signature type (`src/adapters/devin/reasoning-signature.ts`): the #10/#21 pair arrives after the visible answer and becomes its own signature-only reasoning item, so a single unsigned thinking block plus exactly one signature-only block is replayed as one signed prompt, and a signature-only turn (GPT, Gemini) is replayed rather than dropped. An Anthropic signature is replayed, but because the streamed thinking is a summary the signature may not cover, a turn Cognition refuses with `invalid_argument` before any visible output (reasoning alone does not count) is retried once with Anthropic signatures withheld and the thinking text kept. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog, JWT, and `src/web-search/devin-executor.ts` native search support RPCs remain outside inference-send accounting. Provider-stated pre-output 429 reset delays are surfaced immediately by default, releasing shared active-turn capacity. A positive `OPENCODEX_DEVIN_STATED_RESET_WAIT_MS` explicitly enables bounded waiting and up to two replays on standalone turns, which hold that capacity until completion or cancellation. Combo children bypass that wait and surface a pre-output 429 so the next target can run. During an opted-in standalone wait, safe heartbeats commit the response preflight and keep the stream's stall watchdog fed. Invalid values fail closed to the immediate-refusal behavior. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. Native search previews the current route by effective adapter without mutating combo selection state, pins one admitted active-account snapshot for the request, and calls `GetWebSearchResults`, so it starts no CLI or second model. The wire model UID comes from the catalog's family metadata (`ClientModelConfig` #23/#30/#31): a family id with no effort anchors on the family's default member and selects the nearest enabled rung, rounding up first; an effort moves only the effort axis, to the lowest rung at or above it (else the highest below) while Fast Mode, 1M Context and the other axes stay at the anchor's values unless the caller asked for `fast` or a `1m` value; not lowering a requested effort outranks keeping those axes (an unranked member counts as lower), a disabled row the caller named is kept when the request still selects it so the preflight names that refusal, and resolution never leaves the family. Rows without family metadata fall back to suffix resolution, whose variant scan matches the collapsed base rather than a string prefix. With no caller or configured output cap, the selected row's catalog `maxOutputTokens` fills CompletionConfiguration #2; #3 is `max_newlines` and is sent at a fixed value, never a context window. The leading system text is sent as `GetChatMessage` #2, a failed tool result sets ChatMessagePrompt #9 and keeps an in-band `ERROR:` marker, and Gemini uids have JSON-Schema type arrays expressed as type-only `anyOf` branches in tool parameters. Shared constraints remain on the surrounding node, so nested schemas have one wire representation while outer enum/const, boolean constraints (including `not`, `oneOf`, and `allOf`) on null, and existing null-branch restrictions retain their conjunctive meaning. A null-only type keeps its null constraint even when an outer enum/const excludes null, preserving an unsatisfiable schema without producing an empty anyOf. Schema resource identifiers, anchors, definition maps, and existing anyOf branches remain on their original node. The type constraint is appended to existing allOf entries without shifting their indices, so JSON Pointer targets and reference scopes remain unchanged. A pre-output `invalid_argument` on a history whose word-piece estimate, using the sanitized and truncated tool descriptions actually sent on the wire, reaches 95% of the selected UID's catalog input window, capped by configured provider and model limits (512 KiB of text only when no window is known) is surfaced as `context_length_exceeded` so Codex compacts; a small request with the same code stays a plain 400. Known limit: a malformed schema on a history already at or above that 95% threshold is also reported as overflow because the upstream returns the same `invalid_argument`. The `COGNITION_BLOCKLIST_REWRITES` sanitizer rewrites exact phrases Cognition refuses with `permission_denied`; it runs only on instruction surfaces — tool descriptions and the #2 system prompt — while message text, replayed thinking (#11), and tool-call arguments stay byte-exact, because a rewrite there would silently change literal content such as patches or quoted file bytes; a trigger inside a data field surfaces as the upstream `permission_denied` instead (the Codex `` escalation boilerplate is the recorded #2 case). | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling, including an egress-aware completion fallback, fixed public HTTP 5xx text, and closed-set status/code diagnostics. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index ce081c5cee8..7bc7e51a684 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -327,6 +327,37 @@ describe("Zed Hosted AI provider", () => { }); }); +describe("Zed OpenAI tool selection after filtering", () => { + const custom = { type: "custom", name: "synthetic_custom", format: { type: "text" } }; + const web = { type: "web_search" }; + for (const [name, tools, choice, expectedTools, expectedChoice] of [ + ["custom is preserved", [custom, web], "required", [custom], "required"], + ["all removed with auto", [web], "auto", [], "auto"], + ["all removed with required", [web], "required", [], "none"], + ["removed named built-in", [web], { type: "web_search" }, [], "none"], + ["mixed allowed tools", [custom, web], { type: "allowed_tools", mode: "auto", tools: [web, { type: "custom", name: "synthetic_custom" }] }, + [custom], { type: "allowed_tools", mode: "auto", tools: [{ type: "custom", name: "synthetic_custom" }] }], + ["all allowed tools removed", [web], { type: "allowed_tools", mode: "auto", tools: [web] }, [], "none"], + ] as const) test(name, async () => { + const catalog = [jsonResponse({ default_organization_id: "org-1" }), + jsonResponse({ token: "llm-token-1" }), jsonResponse({ models: [{ id: "gpt-6.1-sol", provider: "open_ai" }] })]; + globalThis.fetch = (async () => { + const next = catalog.shift(); + if (!next) throw new Error("unexpected Zed catalog fetch"); + return next; + }) as typeof fetch; + const parsed = parseRequest({ model: "gpt-6.1-sol", input: "hello", tools, tool_choice: choice }); + parsed._zedAuthContext = { userId: "user-1" }; + const adapter = withTestTranslatorBudget(createZedAdapter({ adapter: "zed", baseUrl: "https://cloud.zed.dev", + authMode: "oauth", apiKey: "access-token" })); + const request = await adapter.buildRequest(parsed); + const body = JSON.parse(request.body).provider_request; + expect(body.input[0]).toMatchObject({ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }); + expect(body.tools).toEqual(expectedTools); + expect(body.tool_choice).toEqual(expectedChoice); + }); +}); + describe("Zed inference send accounting", () => { async function fixture(limit: number, full = false) { const catalog = [jsonResponse({ default_organization_id: "org-1" }), From 576babd02fa4bbed19203b39a119c137ae4f20a7 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 10:02:51 +0900 Subject: [PATCH 7/7] test(zed): exercise the supported custom-tool passthrough Use apply_patch, the shared Responses custom-tool passthrough; arbitrary custom names are lowered before the Zed filter. Co-authored-by: Andrew Wang --- tests/providers/zed-hosted-provider.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/providers/zed-hosted-provider.test.ts b/tests/providers/zed-hosted-provider.test.ts index 7bc7e51a684..1acb4c8d276 100644 --- a/tests/providers/zed-hosted-provider.test.ts +++ b/tests/providers/zed-hosted-provider.test.ts @@ -328,15 +328,15 @@ describe("Zed Hosted AI provider", () => { }); describe("Zed OpenAI tool selection after filtering", () => { - const custom = { type: "custom", name: "synthetic_custom", format: { type: "text" } }; + const custom = { type: "custom", name: "apply_patch", format: { type: "text" } }; const web = { type: "web_search" }; for (const [name, tools, choice, expectedTools, expectedChoice] of [ ["custom is preserved", [custom, web], "required", [custom], "required"], ["all removed with auto", [web], "auto", [], "auto"], ["all removed with required", [web], "required", [], "none"], ["removed named built-in", [web], { type: "web_search" }, [], "none"], - ["mixed allowed tools", [custom, web], { type: "allowed_tools", mode: "auto", tools: [web, { type: "custom", name: "synthetic_custom" }] }, - [custom], { type: "allowed_tools", mode: "auto", tools: [{ type: "custom", name: "synthetic_custom" }] }], + ["mixed allowed tools", [custom, web], { type: "allowed_tools", mode: "auto", tools: [web, { type: "custom", name: "apply_patch" }] }, + [custom], { type: "allowed_tools", mode: "auto", tools: [{ type: "custom", name: "apply_patch" }] }], ["all allowed tools removed", [web], { type: "allowed_tools", mode: "auto", tools: [web] }, [], "none"], ] as const) test(name, async () => { const catalog = [jsonResponse({ default_organization_id: "org-1" }),