diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..4c5f83475c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -718,7 +718,21 @@ MiniMax model list is still the previous default receives it on the next start; is left as it is; register the preview by hand with `ocx models add minimax MiniMax-M3.1-Flash-Preview --context-window 1000000`. -**OpenCode Go** requires a stable session identifier for routing. OpenCodex sends +**OpenCode Go** routes `gemini-4-argon-contributor` through its Gemini API, including +streaming and tool-result continuations. The existing `opencode-go` preset keeps its +historical URL pinning: saved custom URLs, including `https://opencode.ai/inference/go/v1`, +route through the canonical `https://opencode.ai/zen/go/v1` endpoint and inherit Go defaults. +Gemini then uses Go's native model endpoint with key authentication. A differently named +custom provider keeps its configured URL and does not receive this Gemini pin. +Go selects protocols by exact model ID: GPT 6 Luna, GPT 5.6 Luna, Grok 4.6/4.7 and +Muse Spark Contributor use Responses; Claude Haiku 5.5, MiniMax M2.7/M3 and +Qwen3.7 Plus/3.8 Max/3.8 Flash use Anthropic Messages. Legacy MiniMax M2.5 and +Union Alpha also retain Messages. Models without a listed wire retain Chat. +New native pins apply to the pinned Go destination with key authentication. +Responses models still accept an explicit `modelAdapters` override. +Gemini uses `low`, `medium` and `high` thinking levels; higher requested efforts clamp to `high`. + +OpenCode Go requires a stable session identifier for routing. OpenCodex sends `claude-haiku-5-5`, like the MiniMax models, over the Anthropic Messages wire, matching [OpenCode Go's endpoint table](https://opencode.ai/docs/go/#endpoints). OpenCodex derives its Go session header from Codex thread/session headers, or from a client's diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..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", @@ -1283,6 +1284,9 @@ "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", + "opencode-go-gemini-wire.test.ts": "providers", + "opencode-go-model-protocols.test.ts": "providers", + "registry-transport-alias.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", "opencode-go-muse-context.test.ts": "providers", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index a60bf565d67..86b142349d5 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, @@ -864,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 = { @@ -945,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 @@ -1202,11 +1205,21 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } - // ai-studio (default): Generative Language API + x-goog-api-key. - const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`; + // Go speaks Gemini on its own endpoint; ordinary AI Studio keeps /v1beta and its key header. + const goEndpoint = openCodeGoGeminiEndpoint(provider, routedModelId); + const endpoint = goEndpoint ?? `${provider.baseUrl}/v1beta/models/${routedModelId}`; + const url = `${endpoint}:${method}${streamParam}`; const apiKey = provider.apiKey?.trim(); - if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key"); - headers["x-goog-api-key"] = apiKey; + if (!apiKey) throw new Error(`${goEndpoint ? "OpenCode Go" : "google (AI Studio)"} requires a non-empty API key`); + if (goEndpoint) { + // Console inference documents Bearer auth. Send only the selected Go key. + for (const name of Object.keys(headers)) { + if (["authorization", "x-goog-api-key", "x-api-key"].includes(name.toLowerCase())) delete headers[name]; + } + headers["Authorization"] = `Bearer ${apiKey}`; + } else { + headers["x-goog-api-key"] = apiKey; + } const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); reportToolSchemaLoss(compiled.toolSchemaLossReport); diff --git a/src/protocols/provider-summary.ts b/src/protocols/provider-summary.ts index c4fbc383729..9d6d7fb8fe5 100644 --- a/src/protocols/provider-summary.ts +++ b/src/protocols/provider-summary.ts @@ -55,12 +55,13 @@ function usableModelId(model: unknown): model is string { function candidateModelIds(name: string, provider: Readonly): string[] { const registry = PROVIDER_REGISTRY.find(entry => entry.id === name); const ids = new Set(); + /** Collect valid candidate IDs without exceeding the protocol-summary scan limit. */ const add = (model: unknown) => { if (ids.size < MODEL_SCAN_LIMIT && usableModelId(model)) ids.add(model); }; for (const model of Object.keys(provider.modelAdapters ?? {})) add(model); for (const model of Object.keys(registry?.modelWireDefaults ?? {})) add(model); - for (const model of Object.keys(captureWireAdapterHardPins(name))) add(model); + for (const model of Object.keys(captureWireAdapterHardPins(name, provider))) add(model); add(provider.defaultModel); for (const model of provider.models ?? []) add(model); return [...ids].sort((left, right) => left.localeCompare(right)); diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts index 9090ca604dd..31aaaf47ebd 100644 --- a/src/providers/opencode-go-transport.ts +++ b/src/providers/opencode-go-transport.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import type { OcxProviderConfig } from "../types"; import { registryEntryForProviderDestination } from "./registry"; +import { isOpenCodeGoGeminiDestination } from "../types/opencode-go"; export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session"; @@ -44,7 +45,8 @@ export function resolveOpenCodeGoTransport( Pick & Partial>, ): T { - if (registryEntryForProviderDestination(destinationProvider)?.id !== "opencode-go") return provider; + if (registryEntryForProviderDestination(destinationProvider)?.id !== "opencode-go" + && !(provider.adapter === "google" && isOpenCodeGoGeminiDestination(provider))) return provider; if (!sessionLane) return provider; if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider; diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 3057fe31b74..507bd5ae49d 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1,3 +1,4 @@ +import { OPENCODE_GO_BASE_URL, OPENCODE_GO_RESPONSES_DEFAULTS } from "../../types/opencode-go"; import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "../kiro-models"; import { 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"; @@ -824,7 +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" }], 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 @@ -846,13 +848,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 @@ -909,6 +905,9 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ }, modelReasoningEfforts: { "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, + // Gemini's native thinkingLevel ladder; higher Codex efforts clamp to high. + // https://ai.google.dev/gemini-api/docs/models/gemini-4-argon + "gemini-4-argon-contributor": ["low", "medium", "high"], "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.7": ["low", "medium", "high", "xhigh"], "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 56a8c82752f..f5939c98601 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -213,7 +213,7 @@ export interface ProviderRegistryEntry { */ chatCompletionsPath?: string; /** - * Endpoints this entry used to live at, kept so a saved custom provider that still points + * Canonical or historical endpoints, kept so a saved custom provider that still points * at one keeps receiving this row's metadata through `registryEntryForProviderDestination`. * Destination matching is by adapter plus normalized base URL, so moving a row's wire or * prefix would otherwise orphan every config a user wrote against the old address. diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index c435da8d6f6..4e3aa884add 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -33,6 +33,7 @@ type ServiceTierCapabilityProvider = Pick< | "modelAdapters" | "baseUrl" | "authMode" + | "googleMode" | "apiKeyTransport" | "chatServiceTier" | "fastWire" @@ -119,7 +120,7 @@ function buildFastPolicyAuthority( : {}), }), modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }), - hardPins: captureWireAdapterHardPins(providerName), + hardPins: captureWireAdapterHardPins(providerName, provider), hardPinPrefixes: captureWireAdapterHardPinPrefixes(providerName, provider), registryWireDefaults: cloneRegistryWireDefaults(registry?.modelWireDefaults), }); diff --git a/src/types/opencode-go.ts b/src/types/opencode-go.ts new file mode 100644 index 00000000000..ecdea8c6013 --- /dev/null +++ b/src/types/opencode-go.ts @@ -0,0 +1,61 @@ +/** Exact-model protocols from https://opencode.ai/v2/docs/console/go/#endpoints (2026-10-15). + * Unknown models retain Chat. Responses entries are overridable registry defaults; + * the native wires are hard pins because modelAdapters cannot select those adapters. + */ +export const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1"; + +export const OPENCODE_GO_MODEL_WIRES: Readonly> = Object.freeze({ + "gemini-4-argon-contributor": "google", + "claude-haiku-5-5": "anthropic", + "minimax-m2.7": "anthropic", + "minimax-m3": "anthropic", + "qwen3.7-plus": "anthropic", + "qwen3.8-max": "anthropic", + "qwen3.8-flash": "anthropic", + "gpt-6-luna": "openai-responses", + "gpt-5.6-luna": "openai-responses", + "grok-4.6": "openai-responses", + "grok-4.7": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + // Retain previously supported Go models absent from the current endpoint table (#4847). + "minimax-m2.5": "anthropic", + "union-alpha": "anthropic", +}); + +/** Compatibility: these five pins predate destination scoping. New native pins require Go. */ +export const OPENCODE_GO_LEGACY_NAME_PINS: ReadonlySet = new Set([ + "claude-haiku-5-5", "minimax-m2.5", "minimax-m2.7", "minimax-m3", "union-alpha", +]); + +export const OPENCODE_GO_RESPONSES_DEFAULTS = Object.freeze(Object.fromEntries( + Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([, wire]) => wire === "openai-responses"), +)); + +export interface OpenCodeGoDestination { + readonly baseUrl?: unknown; + readonly authMode?: unknown; + readonly googleMode?: unknown; +} + +/** A provider name alone must never authorize redirecting a credential to OpenCode. */ +export function isOpenCodeGoGeminiDestination(provider: OpenCodeGoDestination | undefined): boolean { + if (!provider || typeof provider.baseUrl !== "string") return false; + if (provider.authMode !== undefined && provider.authMode !== "key") return false; + if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return false; + try { + const url = new URL(provider.baseUrl.trim()); + return url.origin === "https://opencode.ai" + && !url.username && !url.password && !url.search && !url.hash + && ["/zen/go/v1", "/inference/go/v1"].includes(url.pathname.replace(/\/+$/, "")); + } catch { + return false; + } +} + +/** Return the fixed Gemini endpoint only for the exact model and an eligible Go destination. */ +export function openCodeGoGeminiEndpoint(provider: OpenCodeGoDestination, modelId: string): string | undefined { + return Object.hasOwn(OPENCODE_GO_MODEL_WIRES, modelId) && OPENCODE_GO_MODEL_WIRES[modelId] === "google" && isOpenCodeGoGeminiDestination(provider) + ? `https://opencode.ai/inference/go/v1/models/${encodeURIComponent(modelId)}` + : undefined; +} diff --git a/src/types/wire.ts b/src/types/wire.ts index 59955f5bed1..58246258530 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,3 +1,5 @@ +import { isOpenCodeGoGeminiDestination, OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_WIRES, OPENCODE_GO_LEGACY_NAME_PINS, type OpenCodeGoDestination } from "./opencode-go"; + /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so @@ -40,35 +42,16 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ "openai-responses", ]); -/** - * Providers whose listed model ids must be driven over the Anthropic wire even when - * the provider's configured adapter says otherwise — the upstream only speaks - * Anthropic for these models. - */ -const ANTHROPIC_WIRE_MODELS: Record> = { - "opencode-go": new Set([ - "claude-haiku-5-5", - "minimax-m2.5", - "minimax-m2.7", - "minimax-m3", - // OpenCode's catalog identifies Union Alpha as @ai-sdk/anthropic while the - // provider defaults to OpenAI-compatible; direct Chat returns 500 and direct - // Messages reaches the session check (#4847). - "union-alpha", - ]), -}; - -function anthropicWireModelsForProvider(providerName: string): ReadonlySet | undefined { - return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName) - ? ANTHROPIC_WIRE_MODELS[providerName] - : undefined; -} - -/** Detached provider-local hard-pin table for pure wire-policy resolution. */ -export function captureWireAdapterHardPins(providerName: string): Readonly> { - const models = anthropicWireModelsForProvider(providerName); - if (!models) return Object.freeze({}); - return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"]))); +/** Native Go wire pins share the exact-model table with registry Responses defaults. */ +export function captureWireAdapterHardPins(providerName: string, provider?: WirePinProvider): Readonly> { + if (providerName !== "opencode-go") return Object.freeze({}); + // The existing Go preset pins saved URLs to this endpoint before dispatch. Pure policy + // captures must use that same destination even when handed an unnormalized config row. + const destination = provider && { ...provider, baseUrl: OPENCODE_GO_BASE_URL }; + return Object.freeze(Object.fromEntries(Object.entries(OPENCODE_GO_MODEL_WIRES).filter(([modelId, wire]) => + (wire === "anthropic" && OPENCODE_GO_LEGACY_NAME_PINS.has(modelId)) + || (wire !== "openai-responses" && isOpenCodeGoGeminiDestination(destination)), + ))); } interface WirePinPrefixRule { @@ -98,9 +81,7 @@ const WIRE_ADAPTER_PIN_PREFIXES: Readonly> = O }); /** Just enough of a provider config to tell whether it still points at the pinned endpoint. */ -export interface WirePinProvider { - readonly baseUrl?: unknown; -} +export interface WirePinProvider extends OpenCodeGoDestination {} function normalizedEndpoint(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -148,13 +129,11 @@ function prefixedWireAdapter(providerName: string, modelId: string, provider: Wi * adapter" would pass on the first pass and then let the override win on the second. */ export function isWirePinnedModel(providerName: string, modelId: string, provider?: WirePinProvider): boolean { - return (anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false) - || prefixedWireAdapter(providerName, modelId, provider) !== undefined; + return pinnedWireAdapter(providerName, modelId, provider) !== undefined; } /** The wire a pinned model must use, or undefined when the model is not pinned. */ export function pinnedWireAdapter(providerName: string, modelId: string, provider?: WirePinProvider): string | undefined { - return anthropicWireModelsForProvider(providerName)?.has(modelId) - ? "anthropic" - : prefixedWireAdapter(providerName, modelId, provider); + const pins = captureWireAdapterHardPins(providerName, provider); + return Object.hasOwn(pins, modelId) ? pins[modelId] : prefixedWireAdapter(providerName, modelId, provider); } diff --git a/structure/catalog.md b/structure/catalog.md index 65a9ef46769..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. +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 6c08185aa9a..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`), 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 c522806f375..e01af39fcdd 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. @@ -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. 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 7e81d31afc2..cc133d85bf3 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -1,5 +1,29 @@ # Google Provider +## OpenCode Go Gemini transport + +`src/types/opencode-go.ts` declares the shared exact-model Go protocol table and scopes its Google pins to fixed-key +HTTPS OpenCode Go destinations (`/zen/go/v1` or `/inference/go/v1`), excluding userinfo, +query, fragment, nonstandard ports and Google OAuth/Vertex modes. `src/types/wire.ts` +selects `google` for the table's Gemini model on the `opencode-go` row; runtime, static policy, Fast +policy and protocol summaries share the pin. User-configurable adapter overrides stay +limited to the two OpenAI wires. The existing `opencode-go` preset keeps historical name-based +ownership and pins saved custom or inference URLs to `/zen/go/v1` before dispatch. Pure policy +captures apply the same canonical destination. A differently named custom Chat provider keeps +its configured URL and does not acquire the Google pin. The registry recognizes `/inference/go/v1` +as a destination alias for metadata and affinity on differently named rows. + +`src/adapters/google.ts` sends the compiled Gemini body to +`https://opencode.ai/inference/go/v1/models/gemini-4-argon-contributor`, with +`:generateContent` or `:streamGenerateContent?alt=sse`, using the selected key as Bearer +authorization. The existing Go session helper adds affinity for the settled Google wire. +An empty key fails locally with an OpenCode Go diagnostic; AI Studio keeps its own diagnostic. +The preset declares Gemini's `low`/`medium`/`high` thinking-level ladder; higher requested +Codex efforts clamp to `high` through the existing effort resolver. +AI Studio, Vertex and Antigravity retain their own transport contracts. Mocked dispatch +and thought-signature continuation coverage lives in `tests/providers/opencode-go-gemini-wire.test.ts`; +these fixtures do not attest live upstream availability. + ## Google thought-text visibility boundary Google-family parts with `thought: true` stay separate from assistant output. After a CCA diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 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 diff --git a/tests/codex-integration/catalog-opencode-go-static.test.ts b/tests/codex-integration/catalog-opencode-go-static.test.ts new file mode 100644 index 00000000000..54d433cbd13 --- /dev/null +++ b/tests/codex-integration/catalog-opencode-go-static.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels } from "../../src/codex/catalog"; +import { enrichProviderFromRegistry } from "../../src/providers/derive"; +import { providerMatchesRegistryTransportWithStaticGuards } from "../../src/providers/static-model-discovery"; +import { routedProviderConfig } from "../../src/router"; +import type { OcxProviderConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +describe("static opencode-go catalog destination ownership (#7097)", () => { + for (const models of [undefined, [] as string[]]) { + const label = models === undefined ? "omitted" : "empty"; + for (const destination of [ + { baseUrl: "https://opencode.ai/zen/go/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://opencode.ai/inference/go/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://custom.invalid/v1", defaultModel: undefined, expected: ["kimi-k2.7-code"] }, + { baseUrl: "https://custom.invalid/v1", defaultModel: "custom-model", expected: ["custom-model"] }, + ]) { + test(`${label} models at ${destination.baseUrl} with ${destination.defaultModel ?? "no explicit default"}`, async () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: destination.baseUrl, apiKey: "sk-test", liveModels: false, + ...(models === undefined ? {} : { models }), + ...(destination.defaultModel === undefined ? {} : { defaultModel: destination.defaultModel }), + }; + expect(providerMatchesRegistryTransportWithStaticGuards("opencode-go", provider)).toBe(true); + const enriched = structuredClone(provider); + enrichProviderFromRegistry("opencode-go", enriched); + expect(enriched.baseUrl).toBe(destination.baseUrl); + expect(enriched.defaultModel).toBe(destination.expected[0]); + expect(enriched.models).toEqual(models); + const routed = routedProviderConfig("opencode-go", enriched); + expect(routed.baseUrl).toBe("https://opencode.ai/zen/go/v1"); + expect(routed.defaultModel).toBe(destination.expected[0]); + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("static Go catalog gathering must not fetch models"); + }) as typeof fetch; + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const gathered = await gatherRoutedModels({ providers: { "opencode-go": provider } }, { providerModelOutcomes: outcomes }); + expect(gathered.map(model => `${model.provider}/${model.id}`)) + .toEqual(destination.expected.map(id => `opencode-go/${id}`)); + expect(outcomes).toEqual([{ provider: "opencode-go", state: "authoritative" }]); + expect(fetchCalls).toBe(0); + }); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..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", @@ -870,6 +871,9 @@ "openai-responses-summary-none.test.ts": "responses", "responses-forward-output-cap.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", + "opencode-go-gemini-wire.test.ts": "providers", + "opencode-go-model-protocols.test.ts": "providers", + "registry-transport-alias.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", "opencode-go-muse-context.test.ts": "providers", "opencode-go-muse-vision.test.ts": "providers", "opencode-go-quota.test.ts": "providers", "opencode-go-session-header.test.ts": "providers", "opencode-management-transport.test.ts": "providers", diff --git a/tests/providers/opencode-go-gemini-wire.test.ts b/tests/providers/opencode-go-gemini-wire.test.ts new file mode 100644 index 00000000000..12652a3d6fd --- /dev/null +++ b/tests/providers/opencode-go-gemini-wire.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { fastPolicyForModel } from "../../src/providers/service-tier"; +import { isOpenCodeGoGeminiDestination } from "../../src/types/opencode-go"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import { buildProtocolProviderSummary } from "../../src/protocols/provider-summary"; +import { parseRequest } from "../../src/responses/parser"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxConfig, type OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const MODEL = "gemini-4-argon-contributor"; +const ENDPOINT = `https://opencode.ai/inference/go/v1/models/${MODEL}`; +const BASES = ["https://opencode.ai/zen/go/v1", "https://opencode.ai/inference/go/v1", "https://custom.invalid/v1"]; +const SIGNATURE = "synthetic-gemini-thought-signature-for-tool-continuation"; + +/** Build a synthetic URL with embedded credentials to exercise the destination rejection. */ +function endpointWithUserInfo(): string { + const url = new URL(BASES[0]!); + url.username = "test"; + url.password = "test"; + return url.href; +} + +/** Seed the canonical Go fixture, with overrides for destination and authentication cases. */ +function go(overrides: Partial = {}): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "test-key", ...overrides }; +} + +describe("OpenCode Go Gemini wire (#7097)", () => { + test("runtime, static policy, Fast and protocol summary agree on the exact pin", () => { + for (const baseUrl of BASES) { + const configured = go({ baseUrl, modelAdapters: { [MODEL]: "openai-responses" } }); + expect(captureRouteStaticPolicy("opencode-go", MODEL, configured).model.adapter).toBe("google"); + expect(fastPolicyForModel(configured, MODEL, "opencode-go").adapter).toBe("google"); + const provider = routedProviderConfig("opencode-go", configured); + expect(provider.baseUrl).toBe(BASES[0]!); + for (const inbound of ["responses", "chat", "anthropic"] as const) { + const policy = captureRouteStaticPolicy("opencode-go", MODEL, provider, undefined, inbound); + expect(policy.model.adapter).toBe("google"); + expect(policy.provenance.model.adapter).toBe("hard-pin"); + const resolved = resolveWireProtocolOverride("opencode-go", MODEL, provider, inbound); + expect(resolved.adapter).toBe("google"); + expect(resolveWireProtocolOverride("opencode-go", MODEL, resolved, inbound).adapter).toBe("google"); + expect(fastPolicyForModel(provider, MODEL, "opencode-go", inbound).adapter).toBe("google"); + } + const summary = buildProtocolProviderSummary({ providers: { "opencode-go": go({ baseUrl, models: [] }) } } as OcxConfig, "opencode-go"); + expect(summary?.modelOverrides).toContainEqual({ model: MODEL, adapter: "google", source: "hard-pin" }); + } + expect(MODEL_ADAPTER_OVERRIDE_ALLOWED.has("google")).toBe(false); + }); + + test("sibling models and another provider name keep their existing wires", () => { + for (const [model, adapter] of [ + ["glm-5.2", "openai-chat"], ["gpt-5.6-luna", "openai-responses"], + ["muse-spark-1.3-contributor", "openai-responses"], ["minimax-m3", "anthropic"], + ["claude-haiku-5-5", "anthropic"], ["gemini-future", "openai-chat"], + ]) expect(resolveWireProtocolOverride("opencode-go", model!, go()).adapter).toBe(adapter!); + expect(resolveWireProtocolOverride("custom-go", MODEL, go()).adapter).toBe("openai-chat"); + }); + + test.each([ + { baseUrl: "https://custom.invalid/v1" }, + { baseUrl: "https://opencode.ai.evil.invalid/zen/go/v1" }, + { baseUrl: "https://opencode.ai/zen/v1" }, + { baseUrl: "https://opencode.ai/zen/go/v1?key=fixture" }, + { baseUrl: "https://opencode.ai/zen/go/v1#fragment" }, + { baseUrl: endpointWithUserInfo() }, + { baseUrl: "http://opencode.ai/zen/go/v1" }, + { baseUrl: "https://opencode.ai:8443/zen/go/v1" }, + { authMode: "oauth" }, { authMode: "forward" }, + { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, + ] satisfies Partial[])("does not pin an unrelated destination or auth mode: %j", overrides => { + const provider = go(overrides); + expect(isOpenCodeGoGeminiDestination(provider)).toBe(false); + const name = overrides.baseUrl ? "custom-go" : "opencode-go"; + expect(resolveWireProtocolOverride(name, MODEL, provider).adapter).toBe("openai-chat"); + expect(captureRouteStaticPolicy(name, MODEL, provider).model.adapter).not.toBe("google"); + expect(fastPolicyForModel(provider, MODEL, name).adapter).not.toBe("google"); + if (overrides.baseUrl) expect(routedProviderConfig(name, provider).baseUrl).toBe(overrides.baseUrl); + }); + + test("AI Studio keeps its own endpoint and API-key header", async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter({ + adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "studio-key", + })); + const request = await adapter.buildRequest(parseRequest({ model: MODEL, input: "ping", stream: false })); + expect(request.url).toBe(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`); + expect(new Headers(request.headers).get("x-goog-api-key")).toBe("studio-key"); + expect(new Headers(request.headers).has("authorization")).toBe(false); + await expect(withTestTranslatorBudget(createGoogleAdapter({ + adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: " ", + })).buildRequest(parseRequest({ model: MODEL, input: "ping" }))) + .rejects.toThrow("google (AI Studio) requires a non-empty API key"); + }); + + test("Go normalizes endpoint spelling, owns its key header, and preserves an explicit session", async () => { + const provider = go({ baseUrl: " https://OPENCODE.AI:443/zen/go/v1/// ", headers: { + authorization: "Bearer stale-fixture", "X-Goog-Api-Key": "stale-fixture", "X-Opencode-Session": "operator-session", + } }); + const resolved = resolveWireProtocolOverride("opencode-go", MODEL, provider); + expect(resolved.adapter).toBe("google"); + const adapter = withTestTranslatorBudget(createGoogleAdapter(resolved)); + const request = await adapter.buildRequest(parseRequest({ model: MODEL, input: "ping", stream: false })); + expect(request.url).toBe(`${ENDPOINT}:generateContent`); + const headers = new Headers(request.headers); + expect(headers.get("authorization")).toBe("Bearer test-key"); + expect(headers.has("x-goog-api-key")).toBe(false); + expect(headers.get("x-opencode-session")).toBe("operator-session"); + await expect(withTestTranslatorBudget(createGoogleAdapter({ ...resolved, apiKey: " " })) + .buildRequest(parseRequest({ model: MODEL, input: "ping" }))).rejects.toThrow("OpenCode Go requires a non-empty API key"); + }); +}); + +describe("OpenCode Go Gemini Responses dispatch", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + afterEach(() => { globalThis.fetch = originalFetch; releaseSpendHome?.(); releaseSpendHome = undefined; }); + + for (const baseUrl of BASES) for (const stream of [false, true]) { + test(`Gemini request, completion and tool continuation (${baseUrl}, stream=${stream})`, async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; headers: Headers; body: any }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + const parts = requests.length === 1 + ? [{ functionCall: { id: "call_probe", name: "probe", args: { path: "src" } }, thoughtSignature: SIGNATURE }] + : [{ text: "Done" }]; + const body = { candidates: [{ content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 3, totalTokenCount: 13 } }; + return stream + ? new Response(`data: ${JSON.stringify(body)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(body); + }) as typeof fetch; + const config = { providers: { "opencode-go": go({ baseUrl }) } } as OcxConfig; + /** Dispatch one turn and return the completed Responses object from JSON or SSE. */ + const send = async (input: unknown[]) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "thread-id": "gemini-fixture-thread" }, + body: JSON.stringify({ model: `opencode-go/${MODEL}`, input, stream, store: false, + reasoning: { effort: stream ? "xhigh" : "high" }, + tools: [{ type: "function", name: "probe", parameters: { type: "object", properties: { path: { type: "string" } } } }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + const text = await response.text(); + if (!stream) return JSON.parse(text); + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + expect(events.some(event => event.type === "response.failed" || event.type === "error")).toBe(false); + return events.find(event => event.type === "response.completed")?.response; + }; + const input = [{ role: "user", content: "Inspect src using probe" }]; + const first = await send(input); + expect(first?.status).toBe("completed"); + const call = first.output.find((item: any) => item.type === "function_call"); + expect(call).toMatchObject({ name: "probe", arguments: '{"path":"src"}' }); + const second = await send([...input, ...first.output, { type: "function_call_output", call_id: call.call_id, output: "found files" }]); + expect(second?.status).toBe("completed"); + expect(JSON.stringify(second.output)).toContain("Done"); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe(`${ENDPOINT}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.headers.get("authorization")).toBe("Bearer test-key"); + expect(request.headers.has("x-goog-api-key")).toBe(false); + expect(request.headers.get("x-opencode-session")).toMatch(/^ocx_[a-f0-9]{32}$/); + expect(request.body.contents[0]).toMatchObject({ role: "user" }); + expect(request.body.messages).toBeUndefined(); + expect(request.body.reasoning_effort).toBeUndefined(); + expect(request.body.generationConfig.thinkingConfig.thinkingLevel).toBe("high"); + expect(request.body.tools[0].functionDeclarations[0].name).toBe("probe"); + } + expect(requests[1]!.headers.get("x-opencode-session")).toBe(requests[0]!.headers.get("x-opencode-session")); + const contents = requests[1]!.body.contents; + const callIndex = contents.findIndex((turn: any) => turn.parts.some((part: any) => part.functionCall)); + expect(contents[callIndex].parts[0]).toMatchObject({ functionCall: { name: "probe", args: { path: "src" } }, thoughtSignature: SIGNATURE }); + expect(contents[callIndex + 1].parts[0]).toMatchObject({ functionResponse: { name: "probe", response: { result: "found files" } } }); + }); + } + + test("a differently named custom provider sends its key only to its configured destination", async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `custom-go/${MODEL}`, input: "ping", stream: false }), + }), { providers: { "custom-go": { adapter: "openai-chat", baseUrl: "https://custom.invalid/v1", apiKey: "custom-key" } } } as OcxConfig, + { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe("https://custom.invalid/v1/chat/completions"); + expect(requests[0]!.headers.get("authorization")).toBe("Bearer custom-key"); + expect(requests[0]!.headers.has("x-opencode-session")).toBe(false); + }); +}); diff --git a/tests/providers/opencode-go-model-protocols.test.ts b/tests/providers/opencode-go-model-protocols.test.ts new file mode 100644 index 00000000000..c1ff8db4897 --- /dev/null +++ b/tests/providers/opencode-go-model-protocols.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import { fastPolicyForModel } from "../../src/providers/service-tier"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const BASES = ["https://opencode.ai/zen/go/v1", "https://opencode.ai/inference/go/v1", "https://custom.invalid/v1"]; +const ROUTES = [ + ["gpt-6-luna", "openai-responses", "responses"], + ["gpt-5.6-luna", "openai-responses", "responses"], + ["claude-haiku-5-5", "anthropic", "messages"], + ["glm-5.2", "openai-chat", "chat/completions"], + ["qwen3.7-plus", "anthropic", "messages"], + ["qwen3.8-max", "anthropic", "messages"], + ["qwen3.8-flash", "anthropic", "messages"], +] as const; +function go(baseUrl = BASES[0]): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "test-key", baseUrl: baseUrl! }; +} + +describe("OpenCode Go documented per-model protocols (2026-10-15)", () => { + for (const baseUrl of BASES) for (const [model, adapter] of ROUTES) test(`${model} resolves consistently (${baseUrl})`, () => { + const provider = routedProviderConfig("opencode-go", go(baseUrl)); + expect(provider.baseUrl).toBe(BASES[0]!); + for (const inbound of ["responses", "chat", "anthropic"] as const) { + expect(resolveWireProtocolOverride("opencode-go", model, provider, inbound).adapter).toBe(adapter); + expect(captureRouteStaticPolicy("opencode-go", model, provider, undefined, inbound).model.adapter).toBe(adapter); + expect(fastPolicyForModel(provider, model, "opencode-go", inbound).adapter).toBe(adapter); + } + }); + + for (const model of ["qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash"]) { + test(`${model} does not pin differently named custom providers or non-key auth`, () => { + for (const overrides of [{ baseUrl: "https://custom.invalid/v1" }, { authMode: "oauth" }, { authMode: "forward" }] as const) { + const provider = { ...go(), ...overrides, modelAdapters: { [model]: "openai-responses" } }; + const name = "baseUrl" in overrides ? "custom-go" : "opencode-go"; + expect(resolveWireProtocolOverride(name, model, provider).adapter).toBe("openai-responses"); + expect(captureRouteStaticPolicy(name, model, provider).model.adapter).toBe("openai-responses"); + expect(fastPolicyForModel(provider, model, name).adapter).toBe("openai-responses"); + } + }); + } + + test("Luna retains an explicit Chat opt-out and unknown siblings remain Chat", () => { + const provider = { ...go(), modelAdapters: { "gpt-6-luna": "openai-chat" } }; + expect(resolveWireProtocolOverride("opencode-go", "gpt-6-luna", provider).adapter).toBe("openai-chat"); + for (const model of ["qwen-next", "gemini-next", "gpt-next", "glm-5.3-flash"]) + expect(resolveWireProtocolOverride("opencode-go", model, go()).adapter).toBe("openai-chat"); + }); +}); + +const originalFetch = globalThis.fetch; +let releaseSpendHome: (() => void) | undefined; +afterEach(() => { globalThis.fetch = originalFetch; releaseSpendHome?.(); releaseSpendHome = undefined; }); +for (const baseUrl of BASES) for (const [model, adapter, path] of ROUTES) test(`${model} dispatches ${path} (${baseUrl})`, async () => { + releaseSpendHome = acquireOwnedSpendHome(); + const requests: Array<{ url: string; body: any; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body)), headers: new Headers(init?.headers) }); + return Response.json(adapter === "anthropic" + ? { id: "msg_fixture", type: "message", role: "assistant", model, content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", usage: { input_tokens: 2, output_tokens: 1 } } + : adapter === "openai-chat" + ? { choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] } + : { id: "resp_fixture", object: "response", status: "completed", model, + output: [{ type: "message", id: "msg_fixture", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "ok", annotations: [] }] }], usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 } }); + }) as typeof fetch; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json", "thread-id": "protocol-fixture" }, + body: JSON.stringify({ model: `opencode-go/${model}`, input: "ping", stream: false }), + }), { providers: { "opencode-go": go(baseUrl) } } as OcxConfig, { model: "", provider: "" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("ok"); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe(`${BASES[0]}/${path}`); + expect(requests[0]!.body.model).toBe(model); + if (adapter === "anthropic") expect(requests[0]!.headers.get("x-api-key")).toBe("test-key"); + else expect(requests[0]!.headers.get("authorization")).toBe("Bearer test-key"); + expect(requests[0]!.headers.get("x-opencode-session")).toMatch(/^ocx_[a-f0-9]{32}$/); + if (adapter !== "openai-responses") expect(requests[0]!.body.messages[0].role).toBe("user"); + else expect(requests[0]!.body.input).toBeDefined(); +}); diff --git a/tests/providers/registry-transport-alias.test.ts b/tests/providers/registry-transport-alias.test.ts new file mode 100644 index 00000000000..912206ccc4f --- /dev/null +++ b/tests/providers/registry-transport-alias.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../src/providers/registry"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../src/router"; +import type { OcxProviderConfig } from "../../src/types"; + +test("a metadata alias on another adapter does not grant registry transport ownership", () => { + // Volcengine's canonical row moved to Responses; saved Chat rows remain Chat. + const provider: OcxProviderConfig = { + adapter: "openai-chat", authMode: "key", apiKey: "test-key", + baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", + }; + expect(registryEntryForProviderDestination(provider)?.id).toBe("volcengine-coding-plan"); + expect(providerMatchesRegistryTransport("volcengine-coding-plan", provider)).toBe(false); + expect(routedProviderConfig("volcengine-coding-plan", provider).adapter).toBe("openai-chat"); + expect(captureRouteStaticPolicy("volcengine-coding-plan", "doubao-seed-2.0-code", provider).provider.adapter).toBe("openai-chat"); +}); + +test("the Go inference alias identifies renamed destinations while the preset remains pinned", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", authMode: "key", apiKey: "test-key", + baseUrl: "https://opencode.ai/inference/go/v1", + }; + expect(registryEntryForProviderDestination(provider)?.id).toBe("opencode-go"); + expect(providerMatchesRegistryTransport("opencode-go", provider)).toBe(true); + expect(routedProviderConfig("opencode-go", provider).baseUrl).toBe("https://opencode.ai/zen/go/v1"); + expect(routedProviderConfig("renamed-go", provider).baseUrl).toBe(provider.baseUrl); +});