Skip to content

Commit df71df9

Browse files
authored
Merge pull request #79 from pavelhov/development
test(catalog): make live native integration test reliable on Windows
2 parents 0f5c103 + cefcd74 commit df71df9

13 files changed

Lines changed: 359 additions & 45 deletions

‎docs-site/src/content/docs/guides/providers.md‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,23 @@ and 30-day observations are local usage estimates, not live remaining quota or b
296296
authoritative limit event is shown only after the upstream reports a concrete limit event (and, when
297297
provided, its reset).
298298

299-
The provider pins the current Zen Go lineup (25 model ids) as its static catalog, with live
300-
`/v1/models` discovery authoritative on the canonical host — ids outside the trusted set are
301-
quarantined rather than routed. Transports follow the official endpoint table: Qwen and MiniMax
302-
models go over Anthropic Messages, `gpt-5.6-luna` and `grok-4.5` over OpenAI Responses, and the
299+
The provider pins 28 verified Zen Go model ids, including `deepseek-v4.1-flash`,
300+
`glm-5.3-flash`, and `qwen3.8-flash`, as
301+
its static catalog. Live `/v1/models` discovery is authoritative on the canonical host; ids
302+
outside the trusted set are quarantined rather than routed. Transports follow the official
303+
endpoint table: Qwen and MiniMax models go over Anthropic Messages, `gpt-5.6-luna` and `grok-4.5`
304+
over OpenAI Responses, and the
303305
remaining models over OpenAI Chat Completions. These trust facts attach only to the canonical
304306
`https://opencode.ai/zen/go/v1` destination; a same-named custom provider keeps its own behavior.
305307

308+
OpenCode Go requires a session identifier for each conversation. CodexCommander derives an opaque
309+
`x-opencode-session` from the Codex task or session header and keeps it stable through that
310+
conversation, including subagent turns. Claude Code turns use their per-session metadata when available.
311+
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
312+
session, and a configured provider header takes precedence. This behavior applies only to the
313+
canonical OpenCode Go destination. The proxy identifies itself with a CodexCommander user agent
314+
unless the provider configuration sets one explicitly.
315+
306316
The built-in preset is key-based, so Add Provider groups it under **Paid**, not account-login
307317
providers, and CodexCommander does not offer an OpenCode Go OAuth flow. It is also separate from both the
308318
**OpenCode** client under Client Apps and the no-key **OpenCode Free** provider. Add Provider search

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexcommander",
3-
"version": "0.1.18",
3+
"version": "0.1.19",
44
"private": true,
55
"description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
66
"type": "module",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { createHash, randomUUID } from "node:crypto";
2+
import { resolveCodexTaskIdentity } from "../codex/task-identity";
3+
import type { CodexCommanderProviderConfig } from "../types";
4+
import { providerMatchesRegistryTransport } from "./registry";
5+
6+
const SESSION_HEADER = "x-opencode-session";
7+
const USER_AGENT_HEADER = "user-agent";
8+
9+
function hasHeader(headers: Record<string, string> | undefined, name: string): boolean {
10+
return Object.keys(headers ?? {}).some(key => key.toLowerCase() === name);
11+
}
12+
13+
function validSession(value: string | null): string | undefined {
14+
return value && value.length <= 512 && !/[\x00-\x20\x7f,]/.test(value) ? value : undefined;
15+
}
16+
17+
/** Give OpenCode Go a stable, opaque lane for each Codex conversation. */
18+
export function resolveOpenCodeGoTransport(
19+
providerName: string,
20+
provider: CodexCommanderProviderConfig,
21+
headers: Headers,
22+
clientMetadata?: unknown,
23+
): CodexCommanderProviderConfig {
24+
// The router may already have pinned this model to Go's Anthropic or Responses
25+
// wire. Verify the canonical key destination against the registry's base adapter,
26+
// while accepting only Go's three documented wire adapters.
27+
if (providerName !== "opencode-go"
28+
|| !["openai-chat", "anthropic", "openai-responses"].includes(provider.adapter)
29+
|| !providerMatchesRegistryTransport(providerName, { ...provider, adapter: "openai-chat" })) return provider;
30+
const hasSession = hasHeader(provider.headers, SESSION_HEADER);
31+
const hasUserAgent = hasHeader(provider.headers, USER_AGENT_HEADER);
32+
if (hasSession && hasUserAgent) return provider;
33+
34+
const outboundHeaders = { ...provider.headers };
35+
if (!hasSession) {
36+
const lane = resolveCodexTaskIdentity(headers, clientMetadata).taskId
37+
?? validSession(headers.get(SESSION_HEADER))
38+
?? randomUUID();
39+
const session = createHash("sha256")
40+
.update("codexcommander/opencode-go/session/v1\0")
41+
.update(lane)
42+
.digest("hex")
43+
.slice(0, 32);
44+
outboundHeaders[SESSION_HEADER] = `ccx_${session}`;
45+
}
46+
if (!hasUserAgent) outboundHeaders["User-Agent"] = "CodexCommander";
47+
return {
48+
...provider,
49+
headers: outboundHeaders,
50+
};
51+
}

‎src/providers/registry.ts‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,9 @@ const THINKING_BUDGET_MODELS = [
359359
];
360360
const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max"];
361361
/**
362-
* Pinned last-known-good OpenCode Go lineup (25 ids): the exact id set advertised by
363-
* `GET https://opencode.ai/zen/go/v1/models`, verified 2026-08-05. That endpoint is
362+
* Pinned OpenCode Go lineup (28 ids): the 25-id snapshot from 2026-08-05 plus
363+
* DeepSeek V4.1 Flash, GLM-5.3 Flash, and Qwen3.8 Flash, verified 2026-09-27.
364+
* `GET https://opencode.ai/zen/go/v1/models` is
364365
* existence-only — it returns ids without context/output/pricing metadata — so this list is
365366
* the catalog seed, and the registry-only discovery filter below admits exactly these ids:
366367
* 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
373374
* Transport split per the official endpoint table (https://opencode.ai/docs/go/#endpoints):
374375
* Qwen and MiniMax rows serve Anthropic Messages (`/zen/go/v1/messages`), GPT-5.6 Luna and
375376
* Grok 4.5 serve OpenAI Responses (`/zen/go/v1/responses`), and the remaining rows serve
376-
* OpenAI Chat Completions (`/zen/go/v1/chat/completions`). The Anthropic subset is a hard
377+
* OpenAI Chat Completions (`/zen/go/v1/chat/completions`). DeepSeek V4.1 Flash is included
378+
* on that Chat wire. The Anthropic subset is a hard
377379
* wire pin owned by types.ts (OPENCODE_GO_ANTHROPIC_WIRE_MODEL_IDS); the two OpenAI-shaped
378380
* wires are registry `modelWireDefaults` on the entry below.
379381
*/
380382
const OPENCODE_GO_MODELS = [
381383
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
382384
"kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
383-
"glm-5.2", "glm-5.1", "glm-5",
384-
"deepseek-v4-pro", "deepseek-v4-flash",
385-
"qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus",
385+
"glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5",
386+
"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4.1-flash",
387+
"qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus",
386388
"mimo-v2-pro", "mimo-v2-omni", "mimo-v2.5-pro", "mimo-v2.5",
387389
"hy3", "hy3-preview",
388390
"gpt-5.6-luna", "grok-4.5",
@@ -392,6 +394,7 @@ const OPENCODE_GO_RESPONSES_WIRE_MODELS = ["gpt-5.6-luna", "grok-4.5"];
392394
// ladder on the `xai` entry); GPT-5.6 Luna serves the OpenAI API GPT-5.6 ladder.
393395
const OPENCODE_GO_GROK45_REASONING_EFFORTS = ["low", "medium", "high"];
394396
const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
397+
const OPENCODE_GO_DEEPSEEK_THINKING_MODELS = [...DEEPSEEK_THINKING_MODELS, "deepseek-v4.1-flash"];
395398
const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
396399
/*
397400
* Zen free models that reject `image_url` upstream (#1043, and the reproducible
@@ -1159,7 +1162,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
11591162
jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…",
11601163
models: [...OPENCODE_GO_MODELS],
11611164
// Live /v1/models is the authoritative lineup; the static list above is the last-good
1162-
// fallback seed. The registry-only filter quarantines any id outside the trusted 25, and
1165+
// fallback seed. The registry-only filter quarantines any id outside the trusted set, and
11631166
// `preserveCustomDestination` keeps the whole trusted transport registry (this filter, the
11641167
// wire defaults, and every registry metadata backfill) attached to the canonical Zen Go
11651168
// 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[] = [
11771180
modelWireDefaults: {
11781181
...Object.fromEntries(OPENCODE_GO_RESPONSES_WIRE_MODELS.map(id => [id, "openai-responses"])),
11791182
},
1180-
// Zen Go context windows not covered by the generated jawcode bundle (qwen3.8-max and
1181-
// gpt-5.6-luna have no bundle row yet): official data pages
1182-
// https://opencode.ai/data/qwen/qwen3-8-max (1M) and
1183-
// https://opencode.ai/data/openai/gpt-5-6-luna (1.1M — the OpenAI API value 1,050,000).
1183+
// Zen Go context windows not covered by the generated jawcode bundle:
1184+
// https://opencode.ai/data/deepseek/deepseek-v4-1-flash,
1185+
// https://stats.opencode.ai/data/zhipu/glm-5-3-flash, and
1186+
// https://stats.opencode.ai/data/qwen/qwen3-8-flash (all 1M), plus the
1187+
// previously pinned Qwen3.8 Max and GPT-5.6 Luna values.
11841188
modelContextWindows: {
11851189
"kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW,
1190+
"deepseek-v4.1-flash": 1_000_000,
1191+
"glm-5.3-flash": 1_000_000,
11861192
"qwen3.8-max": 1_000_000,
1193+
"qwen3.8-flash": 1_000_000,
11871194
"gpt-5.6-luna": 1_050_000,
11881195
},
11891196
// qwen3.8-max (text/image/video) and gpt-5.6-luna (text/image/pdf) are multimodal upstream;
11901197
// the jawcode type can only represent text+image, so video/pdf stay source facts.
11911198
modelInputModalities: {
11921199
"kimi-k3": ["text", "image"],
1200+
"deepseek-v4.1-flash": ["text", "image"],
1201+
"glm-5.3-flash": ["text", "image"],
11931202
"qwen3.8-max": ["text", "image"],
1203+
"qwen3.8-flash": ["text", "image"],
11941204
"gpt-5.6-luna": ["text", "image"],
11951205
},
11961206
modelReasoningEfforts: {
@@ -1202,15 +1212,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
12021212
"gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS,
12031213
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
12041214
...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
1205-
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
1215+
...Object.fromEntries(OPENCODE_GO_DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
12061216
},
12071217
modelDefaultReasoningEfforts: { "kimi-k3": "max" },
12081218
// glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
12091219
// the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
12101220
modelReasoningEffortMap: {
12111221
"kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP,
12121222
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
1213-
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
1223+
...Object.fromEntries(OPENCODE_GO_DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
12141224
},
12151225
thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
12161226
thinkingBudgetModels: THINKING_BUDGET_MODELS,
@@ -1230,7 +1240,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
12301240
noPenaltyModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
12311241
autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
12321242
// Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns.
1233-
preserveReasoningContentModels: ["glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS],
1243+
preserveReasoningContentModels: ["glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...OPENCODE_GO_DEEPSEEK_THINKING_MODELS],
12341244
},
12351245
{
12361246
id: "neuralwatt",

‎src/server/chat-completions.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ async function handleChatCompletionsWithBudget(
167167
const value = req.headers.get(name);
168168
if (value) headers.set(name, value);
169169
}
170+
// Keep an explicit Go session through the Chat-to-Responses bridge. Only the
171+
// canonical Go destination turns it into an upstream header.
172+
const goSession = req.headers.get("x-opencode-session");
173+
if (goSession) headers.set("x-opencode-session", goSession);
170174
// Prefer main ChatGPT auth so OpenAI-backed sidecars remain reachable on routed turns.
171175
if (!directRoute) {
172176
// This enrichment is optional for routed/non-main providers. If native main

‎src/server/claude-messages.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { clearableDeadline, idleDeadline } from "../lib/abort";
3131
import { estimateTokens } from "../lib/token-estimate";
3232
import { NoEligiblePolicyCandidateError, routeModel } from "../router";
33+
import { providerMatchesRegistryTransport } from "../providers/registry";
3334
import { evidenceFromBody } from "../routing/request-evidence";
3435
import { resolveWireProtocolOverride } from "./adapter-resolve";
3536
import type { CodexCommanderConfig } from "../types";
@@ -706,11 +707,15 @@ async function handleClaudeMessagesWithBudget(
706707
// bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
707708
// verified live 2026-07-11). Strip them for that route; routed providers keep them.
708709
let nativeRoute = false;
710+
let openCodeGoRoute = false;
709711
try {
710712
const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
711713
// Settle the wire once so the sampling decision below reads the effective
712714
// adapter rather than the provider-wide default (#404).
713715
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
716+
openCodeGoRoute = route.providerName === "opencode-go"
717+
&& ["openai-chat", "anthropic", "openai-responses"].includes(route.provider.adapter)
718+
&& providerMatchesRegistryTransport(route.providerName, { ...route.provider, adapter: "openai-chat" });
714719
logCtx.routeDecision = route.routeDecision;
715720
if (route.provider.adapter === "openai-responses") {
716721
nativeRoute = true;
@@ -754,6 +759,13 @@ async function handleClaudeMessagesWithBudget(
754759
const value = req.headers.get(name);
755760
if (value) headers.set(name, value);
756761
}
762+
// The Messages replay uses Responses routing; carry an explicit Go lane across
763+
// that internal boundary only for the canonical Go destination. Its transport
764+
// hashes the value before sending it upstream.
765+
if (openCodeGoRoute) {
766+
const goSession = req.headers.get("x-opencode-session");
767+
if (goSession) headers.set("x-opencode-session", goSession);
768+
}
757769
// Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable;
758770
// native replays have no caller ChatGPT credential. This enrichment is optional:
759771
// auth-context later rejects a real physical-main selection, while routed/pool
@@ -766,14 +778,16 @@ async function handleClaudeMessagesWithBudget(
766778
headers.set("chatgpt-account-id", token.chatgptAccountId);
767779
}
768780
}
769-
if (nativeRoute) {
781+
if (nativeRoute || (openCodeGoRoute && !headers.has("x-opencode-session"))) {
770782
// ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
771783
// clients always send their session uuid; implementation contract follow-up: body-level
772784
// prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
773785
// the header, so synthesize a stable per-session uuid from the same cache key —
774786
// but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
775787
// key is shared across Desktop conversations, and a shared session_id's backend
776788
// semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
789+
// Go also needs a per-session identity on routed Claude Code turns. Never
790+
// derive it from the shared system fallback or override an explicit Go lane.
777791
if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") {
778792
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
779793
}

‎src/server/responses/core.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import {
126126
import { noteProviderCredentialVerified } from "../../providers/credential-verification";
127127
import { shouldAttemptImageTierRetry } from "../image-retry";
128128
import { resolveProviderTransport } from "../../providers/xai-transport";
129+
import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport";
129130
import type { WsData } from "../ws-bridge";
130131
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn, type ActiveTurnLease } from "../lifecycle";
131132
import { redactSecretString } from "../../lib/redact";
@@ -1789,6 +1790,12 @@ async function handleResponsesInner(
17891790
parsed.options.promptCacheKey,
17901791
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
17911792
);
1793+
route.provider = resolveOpenCodeGoTransport(
1794+
route.providerName,
1795+
route.provider,
1796+
req.headers,
1797+
nativeClientMetadata(parsed._rawBody),
1798+
);
17921799
requestDispatchContext(logCtx, options.abortSignal ?? req.signal, undefined, config.providers[route.providerName]);
17931800
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
17941801
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);

‎src/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1355,7 +1355,7 @@ export const MODEL_ADAPTER_OVERRIDE_ALLOWED: ReadonlySet<string> = new Set([
13551355
*/
13561356
export const OPENCODE_GO_ANTHROPIC_WIRE_MODEL_IDS = [
13571357
"minimax-m2.5", "minimax-m2.7", "minimax-m3",
1358-
"qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max",
1358+
"qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", "qwen3.8-max", "qwen3.8-flash",
13591359
] as const;
13601360

13611361
const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {

0 commit comments

Comments
 (0)