diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520134210a2..15f2c0430e9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -296,13 +296,23 @@ and 30-day observations are local usage estimates, not live remaining quota or b authoritative limit event is shown only after the upstream reports a concrete limit event (and, when provided, its reset). -The provider pins the current Zen Go lineup (25 model ids) as its static catalog, with live -`/v1/models` discovery authoritative on the canonical host — ids outside the trusted set are -quarantined rather than routed. Transports follow the official endpoint table: Qwen and MiniMax -models go over Anthropic Messages, `gpt-5.6-luna` and `grok-4.5` over OpenAI Responses, and the +The provider pins 28 verified Zen Go model ids, including `deepseek-v4.1-flash`, +`glm-5.3-flash`, and `qwen3.8-flash`, as +its static catalog. Live `/v1/models` discovery is authoritative on the canonical host; ids +outside the trusted set are quarantined rather than routed. Transports follow the official +endpoint table: Qwen and MiniMax models go over Anthropic Messages, `gpt-5.6-luna` and `grok-4.5` +over OpenAI Responses, and the remaining models over OpenAI Chat Completions. These trust facts attach only to the canonical `https://opencode.ai/zen/go/v1` destination; a same-named custom provider keeps its own behavior. +OpenCode Go requires a session identifier for each conversation. CodexCommander derives an opaque +`x-opencode-session` from the Codex task or session header and keeps it stable through that +conversation, including subagent turns. Claude Code turns use their per-session metadata when available. +An explicit `x-opencode-session` from a Responses, Chat Completions, or Messages client is used when no Codex identity is available. Requests without either identifier receive a distinct temporary +session, and a configured provider header takes precedence. This behavior applies only to the +canonical OpenCode Go destination. The proxy identifies itself with a CodexCommander user agent +unless the provider configuration sets one explicitly. + The built-in preset is key-based, so Add Provider groups it under **Paid**, not account-login providers, and CodexCommander does not offer an OpenCode Go OAuth flow. It is also separate from both the **OpenCode** client under Client Apps and the no-key **OpenCode Free** provider. Add Provider search diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts new file mode 100644 index 00000000000..b59bedd7957 --- /dev/null +++ b/src/providers/opencode-go-transport.ts @@ -0,0 +1,51 @@ +import { createHash, randomUUID } from "node:crypto"; +import { resolveCodexTaskIdentity } from "../codex/task-identity"; +import type { CodexCommanderProviderConfig } from "../types"; +import { providerMatchesRegistryTransport } from "./registry"; + +const SESSION_HEADER = "x-opencode-session"; +const USER_AGENT_HEADER = "user-agent"; + +function hasHeader(headers: Record | undefined, name: string): boolean { + return Object.keys(headers ?? {}).some(key => key.toLowerCase() === name); +} + +function validSession(value: string | null): string | undefined { + return value && value.length <= 512 && !/[\x00-\x20\x7f,]/.test(value) ? value : undefined; +} + +/** Give OpenCode Go a stable, opaque lane for each Codex conversation. */ +export function resolveOpenCodeGoTransport( + providerName: string, + provider: CodexCommanderProviderConfig, + headers: Headers, + clientMetadata?: unknown, +): CodexCommanderProviderConfig { + // The router may already have pinned this model to Go's Anthropic or Responses + // wire. Verify the canonical key destination against the registry's base adapter, + // while accepting only Go's three documented wire adapters. + if (providerName !== "opencode-go" + || !["openai-chat", "anthropic", "openai-responses"].includes(provider.adapter) + || !providerMatchesRegistryTransport(providerName, { ...provider, adapter: "openai-chat" })) return provider; + const hasSession = hasHeader(provider.headers, SESSION_HEADER); + const hasUserAgent = hasHeader(provider.headers, USER_AGENT_HEADER); + if (hasSession && hasUserAgent) return provider; + + const outboundHeaders = { ...provider.headers }; + if (!hasSession) { + const lane = resolveCodexTaskIdentity(headers, clientMetadata).taskId + ?? validSession(headers.get(SESSION_HEADER)) + ?? randomUUID(); + const session = createHash("sha256") + .update("codexcommander/opencode-go/session/v1\0") + .update(lane) + .digest("hex") + .slice(0, 32); + outboundHeaders[SESSION_HEADER] = `ccx_${session}`; + } + if (!hasUserAgent) outboundHeaders["User-Agent"] = "CodexCommander"; + return { + ...provider, + headers: outboundHeaders, + }; +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 44a7ffc15e9..59a0875a692 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -359,8 +359,9 @@ const THINKING_BUDGET_MODELS = [ ]; const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max"]; /** - * Pinned last-known-good OpenCode Go lineup (25 ids): the exact id set advertised by - * `GET https://opencode.ai/zen/go/v1/models`, verified 2026-08-05. That endpoint is + * Pinned OpenCode Go lineup (28 ids): the 25-id snapshot from 2026-08-05 plus + * DeepSeek V4.1 Flash, GLM-5.3 Flash, and Qwen3.8 Flash, verified 2026-09-27. + * `GET https://opencode.ai/zen/go/v1/models` is * existence-only — it returns ids without context/output/pricing metadata — so this list is * the catalog seed, and the registry-only discovery filter below admits exactly these ids: * any other model upstream starts (or stops) advertising is quarantined rather than guessed @@ -373,16 +374,17 @@ const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwe * Transport split per the official endpoint table (https://opencode.ai/docs/go/#endpoints): * Qwen and MiniMax rows serve Anthropic Messages (`/zen/go/v1/messages`), GPT-5.6 Luna and * Grok 4.5 serve OpenAI Responses (`/zen/go/v1/responses`), and the remaining rows serve - * OpenAI Chat Completions (`/zen/go/v1/chat/completions`). The Anthropic subset is a hard + * OpenAI Chat Completions (`/zen/go/v1/chat/completions`). DeepSeek V4.1 Flash is included + * on that Chat wire. The Anthropic subset is a hard * wire pin owned by types.ts (OPENCODE_GO_ANTHROPIC_WIRE_MODEL_IDS); the two OpenAI-shaped * wires are registry `modelWireDefaults` on the entry below. */ const OPENCODE_GO_MODELS = [ "minimax-m3", "minimax-m2.7", "minimax-m2.5", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", - "glm-5.2", "glm-5.1", "glm-5", - "deepseek-v4-pro", "deepseek-v4-flash", - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", + "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2.5-pro", "mimo-v2.5", "hy3", "hy3-preview", "gpt-5.6-luna", "grok-4.5", @@ -392,6 +394,7 @@ const OPENCODE_GO_RESPONSES_WIRE_MODELS = ["gpt-5.6-luna", "grok-4.5"]; // ladder on the `xai` entry); GPT-5.6 Luna serves the OpenAI API GPT-5.6 ladder. const OPENCODE_GO_GROK45_REASONING_EFFORTS = ["low", "medium", "high"]; const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"]; +const OPENCODE_GO_DEEPSEEK_THINKING_MODELS = [...DEEPSEEK_THINKING_MODELS, "deepseek-v4.1-flash"]; const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"]; /* * Zen free models that reject `image_url` upstream (#1043, and the reproducible @@ -1159,7 +1162,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", models: [...OPENCODE_GO_MODELS], // Live /v1/models is the authoritative lineup; the static list above is the last-good - // fallback seed. The registry-only filter quarantines any id outside the trusted 25, and + // fallback seed. The registry-only filter quarantines any id outside the trusted set, and // `preserveCustomDestination` keeps the whole trusted transport registry (this filter, the // wire defaults, and every registry metadata backfill) attached to the canonical Zen Go // host only — a same-named row pointed elsewhere is a custom provider and gets none of it. @@ -1177,20 +1180,27 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelWireDefaults: { ...Object.fromEntries(OPENCODE_GO_RESPONSES_WIRE_MODELS.map(id => [id, "openai-responses"])), }, - // Zen Go context windows not covered by the generated jawcode bundle (qwen3.8-max and - // gpt-5.6-luna have no bundle row yet): official data pages - // https://opencode.ai/data/qwen/qwen3-8-max (1M) and - // https://opencode.ai/data/openai/gpt-5-6-luna (1.1M — the OpenAI API value 1,050,000). + // Zen Go context windows not covered by the generated jawcode bundle: + // https://opencode.ai/data/deepseek/deepseek-v4-1-flash, + // https://stats.opencode.ai/data/zhipu/glm-5-3-flash, and + // https://stats.opencode.ai/data/qwen/qwen3-8-flash (all 1M), plus the + // previously pinned Qwen3.8 Max and GPT-5.6 Luna values. modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, + "deepseek-v4.1-flash": 1_000_000, + "glm-5.3-flash": 1_000_000, "qwen3.8-max": 1_000_000, + "qwen3.8-flash": 1_000_000, "gpt-5.6-luna": 1_050_000, }, // qwen3.8-max (text/image/video) and gpt-5.6-luna (text/image/pdf) are multimodal upstream; // the jawcode type can only represent text+image, so video/pdf stay source facts. modelInputModalities: { "kimi-k3": ["text", "image"], + "deepseek-v4.1-flash": ["text", "image"], + "glm-5.3-flash": ["text", "image"], "qwen3.8-max": ["text", "image"], + "qwen3.8-flash": ["text", "image"], "gpt-5.6-luna": ["text", "image"], }, modelReasoningEfforts: { @@ -1202,7 +1212,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])), ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), - ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + ...Object.fromEntries(OPENCODE_GO_DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), }, modelDefaultReasoningEfforts: { "kimi-k3": "max" }, // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map); @@ -1210,7 +1220,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelReasoningEffortMap: { "kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP, ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), - ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + ...Object.fromEntries(OPENCODE_GO_DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), }, thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, thinkingBudgetModels: THINKING_BUDGET_MODELS, @@ -1230,7 +1240,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ noPenaltyModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. - preserveReasoningContentModels: ["glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS], + preserveReasoningContentModels: ["glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...OPENCODE_GO_DEEPSEEK_THINKING_MODELS], }, { id: "neuralwatt", diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 2a1839cb4ce..921756d9c1c 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -167,6 +167,10 @@ async function handleChatCompletionsWithBudget( const value = req.headers.get(name); if (value) headers.set(name, value); } + // Keep an explicit Go session through the Chat-to-Responses bridge. Only the + // canonical Go destination turns it into an upstream header. + const goSession = req.headers.get("x-opencode-session"); + if (goSession) headers.set("x-opencode-session", goSession); // Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns. if (!directRoute) { // This enrichment is optional for routed/non-main providers. If native main diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1afa0478728..c749e829b1c 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -30,6 +30,7 @@ import { import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, routeModel } from "../router"; +import { providerMatchesRegistryTransport } from "../providers/registry"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { CodexCommanderConfig } from "../types"; @@ -706,11 +707,15 @@ async function handleClaudeMessagesWithBudget( // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. let nativeRoute = false; + let openCodeGoRoute = false; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); + openCodeGoRoute = route.providerName === "opencode-go" + && ["openai-chat", "anthropic", "openai-responses"].includes(route.provider.adapter) + && providerMatchesRegistryTransport(route.providerName, { ...route.provider, adapter: "openai-chat" }); logCtx.routeDecision = route.routeDecision; if (route.provider.adapter === "openai-responses") { nativeRoute = true; @@ -754,6 +759,13 @@ async function handleClaudeMessagesWithBudget( const value = req.headers.get(name); if (value) headers.set(name, value); } + // The Messages replay uses Responses routing; carry an explicit Go lane across + // that internal boundary only for the canonical Go destination. Its transport + // hashes the value before sending it upstream. + if (openCodeGoRoute) { + const goSession = req.headers.get("x-opencode-session"); + if (goSession) headers.set("x-opencode-session", goSession); + } // Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable; // native replays have no caller ChatGPT credential. This enrichment is optional: // auth-context later rejects a real physical-main selection, while routed/pool @@ -766,7 +778,7 @@ async function handleClaudeMessagesWithBudget( headers.set("chatgpt-account-id", token.chatgptAccountId); } } - if (nativeRoute) { + if (nativeRoute || (openCodeGoRoute && !headers.has("x-opencode-session"))) { // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex // clients always send their session uuid; implementation contract follow-up: body-level // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends @@ -774,6 +786,8 @@ async function handleClaudeMessagesWithBudget( // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback // key is shared across Desktop conversations, and a shared session_id's backend // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. + // Go also needs a per-session identity on routed Claude Code turns. Never + // derive it from the shared system fallback or override an explicit Go lane. if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1d16b3c22fa..420a9354b30 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -126,6 +126,7 @@ import { import { noteProviderCredentialVerified } from "../../providers/credential-verification"; import { shouldAttemptImageTierRetry } from "../image-retry"; import { resolveProviderTransport } from "../../providers/xai-transport"; +import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport"; import type { WsData } from "../ws-bridge"; import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn, type ActiveTurnLease } from "../lifecycle"; import { redactSecretString } from "../../lib/redact"; @@ -1789,6 +1790,12 @@ async function handleResponsesInner( parsed.options.promptCacheKey, route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined, ); + route.provider = resolveOpenCodeGoTransport( + route.providerName, + route.provider, + req.headers, + nativeClientMetadata(parsed._rawBody), + ); requestDispatchContext(logCtx, options.abortSignal ?? req.signal, undefined, config.providers[route.providerName]); const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); const adapter = resolveAdapter(adapterProvider, config.cacheRetention); diff --git a/src/types.ts b/src/types.ts index a5f7b17bf79..f7c47b176d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1355,7 +1355,7 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet = new Set([ */ export const OPENCODE_GO_ANTHROPIC_WIRE_MODEL_IDS = [ "minimax-m2.5", "minimax-m2.7", "minimax-m3", - "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max", + "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash", ] as const; const ANTHROPIC_WIRE_MODELS: Record> = { diff --git a/tests/opencode-go-catalog.test.ts b/tests/opencode-go-catalog.test.ts index 575cf4635c2..376c2378188 100644 --- a/tests/opencode-go-catalog.test.ts +++ b/tests/opencode-go-catalog.test.ts @@ -2,13 +2,15 @@ * OpenCode Go provider catalog + trusted transport registry drift guard. * * The registry pins the last-known-good Zen Go lineup (25 ids from - * `GET https://opencode.ai/zen/go/v1/models`, verified 2026-08-05) and every trusted id owns + * `GET https://opencode.ai/zen/go/v1/models`, verified 2026-08-05, plus DeepSeek V4.1 Flash, + * GLM-5.3 Flash, and Qwen3.8 Flash, verified 2026-09-27) + * and every trusted id owns * an explicit wire fact from the official endpoint table * (https://opencode.ai/docs/go/#endpoints): * * - Qwen 3.5/3.6/3.7/3.8 and MiniMax M2.5/M2.7/M3 -> Anthropic Messages (hard pin, types.ts) * - GPT-5.6 Luna and Grok 4.5 -> OpenAI Responses (registry wire default) - * - the remaining 15 known-compatible rows -> OpenAI Chat Completions (wire default) + * - the remaining known-compatible rows -> OpenAI Chat Completions (wire default) * * The trust policy is registry-only and canonical-host-only: a same-named provider pointed at * any other destination receives none of it, and live-discovered ids outside the trusted set @@ -39,16 +41,16 @@ const CANONICAL_MODELS_URL = "https://opencode.ai/zen/go/v1/models"; const TRUSTED_MODEL_IDS = [ "minimax-m3", "minimax-m2.7", "minimax-m2.5", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", - "glm-5.2", "glm-5.1", "glm-5", - "deepseek-v4-pro", "deepseek-v4-flash", - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", + "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", + "deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash", + "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2.5-pro", "mimo-v2.5", "hy3", "hy3-preview", "gpt-5.6-luna", "grok-4.5", ]; const ANTHROPIC_WIRE_MODEL_IDS = [ "minimax-m2.5", "minimax-m2.7", "minimax-m3", - "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max", + "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash", ]; const RESPONSES_WIRE_MODEL_IDS = ["gpt-5.6-luna", "grok-4.5"]; const CHAT_WIRE_MODEL_IDS = TRUSTED_MODEL_IDS.filter(id => @@ -56,7 +58,7 @@ const CHAT_WIRE_MODEL_IDS = TRUSTED_MODEL_IDS.filter(id => /** Advertised but not Go-plan callable (issue #82): trusted id, compatibility-excluded from pickers. */ const COMPATIBILITY_EXCLUDED_IDS = ["hy3-preview"]; /** Trusted ids with no generated jawcode bundle row yet; the registry owns their metadata. */ -const REGISTRY_OWNED_METADATA_IDS = ["gpt-5.6-luna", "hy3-preview", "qwen3.8-max"]; +const REGISTRY_OWNED_METADATA_IDS = ["deepseek-v4.1-flash", "glm-5.3-flash", "gpt-5.6-luna", "hy3-preview", "qwen3.8-max", "qwen3.8-flash"]; const originalFetch = globalThis.fetch; @@ -97,7 +99,7 @@ function liveCatalogPayload(extraIds: string[] = []): string { } describe("OpenCode Go trusted catalog", () => { - test("pins the canonical destination and the exact last-good 25 model ids", () => { + test("pins the canonical destination and the exact last-good 28 model ids", () => { const entry = registryEntry(); expect(entry).toMatchObject({ id: "opencode-go", @@ -107,9 +109,9 @@ describe("OpenCode Go trusted catalog", () => { liveModels: true, preserveCustomDestination: true, }); - expect(entry.models).toHaveLength(25); + expect(entry.models).toHaveLength(28); expect([...(entry.models ?? [])].sort()).toEqual([...TRUSTED_MODEL_IDS].sort()); - expect(new Set(entry.models).size).toBe(25); + expect(new Set(entry.models).size).toBe(28); expect(entry.models).toContain(entry.defaultModel); }); @@ -124,7 +126,7 @@ describe("OpenCode Go trusted catalog", () => { test("config seeds and key-login derivations never persist the trust policy", () => { const seed = providerConfigSeed(registryEntry()); - expect(seed.models).toHaveLength(25); + expect(seed.models).toHaveLength(28); expect(seed.liveModels).toBe(true); for (const forbidden of ["modelDiscovery", "modelWireDefaults", "preserveCustomDestination"]) { expect(seed).not.toHaveProperty(forbidden); @@ -135,9 +137,9 @@ describe("OpenCode Go trusted catalog", () => { describe("OpenCode Go per-model transport registry", () => { test("every trusted id owns exactly one explicit wire fact", () => { - expect(ANTHROPIC_WIRE_MODEL_IDS).toHaveLength(8); + expect(ANTHROPIC_WIRE_MODEL_IDS).toHaveLength(9); expect(RESPONSES_WIRE_MODEL_IDS).toHaveLength(2); - expect(CHAT_WIRE_MODEL_IDS).toHaveLength(15); + expect(CHAT_WIRE_MODEL_IDS).toHaveLength(17); expect([ ...ANTHROPIC_WIRE_MODEL_IDS, ...RESPONSES_WIRE_MODEL_IDS, @@ -228,10 +230,22 @@ describe("OpenCode Go live-catalog quarantine", () => { contextWindow: 1_000_000, inputModalities: ["text", "image"], }); + expect(models.find(row => row.id === "qwen3.8-flash")).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + }); + expect(models.find(row => row.id === "glm-5.3-flash")).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + }); expect(models.find(row => row.id === "gpt-5.6-luna")).toMatchObject({ contextWindow: 1_050_000, inputModalities: ["text", "image"], }); + expect(models.find(row => row.id === "deepseek-v4.1-flash")).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + }); }); test("a failed live discovery falls back to the pinned static lineup", async () => { diff --git a/tests/opencode-go-deepseek.test.ts b/tests/opencode-go-deepseek.test.ts index 0f3a378b541..bff9bf34d24 100644 --- a/tests/opencode-go-deepseek.test.ts +++ b/tests/opencode-go-deepseek.test.ts @@ -99,7 +99,7 @@ describe("opencode-go DeepSeek V4 thinking mode", () => { }); }); - test.each(["deepseek-v4-flash", "deepseek-v4-pro"])( + test.each(["deepseek-v4-flash", "deepseek-v4.1-flash", "deepseek-v4-pro"])( "%s replays tool-call reasoning and maps Codex efforts", modelId => { const xhighBody = buildToolCallBody(modelId, "xhigh"); @@ -107,7 +107,7 @@ describe("opencode-go DeepSeek V4 thinking mode", () => { // #1057: `xhigh` is a vendor alias that resolves per model — max on Pro, // high on Flash (api-docs.deepseek.com/guides/thinking_mode, 2026-08-06). - expect(xhighBody.reasoning_effort).toBe(modelId === "deepseek-v4-flash" ? "high" : "max"); + expect(xhighBody.reasoning_effort).toBe(modelId.includes("flash") ? "high" : "max"); expect(mediumBody.reasoning_effort).toBe("high"); expect(xhighBody.messages[1].reasoning_content).toBe("I need to inspect files before answering."); expect(xhighBody.messages[1]).toMatchObject({ diff --git a/tests/opencode-go-session.test.ts b/tests/opencode-go-session.test.ts new file mode 100644 index 00000000000..3ee930a92a9 --- /dev/null +++ b/tests/opencode-go-session.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleResponses } from "../src/server/responses/core"; +import { handleChatCompletions } from "../src/server/chat-completions"; +import { handleClaudeMessages } from "../src/server/claude-messages"; +import { providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { resolveOpenCodeGoTransport } from "../src/providers/opencode-go-transport"; +import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../src/types"; + +function goProvider(overrides: Partial = {}): CodexCommanderProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-go")!), apiKey: "go-test-key", ...overrides }; +} + +describe("OpenCode Go session affinity", () => { + test("keeps one opaque session per task, distinct from the parent task", () => { + const provider = goProvider(); + const headers = new Headers({ "thread-id": "child-1", "x-codex-parent-thread-id": "parent" }); + const first = resolveOpenCodeGoTransport("opencode-go", provider, headers); + const repeat = resolveOpenCodeGoTransport("opencode-go", provider, headers); + const sibling = resolveOpenCodeGoTransport("opencode-go", provider, new Headers({ + "thread-id": "child-2", "x-codex-parent-thread-id": "parent", + })); + expect(first.headers?.["x-opencode-session"]).toBe(repeat.headers?.["x-opencode-session"]); + expect(first.headers?.["x-opencode-session"]).not.toBe(sibling.headers?.["x-opencode-session"]); + expect(first.headers?.["x-opencode-session"]).toMatch(/^ccx_[0-9a-f]{32}$/); + expect(first.headers?.["x-opencode-session"]).not.toContain("child-1"); + }); + + test("accepts Codex session and explicit Go headers, with an isolated request fallback", () => { + const provider = goProvider(); + const native = resolveOpenCodeGoTransport("opencode-go", provider, new Headers({ session_id: "codex-session" })); + const explicit = resolveOpenCodeGoTransport("opencode-go", provider, new Headers({ "x-opencode-session": "client-session" })); + const noIdentity1 = resolveOpenCodeGoTransport("opencode-go", provider, new Headers()); + const noIdentity2 = resolveOpenCodeGoTransport("opencode-go", provider, new Headers()); + expect(native.headers?.["x-opencode-session"]).toBeTruthy(); + expect(explicit.headers?.["x-opencode-session"]).toBeTruthy(); + expect(noIdentity1.headers?.["x-opencode-session"]).not.toBe(noIdentity2.headers?.["x-opencode-session"]); + }); + + test("honors operator headers and never changes a lookalike provider", () => { + const configured = goProvider({ headers: { "X-OpenCode-Session": "operator-session" } }); + const resolved = resolveOpenCodeGoTransport("opencode-go", configured, new Headers({ session_id: "task" })); + expect(resolved.headers?.["X-OpenCode-Session"]).toBe("operator-session"); + expect(resolved.headers?.["x-opencode-session"]).toBeUndefined(); + expect(resolved.headers?.["User-Agent"]).toBe("CodexCommander"); + const fullyConfigured = goProvider({ headers: { "X-OpenCode-Session": "operator-session", "User-Agent": "operator-client" } }); + expect(resolveOpenCodeGoTransport("opencode-go", fullyConfigured, new Headers())).toBe(fullyConfigured); + const lookalike = goProvider({ baseUrl: "https://example.com/zen/go/v1" }); + expect(resolveOpenCodeGoTransport("opencode-go", lookalike, new Headers({ session_id: "task" }))).toBe(lookalike); + expect(resolveOpenCodeGoTransport("other", goProvider(), new Headers({ session_id: "task" })).headers).toBeUndefined(); + }); + + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + test("sends V4.1 Flash with the session header on the Codex Responses bridge", async () => { + const outbound: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + outbound.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 config = { providers: { "opencode-go": goProvider() } } as unknown as CodexCommanderConfig; + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "thread-id": "child-1" }, + body: JSON.stringify({ model: "opencode-go/deepseek-v4.1-flash", input: "Say OK", stream: true }), + }); + const response = await handleResponses(req, config, { model: "", provider: "" }); + await response.text(); + expect(outbound[0]?.url).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + expect(outbound[0]?.headers.get("x-opencode-session")).toMatch(/^ccx_[0-9a-f]{32}$/); + expect(outbound[0]?.headers.get("user-agent")).toBe("CodexCommander"); + }); + + test("keeps the session header across Go's Chat, Anthropic, and Responses wires", async () => { + const outbound: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + outbound.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 config = { providers: { "opencode-go": goProvider() } } as unknown as CodexCommanderConfig; + for (const [model, path] of [ + ["glm-5.3-flash", "/chat/completions"], + ["qwen3.8-flash", "/messages"], + ["qwen3.8-max", "/messages"], + ["gpt-5.6-luna", "/responses"], + ]) { + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", "thread-id": `go-${model}` }, + body: JSON.stringify({ model: `opencode-go/${model}`, input: "Say OK", stream: true }), + }); + const response = await handleResponses(req, config, { model: "", provider: "" }); + await response.text(); + const sent = outbound.at(-1); + expect(sent?.url).toBe(`https://opencode.ai/zen/go/v1${path}`); + expect(sent?.headers.get("x-opencode-session")).toMatch(/^ccx_[0-9a-f]{32}$/); + expect(sent?.headers.get("user-agent")).toBe("CodexCommander"); + } + }); + + test("keeps an explicit client session through the Chat Completions bridge", async () => { + const outbound: Headers[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(new Headers(init?.headers)); + return Response.json({ choices: [{ message: { role: "assistant", content: "OK" }, finish_reason: "stop" }] }); + }) as typeof fetch; + const config = { providers: { "opencode-go": goProvider() } } as unknown as CodexCommanderConfig; + const req = new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json", "x-opencode-session": "chat-client-session" }, + body: JSON.stringify({ + model: "opencode-go/deepseek-v4.1-flash", + messages: [{ role: "user", content: "Say OK" }], + stream: false, + }), + }); + const response = await handleChatCompletions(req, config, { model: "", provider: "" }); + await response.text(); + const expected = resolveOpenCodeGoTransport( + "opencode-go", goProvider(), new Headers({ "x-opencode-session": "chat-client-session" }), + ).headers?.["x-opencode-session"]; + expect(outbound[0]?.get("x-opencode-session")).toBe(expected); + }); + + test("keeps Claude Code turns together and forwards an explicit Messages session", async () => { + const outbound: Headers[] = []; + const outboundBodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(new Headers(init?.headers)); + outboundBodies.push(String(init?.body ?? "")); + return Response.json({ choices: [{ message: { role: "assistant", content: "OK" }, finish_reason: "stop" }] }); + }) as typeof fetch; + const config = { providers: { "opencode-go": goProvider() } } as unknown as CodexCommanderConfig; + const invoke = async (metadata?: Record, session?: string) => { + const request = new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + ...(session ? { "x-opencode-session": session } : {}), + }, + body: JSON.stringify({ + model: "opencode-go/deepseek-v4.1-flash", + max_tokens: 32, + messages: [{ role: "user", content: "Say OK" }], + ...(metadata ? { metadata } : {}), + }), + }); + const response = await handleClaudeMessages(request, config, { model: "", provider: "" }); + await response.text(); + }; + + await invoke({ user_id: "user_abcd_session_1111" }); + await invoke({ user_id: "user_abcd_session_1111" }); + await invoke({ user_id: "user_abcd_session_2222" }); + await invoke(undefined, "explicit-messages-session"); + await invoke({ user_id: "user_abcd_session_1111" }, "explicit-messages-session"); + expect(outbound).toHaveLength(5); + const sessions = outbound.map(headers => headers.get("x-opencode-session")); + expect(sessions[0]).toMatch(/^ccx_[0-9a-f]{32}$/); + expect(sessions[1]).toBe(sessions[0]); + expect(sessions[2]).not.toBe(sessions[0]); + expect(sessions[3]).toBe(resolveOpenCodeGoTransport( + "opencode-go", goProvider(), new Headers({ "x-opencode-session": "explicit-messages-session" }), + ).headers?.["x-opencode-session"]); + expect(sessions[4]).toBe(sessions[3]); + for (const headers of outbound) { + expect(headers.get("x-opencode-session")).not.toContain("user_abcd"); + } + expect(outboundBodies.join("\n")).not.toContain("user_abcd"); + }); +});