From 245566c368756e0f149e891f2f6ad33be1e641e8 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 12:41:42 +0400 Subject: [PATCH 01/11] fix(providers): route OpenCode Go Gemini through its native wire --- scripts/test-layout/layout.json | 1 + src/adapters/google.ts | 17 +- src/protocols/provider-summary.ts | 2 +- src/providers/opencode-go-transport.ts | 4 +- src/providers/registry/entries-core.ts | 5 + src/providers/service-tier.ts | 3 +- src/types/opencode-go.ts | 29 +++ src/types/wire.ts | 19 +- tests/fixtures/test-layout-expected.json | 1 + .../providers/opencode-go-gemini-wire.test.ts | 189 ++++++++++++++++++ 10 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 src/types/opencode-go.ts create mode 100644 tests/providers/opencode-go-gemini-wire.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3696b56564c..0977f4e4ba9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1283,6 +1283,7 @@ "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-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 a60bf565d67..8956939d300 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, @@ -1202,11 +1203,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 (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..35ad4425a17 100644 --- a/src/protocols/provider-summary.ts +++ b/src/protocols/provider-summary.ts @@ -60,7 +60,7 @@ function candidateModelIds(name: string, provider: Readonly): }; 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 3057fe31b74..4b0342f2792 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -825,6 +825,8 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ }, { id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + // Preserve custom URLs before the endpoint-scoped Gemini pin is evaluated (#7097). + preserveCustomDestination: true, 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 @@ -909,6 +911,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/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..eece1d5c008 --- /dev/null +++ b/src/types/opencode-go.ts @@ -0,0 +1,29 @@ +/** Exact Go Gemini contract: https://opencode.ai/docs/go/#endpoints (#7097). */ +export const OPENCODE_GO_GEMINI_MODEL = "gemini-4-argon-contributor"; + +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; + } +} + +export function openCodeGoGeminiEndpoint(provider: OpenCodeGoDestination, modelId: string): string | undefined { + return modelId === OPENCODE_GO_GEMINI_MODEL && isOpenCodeGoGeminiDestination(provider) + ? `https://opencode.ai/inference/go/v1/models/${OPENCODE_GO_GEMINI_MODEL}` + : undefined; +} diff --git a/src/types/wire.ts b/src/types/wire.ts index 59955f5bed1..b4ad6b630f4 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,3 +1,5 @@ +import { isOpenCodeGoGeminiDestination, OPENCODE_GO_GEMINI_MODEL, 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 @@ -65,10 +67,13 @@ function anthropicWireModelsForProvider(providerName: string): ReadonlySet> { +export function captureWireAdapterHardPins(providerName: string, provider?: WirePinProvider): Readonly> { const models = anthropicWireModelsForProvider(providerName); - if (!models) return Object.freeze({}); - return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); + return Object.freeze({ + ...Object.fromEntries([...(models ?? [])].map(modelId => [modelId, "anthropic"])), + ...(providerName === "opencode-go" && isOpenCodeGoGeminiDestination(provider) + ? { [OPENCODE_GO_GEMINI_MODEL]: "google" } : {}), + }); } interface WirePinPrefixRule { @@ -98,9 +103,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; @@ -148,12 +151,12 @@ 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 { + if (providerName === "opencode-go" && modelId === OPENCODE_GO_GEMINI_MODEL && isOpenCodeGoGeminiDestination(provider)) return "google"; return anthropicWireModelsForProvider(providerName)?.has(modelId) ? "anthropic" : prefixedWireAdapter(providerName, modelId, provider); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d662008849b..e09d1db839b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -870,6 +870,7 @@ "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-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..d2a50d14948 --- /dev/null +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -0,0 +1,189 @@ +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 { 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"]; +const SIGNATURE = "synthetic-gemini-thought-signature-for-tool-continuation"; + +function endpointWithUserInfo(): string { + const url = new URL(BASES[0]!); + url.username = "test"; + url.password = "test"; + return url.href; +} + +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 provider = go({ baseUrl, modelAdapters: { [MODEL]: "openai-responses" } }); + 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(resolveWireProtocolOverride("opencode-go", MODEL, provider).adapter).toBe("openai-chat"); + expect(captureRouteStaticPolicy("opencode-go", MODEL, provider).model.adapter).not.toBe("google"); + expect(fastPolicyForModel(provider, MODEL, "opencode-go").adapter).not.toBe("google"); + if (overrides.baseUrl) expect(routedProviderConfig("opencode-go", 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); + }); + + 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("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; + 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 same-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: `opencode-go/${MODEL}`, input: "ping", stream: false }), + }), { providers: { "opencode-go": go({ 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); + }); +}); From 3cc86ca269a8990d409246cef9caad889a58683c Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 12:41:42 +0400 Subject: [PATCH 02/11] docs(providers): describe OpenCode Go Gemini routing and boundaries --- .../src/content/docs/guides/providers.md | 9 ++++++++- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/google.md | 20 +++++++++++++++++++ structure/transports/inventory.md | 3 +++ structure/transports/responses-wire-shapes.md | 4 ++-- 6 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..9357b8f5a1c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -718,7 +718,14 @@ 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. This applies to the built-in Go endpoint and +`https://opencode.ai/inference/go/v1` with key authentication. A custom destination +saved under the same provider name retains its own URL and does not receive this pin. +Other Go models keep their model-specific protocols. +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/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 6c08185aa9a..930a89aa3ab 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`, scoped to the configured destination for Go Gemini), 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 c522806f375..fc0b32b27ee 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -1,6 +1,6 @@ # Providers And Adapters -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. +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. 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. diff --git a/structure/providers/google.md b/structure/providers/google.md index 7e81d31afc2..4f1b19872c8 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -1,5 +1,25 @@ # Google Provider +## OpenCode Go Gemini transport + +`src/types/opencode-go.ts` scopes the exact `gemini-4-argon-contributor` pin 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 that 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 Go registry preserves custom destinations before +normalization so a same-named custom row cannot acquire this credential route. + +`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. +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 896442feb4e..7a48d509948 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 ede4d9a7ca1..8472550dfd4 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -202,7 +202,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. @@ -210,7 +210,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 From c5308e3d5d05ea56b6b6a3706be9395402a514c5 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 14:18:43 +0400 Subject: [PATCH 03/11] test(providers): preserve custom Go vision settings --- .../stale-vision-classification-migration.ts | 6 +++--- structure/providers-and-adapters.md | 8 ++++---- .../vision-classification-seed-repair.test.ts | 17 +++++++++-------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/providers/stale-vision-classification-migration.ts b/src/providers/stale-vision-classification-migration.ts index e27cb186268..c53b03cd4ce 100644 --- a/src/providers/stale-vision-classification-migration.ts +++ b/src/providers/stale-vision-classification-migration.ts @@ -28,9 +28,9 @@ * Identity is the same rule enrichment uses, read from the same helper: the registry must still * own the row (`providerMatchesRegistryTransport`, the check `enrichProviderFromRegistry` makes * before it writes registry metadata) and the row must still be on the entry's adapter. - * `opencode-go` is an existing key preset without `preserveCustomDestination`, so the id alone - * claims a row — exactly as it does for enrichment — while an entry that opts into destination - * preservation narrows this projection for free. + * `opencode-go` opts into `preserveCustomDestination`, so this projection only repairs rows + * still pointing at its registry destination. Same-named custom endpoints keep their saved + * capabilities, just as they do during enrichment. * * Nothing here writes `modelCapabilities`. That is the dedicated per-model axis, it outranks * every source this file touches, and it is where a deliberate text-only override belongs diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index fc0b32b27ee..51d0258f9e4 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -397,10 +397,10 @@ stripped). The paired modality declaration is the guard in both cases, which is without one is left alone — that row is either a half-finished repair or a deliberate operator entry, and the projection does not guess which. The row must also still be the registry's own: identity resolves through `providerMatchesRegistryTransport`, the rule `enrichProviderFromRegistry` -applies before it writes registry metadata, plus the entry's adapter. `opencode-go` is a pinned -key preset without `preserveCustomDestination`, so its id alone claims a row — exactly as it does -for enrichment — and an entry that opts into destination preservation narrows the projection with -it. `modelCapabilities` is never written: it is the +applies before it writes registry metadata, plus the entry's adapter. `opencode-go` opts into +`preserveCustomDestination`, so only rows still pointing at its registry destination are repaired; +same-named custom endpoints keep their saved capabilities, just as they do during enrichment. +`modelCapabilities` is never written: it is the axis that outranks every source here, so it is where a deliberate text-only override belongs (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. diff --git a/tests/providers/vision-classification-seed-repair.test.ts b/tests/providers/vision-classification-seed-repair.test.ts index 208bb413cfd..879abed6535 100644 --- a/tests/providers/vision-classification-seed-repair.test.ts +++ b/tests/providers/vision-classification-seed-repair.test.ts @@ -130,14 +130,15 @@ describe("stale vision classification migration", () => { expect(projectStaleVisionClassifications(config(atOwnHost), [entry]).changed).toBe(false); }); - test("still repairs a pinned preset row at any destination", () => { - // opencode-go is an existing key preset without preserveCustomDestination: the registry claims - // that id itself, and enrichment fills its seed into such a row for the same reason. The - // projection follows that policy instead of inventing a narrower one of its own. - const projection = projectStaleVisionClassifications( - staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"), - ); - expect(projection.changed).toBe(true); + test("preserves a same-named OpenCode Go row at a custom destination", () => { + // Go preserves custom destinations, so its registry migration must not change capabilities + // for an endpoint the registry does not describe. + const config = staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"); + const before = structuredClone(config); + const projection = projectStaleVisionClassifications(config); + expect(projection.changed).toBe(false); + expect(projection.config).toEqual(before); + expect(projection.warnings).toEqual([]); }); test("is a no-op on a config without the provider", () => { From c4b13f819fa74f006a706c15841cbc787ab8d022 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 14:34:38 +0400 Subject: [PATCH 04/11] docs(providers): describe Gemini routing helpers --- src/adapters/google.ts | 1 + src/types/opencode-go.ts | 1 + tests/providers/opencode-go-gemini-wire.test.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 8956939d300..dd83d669874 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -865,6 +865,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 = { diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts index eece1d5c008..90355bf95e2 100644 --- a/src/types/opencode-go.ts +++ b/src/types/opencode-go.ts @@ -22,6 +22,7 @@ export function isOpenCodeGoGeminiDestination(provider: OpenCodeGoDestination | } } +/** 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 modelId === OPENCODE_GO_GEMINI_MODEL && isOpenCodeGoGeminiDestination(provider) ? `https://opencode.ai/inference/go/v1/models/${OPENCODE_GO_GEMINI_MODEL}` diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts index d2a50d14948..dbbca12d17c 100644 --- a/tests/providers/opencode-go-gemini-wire.test.ts +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -17,6 +17,7 @@ const ENDPOINT = `https://opencode.ai/inference/go/v1/models/${MODEL}`; const BASES = ["https://opencode.ai/zen/go/v1", "https://opencode.ai/inference/go/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"; @@ -24,6 +25,7 @@ function endpointWithUserInfo(): string { 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 }; } From 5a29d3ee1c2ac2d80e4ab5844b92fd94399bb701 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 14:43:56 +0400 Subject: [PATCH 05/11] docs(providers): document Gemini request compilation --- src/adapters/google.ts | 1 + src/protocols/provider-summary.ts | 1 + tests/providers/opencode-go-gemini-wire.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index dd83d669874..d5176a3ac16 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -947,6 +947,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 diff --git a/src/protocols/provider-summary.ts b/src/protocols/provider-summary.ts index 35ad4425a17..9d6d7fb8fe5 100644 --- a/src/protocols/provider-summary.ts +++ b/src/protocols/provider-summary.ts @@ -55,6 +55,7 @@ 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); }; diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts index dbbca12d17c..b69fb75cea6 100644 --- a/tests/providers/opencode-go-gemini-wire.test.ts +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -126,6 +126,7 @@ describe("OpenCode Go Gemini Responses dispatch", () => { : 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" }, From c6b0c12967199eaed90a01b23403cd95db69acc7 Mon Sep 17 00:00:00 2001 From: Georgiy Bukharov Date: Wed, 14 Oct 2026 14:54:26 +0400 Subject: [PATCH 06/11] fix(providers): identify missing OpenCode Go credentials --- src/adapters/google.ts | 2 +- structure/providers/google.md | 1 + tests/providers/opencode-go-gemini-wire.test.ts | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index d5176a3ac16..86b142349d5 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1210,7 +1210,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte 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"); + 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)) { diff --git a/structure/providers/google.md b/structure/providers/google.md index 4f1b19872c8..51ad409b11a 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -14,6 +14,7 @@ normalization so a same-named custom row cannot acquire this credential route. `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 diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts index b69fb75cea6..b15121ca6ba 100644 --- a/tests/providers/opencode-go-gemini-wire.test.ts +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -85,6 +85,10 @@ describe("OpenCode Go Gemini wire (#7097)", () => { 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 () => { @@ -101,7 +105,7 @@ describe("OpenCode Go Gemini wire (#7097)", () => { 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("non-empty API key"); + .buildRequest(parseRequest({ model: MODEL, input: "ping" }))).rejects.toThrow("OpenCode Go requires a non-empty API key"); }); }); From d58084abd2bae9713284db884daf4fb76b27f0e1 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:25:05 +0900 Subject: [PATCH 07/11] fix(providers): share Go model protocols and preserve canonical aliases --- .../src/content/docs/guides/providers.md | 5 +- scripts/test-layout/layout.json | 1 + src/providers/registry.ts | 6 +- src/providers/registry/entries-core.ts | 10 +-- src/providers/registry/types.ts | 5 +- src/providers/resolved-model-policy-merge.ts | 2 +- src/router.ts | 2 +- src/types/opencode-go.ts | 32 +++++++- src/types/wire.ts | 44 ++--------- structure/providers-and-adapters.md | 2 +- structure/providers/google.md | 8 +- tests/fixtures/test-layout-expected.json | 1 + .../opencode-go-model-protocols.test.ts | 74 +++++++++++++++++++ 13 files changed, 133 insertions(+), 59 deletions(-) create mode 100644 tests/providers/opencode-go-model-protocols.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9357b8f5a1c..403245d57d8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -722,7 +722,10 @@ is left as it is; register the preview by hand with streaming and tool-result continuations. This applies to the built-in Go endpoint and `https://opencode.ai/inference/go/v1` with key authentication. A custom destination saved under the same provider name retains its own URL and does not receive this pin. -Other Go models keep their model-specific protocols. +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. Other models retain Chat. +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 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index c27f2a41dde..781a3e3af01 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1284,6 +1284,7 @@ "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", "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/providers/registry.ts b/src/providers/registry.ts index e420d88aac0..a24a957515b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -98,7 +98,7 @@ export function normalizedProviderEndpoint(value: string): string { * OAuth/forward providers stay pinned because their credentials must never be sent to an * arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new * preset can opt into collision preservation, in which case its fixed endpoint owns only rows - * that still match that destination. + * that still match that destination or a declared alias. */ export function providerMatchesRegistryTransport( id: string, @@ -112,9 +112,9 @@ export function providerMatchesRegistryTransport( // registry edit combines it with an override/template despite the registry parity tests. if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; if (typeof provider.baseUrl !== "string") return false; - if (provider.adapter !== entry.adapter) return false; if (provider.authMode !== undefined && provider.authMode !== "key") return false; - return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); + return [entry, ...(entry.destinationAliases ?? [])].some(destination => destination.adapter === provider.adapter + && normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(destination.baseUrl)); } /** * Resolve the registry entry a configured provider actually points at, by TRANSPORT diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 4b0342f2792..3830d8b6f6f 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1,3 +1,4 @@ +import { OPENCODE_GO_RESPONSES_DEFAULTS } from "../../types/opencode-go"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "../kiro-models"; import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "../antigravity-models"; @@ -825,6 +826,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ }, { id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + destinationAliases: [{ adapter: "openai-chat", baseUrl: "https://opencode.ai/inference/go/v1" }], // Preserve custom URLs before the endpoint-scoped Gemini pin is evaluated (#7097). preserveCustomDestination: true, authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", @@ -848,13 +850,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 diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 56a8c82752f..a32cd9a9212 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -213,10 +213,11 @@ 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. + * prefix would otherwise orphan every config a user wrote against the old address. Entries + * preserving custom destinations also recognize these aliases for registry transport ownership. */ destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; /** diff --git a/src/providers/resolved-model-policy-merge.ts b/src/providers/resolved-model-policy-merge.ts index b0502fd409d..a3fb50efdfb 100644 --- a/src/providers/resolved-model-policy-merge.ts +++ b/src/providers/resolved-model-policy-merge.ts @@ -117,7 +117,7 @@ export function resolvedBaseUrl( if (entry.allowBaseUrlOverride && !configuredIsResolved) { throw new Error(`Invalid baseUrl for provider "${entry.id}": expected a nonblank URL without unresolved placeholders`); } - return (/\{[^}]*\}/.test(entry.baseUrl) || entry.allowBaseUrlOverride) && configuredIsResolved + return (/\{[^}]*\}/.test(entry.baseUrl) || entry.allowBaseUrlOverride || entry.preserveCustomDestination) && configuredIsResolved ? [configured, "operator"] : [entry.baseUrl, "registry"]; } diff --git a/src/router.ts b/src/router.ts index 0ea69b230a3..16151d3e2c3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -380,7 +380,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`); } // Registry template URLs are presets; local/self-hosted entries opt in explicitly. - const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved + const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride || registryEntry.preserveCustomDestination) && userBaseUrlIsResolved ? userBaseUrl : registryEntry.baseUrl; if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl); diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts index 90355bf95e2..86f364efc05 100644 --- a/src/types/opencode-go.ts +++ b/src/types/opencode-go.ts @@ -1,5 +1,29 @@ -/** Exact Go Gemini contract: https://opencode.ai/docs/go/#endpoints (#7097). */ -export const OPENCODE_GO_GEMINI_MODEL = "gemini-4-argon-contributor"; +/** 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_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", +}); + +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; @@ -24,7 +48,7 @@ export function isOpenCodeGoGeminiDestination(provider: OpenCodeGoDestination | /** 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 modelId === OPENCODE_GO_GEMINI_MODEL && isOpenCodeGoGeminiDestination(provider) - ? `https://opencode.ai/inference/go/v1/models/${OPENCODE_GO_GEMINI_MODEL}` + 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 b4ad6b630f4..de6721a9ee4 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,4 +1,4 @@ -import { isOpenCodeGoGeminiDestination, OPENCODE_GO_GEMINI_MODEL, type OpenCodeGoDestination } from "./opencode-go"; +import { isOpenCodeGoGeminiDestination, OPENCODE_GO_MODEL_WIRES, type OpenCodeGoDestination } from "./opencode-go"; /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the @@ -42,38 +42,12 @@ 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. */ +/** Native Go wire pins share the exact-model table with registry Responses defaults. */ export function captureWireAdapterHardPins(providerName: string, provider?: WirePinProvider): Readonly> { - const models = anthropicWireModelsForProvider(providerName); - return Object.freeze({ - ...Object.fromEntries([...(models ?? [])].map(modelId => [modelId, "anthropic"])), - ...(providerName === "opencode-go" && isOpenCodeGoGeminiDestination(provider) - ? { [OPENCODE_GO_GEMINI_MODEL]: "google" } : {}), - }); + if (providerName !== "opencode-go") return Object.freeze({}); + return Object.freeze(Object.fromEntries(Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([, wire]) => + wire === "anthropic" || (wire === "google" && isOpenCodeGoGeminiDestination(provider)), + ))); } interface WirePinPrefixRule { @@ -156,8 +130,6 @@ export function isWirePinnedModel(providerName: string, modelId: string, provide /** 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 { - if (providerName === "opencode-go" && modelId === OPENCODE_GO_GEMINI_MODEL && isOpenCodeGoGeminiDestination(provider)) return "google"; - 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/providers-and-adapters.md b/structure/providers-and-adapters.md index 51d0258f9e4..cc5547fc65b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -571,7 +571,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. +Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize declared destination aliases as registry-owned and retain the configured canonical URL. `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); unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. `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 51ad409b11a..35867be3fba 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -2,13 +2,15 @@ ## OpenCode Go Gemini transport -`src/types/opencode-go.ts` scopes the exact `gemini-4-argon-contributor` pin to fixed-key +`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 that model on the `opencode-go` row; runtime, static policy, Fast +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 Go registry preserves custom destinations before -normalization so a same-named custom row cannot acquire this credential route. +normalization so a same-named custom row cannot acquire this credential route. The registry +recognizes `/inference/go/v1` as a canonical alias, preserving its URL, sibling wire defaults +and affinity; arbitrary custom destinations remain outside that ownership. `src/adapters/google.ts` sends the compiled Gemini body to `https://opencode.ai/inference/go/v1/models/gemini-4-argon-contributor`, with diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f37c50b0e49..a261386a845 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -871,6 +871,7 @@ "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", "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-model-protocols.test.ts b/tests/providers/opencode-go-model-protocols.test.ts new file mode 100644 index 00000000000..06026865171 --- /dev/null +++ b/tests/providers/opencode-go-model-protocols.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { captureRouteStaticPolicy } 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"]; +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 = go(baseUrl); + 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); + } + }); + + 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(`${baseUrl}/${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(); +}); From 3be7930042fbe54379af61e6d982081943f81d73 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:27:25 +0900 Subject: [PATCH 08/11] fix(providers): retain adapter isolation for destination aliases --- scripts/test-layout/layout.json | 1 + src/providers/registry.ts | 2 +- src/providers/registry/types.ts | 2 +- structure/providers-and-adapters.md | 2 +- tests/fixtures/test-layout-expected.json | 1 + tests/providers/registry-transport-alias.test.ts | 16 ++++++++++++++++ 6 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 tests/providers/registry-transport-alias.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 781a3e3af01..172480169b6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1285,6 +1285,7 @@ "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/providers/registry.ts b/src/providers/registry.ts index a24a957515b..77406c875b1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -111,7 +111,7 @@ export function providerMatchesRegistryTransport( // The opt-in is intentionally limited to fixed key destinations. Fail closed if a future // registry edit combines it with an override/template despite the registry parity tests. if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; - if (typeof provider.baseUrl !== "string") return false; + if (typeof provider.baseUrl !== "string" || provider.adapter !== entry.adapter) return false; if (provider.authMode !== undefined && provider.authMode !== "key") return false; return [entry, ...(entry.destinationAliases ?? [])].some(destination => destination.adapter === provider.adapter && normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(destination.baseUrl)); diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index a32cd9a9212..ec8b507b1c5 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -217,7 +217,7 @@ export interface ProviderRegistryEntry { * 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. Entries - * preserving custom destinations also recognize these aliases for registry transport ownership. + * preserving custom destinations also recognize same-adapter aliases for registry transport ownership. */ destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; /** diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index cc5547fc65b..f1b5795bf8b 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -571,7 +571,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 -Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize declared destination aliases as registry-owned and retain the configured canonical URL. `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); unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. +Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize same-adapter destination aliases as registry-owned and retain the configured canonical URL. `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); unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. `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/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a261386a845..257a427b0bd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -872,6 +872,7 @@ "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/registry-transport-alias.test.ts b/tests/providers/registry-transport-alias.test.ts new file mode 100644 index 00000000000..f75c034bf45 --- /dev/null +++ b/tests/providers/registry-transport-alias.test.ts @@ -0,0 +1,16 @@ +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"); +}); From 064b5e085541a8c2a733cd01954004765930a410 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 16:38:06 +0900 Subject: [PATCH 09/11] fix(providers): scope new Go native pins to canonical destinations --- docs-site/src/content/docs/guides/providers.md | 4 +++- src/types/opencode-go.ts | 5 +++++ src/types/wire.ts | 7 ++++--- structure/providers-and-adapters.md | 2 +- tests/providers/opencode-go-model-protocols.test.ts | 11 +++++++++++ .../responses-console-go-upload-retry.test.ts | 11 ++++------- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 403245d57d8..5982b8297ff 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -724,7 +724,9 @@ streaming and tool-result continuations. This applies to the built-in Go endpoin saved under the same provider name retains its own URL and does not receive this 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. Other models retain Chat. +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 only to canonical Go destinations 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`. diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts index 86f364efc05..cf3171c360f 100644 --- a/src/types/opencode-go.ts +++ b/src/types/opencode-go.ts @@ -21,6 +21,11 @@ export const OPENCODE_GO_MODEL_WIRES: Readonly = 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"), )); diff --git a/src/types/wire.ts b/src/types/wire.ts index de6721a9ee4..0e2767e1e2d 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,4 +1,4 @@ -import { isOpenCodeGoGeminiDestination, OPENCODE_GO_MODEL_WIRES, type OpenCodeGoDestination } from "./opencode-go"; +import { isOpenCodeGoGeminiDestination, 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 @@ -45,8 +45,9 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ /** 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({}); - return Object.freeze(Object.fromEntries(Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([, wire]) => - wire === "anthropic" || (wire === "google" && isOpenCodeGoGeminiDestination(provider)), + 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(provider)), ))); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index f1b5795bf8b..cc57af36698 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -571,7 +571,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 -Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize same-adapter destination aliases as registry-owned and retain the configured canonical URL. `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); unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. +Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize same-adapter destination aliases as registry-owned and retain the configured canonical URL. `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 require a canonical Go key destination while the five legacy name pins retain compatibility; unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. `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/tests/providers/opencode-go-model-protocols.test.ts b/tests/providers/opencode-go-model-protocols.test.ts index 06026865171..e69a438e971 100644 --- a/tests/providers/opencode-go-model-protocols.test.ts +++ b/tests/providers/opencode-go-model-protocols.test.ts @@ -32,6 +32,17 @@ describe("OpenCode Go documented per-model protocols (2026-10-15)", () => { } }); + for (const model of ["qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash"]) { + test(`${model} does not pin custom destinations 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" } }; + expect(resolveWireProtocolOverride("opencode-go", model, provider).adapter).toBe("openai-responses"); + expect(captureRouteStaticPolicy("opencode-go", model, provider).model.adapter).toBe("openai-responses"); + expect(fastPolicyForModel(provider, model, "opencode-go").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"); diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts index 3151c48ce8d..0d1c2d38bed 100644 --- a/tests/responses/responses-console-go-upload-retry.test.ts +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -200,23 +200,20 @@ describe("Console destination and translated recovery controls", () => { globalThis.fetch = (async input => { outbound.push(String(input)); return refusal(); }) as typeof fetch; const response = await handleResponses(request(false, "opencode-go"), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); - // Canonical row names normalize their base URL. A configured send path survives - // that normalization and reaches the effective-destination recovery gate. + // The canonical Go destination retains its configured send path, which reaches + // the effective-destination recovery gate. expect(outbound).toEqual(["https://opencode.ai/zen/go/v1/unrelated"]); expect(await response.text()).toContain("Invalid upload request."); }); - test("normalization to the canonical endpoint keeps its bounded recovery", async () => { + test("a same-named custom destination remains custom and does not authorize Go replay", async () => { const cfg = config(); cfg.providers["opencode-go"] = { ...cfg.providers.go!, baseUrl: "https://other.example.test/v1" }; const outbound: string[] = []; globalThis.fetch = (async input => { outbound.push(String(input)); return refusal(); }) as typeof fetch; const response = await handleResponses(request(false, "opencode-go"), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); - expect(outbound).toEqual([ - "https://opencode.ai/zen/go/v1/responses", - "https://opencode.ai/zen/go/v1/responses", - ]); + expect(outbound).toEqual(["https://other.example.test/v1/responses"]); expect(await response.text()).toContain("Invalid upload request."); }); From cb8c3e438466acb06eaebe89fe66c06fe2c7e039 Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 20:53:04 +0900 Subject: [PATCH 10/11] test(catalog): align static Go fixtures with destination ownership The inherited-default tests used a custom host while asserting historical name-only registry ownership. Go now deliberately preserves custom destinations, so those rows cannot inherit Go defaults. The protocol table never expanded the static roster, and changing runtime ownership back would break that boundary. Use the canonical Go endpoint without weakening any catalog assertions. Cover both canonical endpoints and custom rows with and without explicit defaults for omitted and empty model lists, including authoritative outcomes and zero fetches. Validation: 960 catalog/Codex tests across 24 files, 529 protocol regressions, 36 layout/size guards; typecheck, structure and privacy checks passed. No full suite per task scope. Mutating destination preservation makes four new tests fail. Co-authored-by: Georgiy Bukharov --- scripts/test-layout/layout.json | 1 + structure/catalog.md | 2 +- .../catalog-opencode-go-static.test.ts | 45 +++++++++++++++++++ tests/codex-integration/codex-catalog.test.ts | 6 +-- tests/fixtures/test-layout-expected.json | 1 + 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/codex-integration/catalog-opencode-go-static.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 172480169b6..732a6b7d877 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -283,6 +283,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", diff --git a/structure/catalog.md b/structure/catalog.md index 65a9ef46769..ea190aa26d0 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. Go inherits its registry default only at a canonical Go destination; custom destinations keep their explicit default or stay empty (`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/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..38ebd086529 --- /dev/null +++ b/tests/codex-integration/catalog-opencode-go-static.test.ts @@ -0,0 +1,45 @@ +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 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", owned: true, defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://opencode.ai/inference/go/v1", owned: true, defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://custom.invalid/v1", owned: false, defaultModel: undefined, expected: [] }, + { baseUrl: "https://custom.invalid/v1", owned: false, 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(destination.owned); + 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); + 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/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 5c3997706cb..a28124502ee 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -5780,12 +5780,12 @@ describe("Codex catalog routed normalization", () => { { label: "empty", models: [] as string[] }, ])("static Go $label models expose only the inherited default, not the metadata roster", async ({ models: configuredModels }) => { const provider: OcxProviderConfig = { - adapter: "openai-chat", baseUrl: "https://opencode-go.test/v1", apiKey: "sk-test", + adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", apiKey: "sk-test", liveModels: false, ...(configuredModels === undefined ? {} : { models: configuredModels }), }; - // Go keeps the registry's historical name-based ownership even with this fixture URL. - // This was never a genuinely default-less provider after capture-time enrichment. + // Registry defaults belong to canonical Go destinations; custom rows stay operator-owned. + // A canonical row inherits its default at capture time without importing the metadata roster. expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(true); const enriched = structuredClone(provider); enrichProviderFromRegistry("opencode-go", enriched); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 257a427b0bd..b7bc816f75d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -239,6 +239,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", From 0061da263a2e76c5d06daf75a6760360404c301e Mon Sep 17 00:00:00 2001 From: GOAT Date: Thu, 15 Oct 2026 21:00:48 +0900 Subject: [PATCH 11/11] fix(providers): restore historical OpenCode Go URL pinning Keep opencode-go's existing registry ownership and canonical URL pinning. Remove the custom-destination opt-in and its transport/policy support, and restore the original catalog, vision migration and recovery fixtures. Use the pinned canonical destination when capturing native Go model policies from saved rows, including protocol summaries. Keep the inference alias for renamed destination metadata and affinity without preserving preset URLs. Cover custom-name isolation, Gemini JSON/SSE/auth/signatures/effort, Qwen Messages and Luna Responses, and correct the user and architecture docs. Validation: 3,529 tests passed across 111 files (all 74 Go-containing files, all 32 protocol-named files, and additional boundary/layout guards); all 338 catalog tests passed. Typecheck, structure, privacy and docs build passed. The catalog file is byte-identical to origin/dev with no line growth. An isolated preservation-flag mutation fails 6/8 static catalog cases. The full suite was left to CI for this bounded concurrent-worktree lane. Co-authored-by: Georgiy Bukharov --- .../src/content/docs/guides/providers.md | 10 +++++--- src/providers/registry.ts | 8 +++--- src/providers/registry/entries-core.ts | 6 ++--- src/providers/registry/types.ts | 3 +-- src/providers/resolved-model-policy-merge.ts | 2 +- .../stale-vision-classification-migration.ts | 6 ++--- src/router.ts | 2 +- src/types/opencode-go.ts | 2 ++ src/types/wire.ts | 7 ++++-- structure/catalog.md | 2 +- structure/data-planes/protocol-paths.md | 2 +- structure/providers-and-adapters.md | 10 ++++---- structure/providers/google.md | 9 ++++--- .../catalog-opencode-go-static.test.ts | 14 +++++++---- tests/codex-integration/codex-catalog.test.ts | 6 ++--- .../providers/opencode-go-gemini-wire.test.ts | 25 ++++++++++++------- .../opencode-go-model-protocols.test.ts | 18 +++++++------ .../registry-transport-alias.test.ts | 11 ++++++++ .../vision-classification-seed-repair.test.ts | 17 ++++++------- .../responses-console-go-upload-retry.test.ts | 11 +++++--- 20 files changed, 101 insertions(+), 70 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5982b8297ff..4c5f83475c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -719,14 +719,16 @@ 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** routes `gemini-4-argon-contributor` through its Gemini API, including -streaming and tool-result continuations. This applies to the built-in Go endpoint and -`https://opencode.ai/inference/go/v1` with key authentication. A custom destination -saved under the same provider name retains its own URL and does not receive this pin. +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 only to canonical Go destinations with key authentication. +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`. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 77406c875b1..e420d88aac0 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -98,7 +98,7 @@ export function normalizedProviderEndpoint(value: string): string { * OAuth/forward providers stay pinned because their credentials must never be sent to an * arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new * preset can opt into collision preservation, in which case its fixed endpoint owns only rows - * that still match that destination or a declared alias. + * that still match that destination. */ export function providerMatchesRegistryTransport( id: string, @@ -111,10 +111,10 @@ export function providerMatchesRegistryTransport( // The opt-in is intentionally limited to fixed key destinations. Fail closed if a future // registry edit combines it with an override/template despite the registry parity tests. if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; - if (typeof provider.baseUrl !== "string" || provider.adapter !== entry.adapter) return false; + if (typeof provider.baseUrl !== "string") return false; + if (provider.adapter !== entry.adapter) return false; if (provider.authMode !== undefined && provider.authMode !== "key") return false; - return [entry, ...(entry.destinationAliases ?? [])].some(destination => destination.adapter === provider.adapter - && normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(destination.baseUrl)); + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); } /** * Resolve the registry entry a configured provider actually points at, by TRANSPORT diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 3830d8b6f6f..507bd5ae49d 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1,4 +1,4 @@ -import { OPENCODE_GO_RESPONSES_DEFAULTS } from "../../types/opencode-go"; +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 { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../../adapters/devin/live-models"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "../antigravity-models"; @@ -825,10 +825,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" }], - // Preserve custom URLs before the endpoint-scoped Gemini pin is evaluated (#7097). - preserveCustomDestination: true, 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 diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index ec8b507b1c5..f5939c98601 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -216,8 +216,7 @@ export interface ProviderRegistryEntry { * 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. Entries - * preserving custom destinations also recognize same-adapter aliases for registry transport ownership. + * prefix would otherwise orphan every config a user wrote against the old address. */ destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; /** diff --git a/src/providers/resolved-model-policy-merge.ts b/src/providers/resolved-model-policy-merge.ts index a3fb50efdfb..b0502fd409d 100644 --- a/src/providers/resolved-model-policy-merge.ts +++ b/src/providers/resolved-model-policy-merge.ts @@ -117,7 +117,7 @@ export function resolvedBaseUrl( if (entry.allowBaseUrlOverride && !configuredIsResolved) { throw new Error(`Invalid baseUrl for provider "${entry.id}": expected a nonblank URL without unresolved placeholders`); } - return (/\{[^}]*\}/.test(entry.baseUrl) || entry.allowBaseUrlOverride || entry.preserveCustomDestination) && configuredIsResolved + return (/\{[^}]*\}/.test(entry.baseUrl) || entry.allowBaseUrlOverride) && configuredIsResolved ? [configured, "operator"] : [entry.baseUrl, "registry"]; } diff --git a/src/providers/stale-vision-classification-migration.ts b/src/providers/stale-vision-classification-migration.ts index c53b03cd4ce..e27cb186268 100644 --- a/src/providers/stale-vision-classification-migration.ts +++ b/src/providers/stale-vision-classification-migration.ts @@ -28,9 +28,9 @@ * Identity is the same rule enrichment uses, read from the same helper: the registry must still * own the row (`providerMatchesRegistryTransport`, the check `enrichProviderFromRegistry` makes * before it writes registry metadata) and the row must still be on the entry's adapter. - * `opencode-go` opts into `preserveCustomDestination`, so this projection only repairs rows - * still pointing at its registry destination. Same-named custom endpoints keep their saved - * capabilities, just as they do during enrichment. + * `opencode-go` is an existing key preset without `preserveCustomDestination`, so the id alone + * claims a row — exactly as it does for enrichment — while an entry that opts into destination + * preservation narrows this projection for free. * * Nothing here writes `modelCapabilities`. That is the dedicated per-model axis, it outranks * every source this file touches, and it is where a deliberate text-only override belongs diff --git a/src/router.ts b/src/router.ts index 16151d3e2c3..0ea69b230a3 100644 --- a/src/router.ts +++ b/src/router.ts @@ -380,7 +380,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`); } // Registry template URLs are presets; local/self-hosted entries opt in explicitly. - const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride || registryEntry.preserveCustomDestination) && userBaseUrlIsResolved + const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved ? userBaseUrl : registryEntry.baseUrl; if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl); diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts index cf3171c360f..ecdea8c6013 100644 --- a/src/types/opencode-go.ts +++ b/src/types/opencode-go.ts @@ -2,6 +2,8 @@ * 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", diff --git a/src/types/wire.ts b/src/types/wire.ts index 0e2767e1e2d..58246258530 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,4 +1,4 @@ -import { isOpenCodeGoGeminiDestination, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go"; +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 @@ -45,9 +45,12 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ /** 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(provider)), + || (wire !== "openai-responses" && isOpenCodeGoGeminiDestination(destination)), ))); } diff --git a/structure/catalog.md b/structure/catalog.md index ea190aa26d0..29c6c7a50c6 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. Go inherits its registry default only at a canonical Go destination; custom destinations keep their explicit default or stay empty (`tests/codex-integration/catalog-opencode-go-static.test.ts`). +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 930a89aa3ab..73b28e2e12b 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`, scoped to the configured destination for Go Gemini), 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 cc57af36698..e01af39fcdd 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -397,10 +397,10 @@ stripped). The paired modality declaration is the guard in both cases, which is without one is left alone — that row is either a half-finished repair or a deliberate operator entry, and the projection does not guess which. The row must also still be the registry's own: identity resolves through `providerMatchesRegistryTransport`, the rule `enrichProviderFromRegistry` -applies before it writes registry metadata, plus the entry's adapter. `opencode-go` opts into -`preserveCustomDestination`, so only rows still pointing at its registry destination are repaired; -same-named custom endpoints keep their saved capabilities, just as they do during enrichment. -`modelCapabilities` is never written: it is the +applies before it writes registry metadata, plus the entry's adapter. `opencode-go` is a pinned +key preset without `preserveCustomDestination`, so its id alone claims a row — exactly as it does +for enrichment — and an entry that opts into destination preservation narrows the projection with +it. `modelCapabilities` is never written: it is the axis that outranks every source here, so it is where a deliberate text-only override belongs (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. @@ -571,7 +571,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 -Anthropic models across runtime and static policy resolution, even with Chat/Responses overrides. Fixed-key entries preserving custom destinations recognize same-adapter destination aliases as registry-owned and retain the configured canonical URL. `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 require a canonical Go key destination while the five legacy name pins retain compatibility; unknown models retain Chat. `tests/providers/opencode-go-model-protocols.test.ts` covers resolution and dispatch. +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. `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 35867be3fba..cc133d85bf3 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -7,10 +7,11 @@ HTTPS OpenCode Go destinations (`/zen/go/v1` or `/inference/go/v1`), excluding u 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 Go registry preserves custom destinations before -normalization so a same-named custom row cannot acquire this credential route. The registry -recognizes `/inference/go/v1` as a canonical alias, preserving its URL, sibling wire defaults -and affinity; arbitrary custom destinations remain outside that ownership. +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 diff --git a/tests/codex-integration/catalog-opencode-go-static.test.ts b/tests/codex-integration/catalog-opencode-go-static.test.ts index 38ebd086529..54d433cbd13 100644 --- a/tests/codex-integration/catalog-opencode-go-static.test.ts +++ b/tests/codex-integration/catalog-opencode-go-static.test.ts @@ -2,6 +2,7 @@ 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; @@ -11,10 +12,10 @@ 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", owned: true, defaultModel: undefined, expected: ["kimi-k2.7-code"] }, - { baseUrl: "https://opencode.ai/inference/go/v1", owned: true, defaultModel: undefined, expected: ["kimi-k2.7-code"] }, - { baseUrl: "https://custom.invalid/v1", owned: false, defaultModel: undefined, expected: [] }, - { baseUrl: "https://custom.invalid/v1", owned: false, defaultModel: "custom-model", expected: ["custom-model"] }, + { 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 = { @@ -22,12 +23,15 @@ describe("static opencode-go catalog destination ownership (#7097)", () => { ...(models === undefined ? {} : { models }), ...(destination.defaultModel === undefined ? {} : { defaultModel: destination.defaultModel }), }; - expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(destination.owned); + 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; diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index a28124502ee..5c3997706cb 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -5780,12 +5780,12 @@ describe("Codex catalog routed normalization", () => { { label: "empty", models: [] as string[] }, ])("static Go $label models expose only the inherited default, not the metadata roster", async ({ models: configuredModels }) => { const provider: OcxProviderConfig = { - adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", apiKey: "sk-test", + adapter: "openai-chat", baseUrl: "https://opencode-go.test/v1", apiKey: "sk-test", liveModels: false, ...(configuredModels === undefined ? {} : { models: configuredModels }), }; - // Registry defaults belong to canonical Go destinations; custom rows stay operator-owned. - // A canonical row inherits its default at capture time without importing the metadata roster. + // Go keeps the registry's historical name-based ownership even with this fixture URL. + // This was never a genuinely default-less provider after capture-time enrichment. expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(true); const enriched = structuredClone(provider); enrichProviderFromRegistry("opencode-go", enriched); diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts index b15121ca6ba..12652a3d6fd 100644 --- a/tests/providers/opencode-go-gemini-wire.test.ts +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -2,6 +2,7 @@ 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"; @@ -14,7 +15,7 @@ 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"]; +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. */ @@ -33,7 +34,11 @@ function go(overrides: Partial = {}): OcxProviderConfig { 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 provider = go({ baseUrl, modelAdapters: { [MODEL]: "openai-responses" } }); + 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"); @@ -71,10 +76,12 @@ describe("OpenCode Go Gemini wire (#7097)", () => { { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, ] satisfies Partial[])("does not pin an unrelated destination or auth mode: %j", overrides => { const provider = go(overrides); - expect(resolveWireProtocolOverride("opencode-go", MODEL, provider).adapter).toBe("openai-chat"); - expect(captureRouteStaticPolicy("opencode-go", MODEL, provider).model.adapter).not.toBe("google"); - expect(fastPolicyForModel(provider, MODEL, "opencode-go").adapter).not.toBe("google"); - if (overrides.baseUrl) expect(routedProviderConfig("opencode-go", provider).baseUrl).toBe(overrides.baseUrl); + 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 () => { @@ -174,7 +181,7 @@ describe("OpenCode Go Gemini Responses dispatch", () => { }); } - test("a same-named custom provider sends its key only to its configured destination", async () => { + 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) => { @@ -183,8 +190,8 @@ describe("OpenCode Go Gemini Responses dispatch", () => { }) as typeof fetch; const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: `opencode-go/${MODEL}`, input: "ping", stream: false }), - }), { providers: { "opencode-go": go({ baseUrl: "https://custom.invalid/v1", apiKey: "custom-key" }) } } as OcxConfig, + 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(); diff --git a/tests/providers/opencode-go-model-protocols.test.ts b/tests/providers/opencode-go-model-protocols.test.ts index e69a438e971..c1ff8db4897 100644 --- a/tests/providers/opencode-go-model-protocols.test.ts +++ b/tests/providers/opencode-go-model-protocols.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; import { providerConfigSeed } from "../../src/providers/derive"; import { getProviderRegistryEntry } from "../../src/providers/registry"; -import { captureRouteStaticPolicy } from "../../src/router"; +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"]; +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"], @@ -24,7 +24,8 @@ function go(baseUrl = BASES[0]): OcxProviderConfig { 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 = go(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); @@ -33,12 +34,13 @@ describe("OpenCode Go documented per-model protocols (2026-10-15)", () => { }); for (const model of ["qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash"]) { - test(`${model} does not pin custom destinations or non-key auth`, () => { + 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" } }; - expect(resolveWireProtocolOverride("opencode-go", model, provider).adapter).toBe("openai-responses"); - expect(captureRouteStaticPolicy("opencode-go", model, provider).model.adapter).toBe("openai-responses"); - expect(fastPolicyForModel(provider, model, "opencode-go").adapter).toBe("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"); } }); } @@ -75,7 +77,7 @@ for (const baseUrl of BASES) for (const [model, adapter, path] of ROUTES) test(` expect(response.status).toBe(200); expect(await response.text()).toContain("ok"); expect(requests).toHaveLength(1); - expect(requests[0]!.url).toBe(`${baseUrl}/${path}`); + 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"); diff --git a/tests/providers/registry-transport-alias.test.ts b/tests/providers/registry-transport-alias.test.ts index f75c034bf45..912206ccc4f 100644 --- a/tests/providers/registry-transport-alias.test.ts +++ b/tests/providers/registry-transport-alias.test.ts @@ -14,3 +14,14 @@ test("a metadata alias on another adapter does not grant registry transport owne 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); +}); diff --git a/tests/providers/vision-classification-seed-repair.test.ts b/tests/providers/vision-classification-seed-repair.test.ts index 879abed6535..208bb413cfd 100644 --- a/tests/providers/vision-classification-seed-repair.test.ts +++ b/tests/providers/vision-classification-seed-repair.test.ts @@ -130,15 +130,14 @@ describe("stale vision classification migration", () => { expect(projectStaleVisionClassifications(config(atOwnHost), [entry]).changed).toBe(false); }); - test("preserves a same-named OpenCode Go row at a custom destination", () => { - // Go preserves custom destinations, so its registry migration must not change capabilities - // for an endpoint the registry does not describe. - const config = staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"); - const before = structuredClone(config); - const projection = projectStaleVisionClassifications(config); - expect(projection.changed).toBe(false); - expect(projection.config).toEqual(before); - expect(projection.warnings).toEqual([]); + test("still repairs a pinned preset row at any destination", () => { + // opencode-go is an existing key preset without preserveCustomDestination: the registry claims + // that id itself, and enrichment fills its seed into such a row for the same reason. The + // projection follows that policy instead of inventing a narrower one of its own. + const projection = projectStaleVisionClassifications( + staleConfig(undefined, undefined, "openai-chat", "https://operator-gateway.example/v1"), + ); + expect(projection.changed).toBe(true); }); test("is a no-op on a config without the provider", () => { diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts index 0d1c2d38bed..3151c48ce8d 100644 --- a/tests/responses/responses-console-go-upload-retry.test.ts +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -200,20 +200,23 @@ describe("Console destination and translated recovery controls", () => { globalThis.fetch = (async input => { outbound.push(String(input)); return refusal(); }) as typeof fetch; const response = await handleResponses(request(false, "opencode-go"), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); - // The canonical Go destination retains its configured send path, which reaches - // the effective-destination recovery gate. + // Canonical row names normalize their base URL. A configured send path survives + // that normalization and reaches the effective-destination recovery gate. expect(outbound).toEqual(["https://opencode.ai/zen/go/v1/unrelated"]); expect(await response.text()).toContain("Invalid upload request."); }); - test("a same-named custom destination remains custom and does not authorize Go replay", async () => { + test("normalization to the canonical endpoint keeps its bounded recovery", async () => { const cfg = config(); cfg.providers["opencode-go"] = { ...cfg.providers.go!, baseUrl: "https://other.example.test/v1" }; const outbound: string[] = []; globalThis.fetch = (async input => { outbound.push(String(input)); return refusal(); }) as typeof fetch; const response = await handleResponses(request(false, "opencode-go"), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); - expect(outbound).toEqual(["https://other.example.test/v1/responses"]); + expect(outbound).toEqual([ + "https://opencode.ai/zen/go/v1/responses", + "https://opencode.ai/zen/go/v1/responses", + ]); expect(await response.text()).toContain("Invalid upload request."); });