@bitkyc08/opencodex 2.6.12 → 2.6.14-preview.20260701

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CTjsL04v.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-Cy2tzg-5.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DIBiVVC0.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.6.12",
3
+ "version": "2.6.14-preview.20260701",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -40,7 +40,7 @@ const MIN_THINKING_BUDGET = 1024;
40
40
  const OUTPUT_HEADROOM = 8192;
41
41
  /** Minimum visible-output room kept below `max_tokens` (so `max_tokens > budget_tokens` always holds). */
42
42
  const OUTPUT_FLOOR = 4096;
43
- const COMPAT_TOOL_PREFIX = "ocx_";
43
+ const COMPAT_TOOL_PREFIX = "cx_";
44
44
 
45
45
  /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
46
46
  function reasoningBudget(effort: string): number {
@@ -172,7 +172,7 @@ function messagesToAnthropicFormat(
172
172
  resultBlocks.push({
173
173
  type: "tool_result",
174
174
  tool_use_id: id,
175
- content: "[opencodex: missing tool_result for this tool_use in Codex history]",
175
+ content: "[missing tool_result for this tool_use in history]",
176
176
  is_error: true,
177
177
  });
178
178
  }
@@ -240,7 +240,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
240
240
  if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
241
241
  if (parsed.options.stopSequences) body.stop_sequences = parsed.options.stopSequences;
242
242
 
243
- if (parsed.options.reasoning) {
243
+ // `reasoning` is a Codex effort string; "none" is the disable sentinel (see parser.ts
244
+ // REASONING_EFFORTS). A bare truthy check would treat "none" as truthy and wrongly enable
245
+ // extended thinking (and strip temperature/top_p), so gate on a real, non-disable effort.
246
+ if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") {
244
247
  // Anthropic requires max_tokens > thinking.budget_tokens (max_tokens caps thinking +
245
248
  // visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
246
249
  // 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens
@@ -270,6 +273,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
270
273
  const headers: Record<string, string> = {
271
274
  "Content-Type": "application/json",
272
275
  "anthropic-version": "2023-06-01",
276
+ "Accept": parsed.stream ? "text/event-stream" : "application/json",
277
+ "User-Agent": "@anthropic-ai/sdk/0.74.0",
273
278
  };
274
279
  if (isOAuth) {
275
280
  if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
@@ -20,6 +20,10 @@ export const CLAUDE_CODE_HEADERS: Record<string, string> = {
20
20
  "X-Stainless-Runtime": "node",
21
21
  "X-Stainless-Lang": "js",
22
22
  "X-Stainless-Timeout": "600",
23
+ "X-Stainless-Arch": process.arch,
24
+ "X-Stainless-OS": process.platform,
25
+ "X-Stainless-Package-Version": "0.74.0",
26
+ "X-Stainless-Runtime-Version": process.version.slice(1),
23
27
  };
24
28
 
25
29
  /**
@@ -19,7 +19,6 @@ import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google
19
19
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
20
20
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
21
21
  import { neutralizeIdentity } from "./identity";
22
- import { ANTIGRAVITY_GOOG_API_CLIENT_UA } from "./client-fingerprint";
23
22
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
24
23
 
25
24
  // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
@@ -264,8 +263,6 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
264
263
  request,
265
264
  };
266
265
  headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA;
267
- // The Antigravity client library reports a secondary Google API client UA alongside the CLI UA.
268
- headers["x-goog-api-client"] = ANTIGRAVITY_GOOG_API_CLIENT_UA;
269
266
  if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
270
267
  return { url, method: "POST", headers, body: JSON.stringify(envelope) };
271
268
  }
@@ -8,9 +8,9 @@ let cachedFp: string | undefined;
8
8
  export function fingerprint(): string {
9
9
  if (cachedFp) return cachedFp;
10
10
  try {
11
- cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro-ocx`).digest("hex");
11
+ cachedFp = createHash("sha256").update(`${hostname()}-${userInfo().username}-kiro`).digest("hex");
12
12
  } catch {
13
- cachedFp = createHash("sha256").update("default-kiro-ocx").digest("hex");
13
+ cachedFp = createHash("sha256").update("default-kiro").digest("hex");
14
14
  }
15
15
  return cachedFp;
16
16
  }
@@ -33,7 +33,7 @@ import { neutralizeIdentity } from "./identity";
33
33
  const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
34
34
  const SDK_VERSION = "1.0.27";
35
35
  const NODE_VERSION = "22.21.1";
36
- const KIRO_IDE_VERSION = "1.2.0";
36
+ const KIRO_IDE_VERSION = "1.0.0";
37
37
 
38
38
  // Payload construction (conversationState)
39
39
  interface KiroToolUse {
@@ -223,92 +223,127 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
223
223
  let currentToolCallId = "";
224
224
  let currentToolCallName = "";
225
225
  let pendingUsage: OcxUsage | undefined;
226
+ // Track terminal signals so a socket EOF without any terminator can fail closed instead of
227
+ // being reported as a clean completion (silent truncation). A graceful close is either an
228
+ // explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
229
+ // OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
230
+ let sawFinish = false;
226
231
 
227
- try {
228
- while (true) {
229
- const { done, value } = await reader.read();
230
- if (done) break;
231
- buffer += decoder.decode(value, { stream: true });
232
-
233
- const lines = buffer.split("\n");
234
- buffer = lines.pop() ?? "";
232
+ // Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
233
+ // a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
234
+ // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
235
+ // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
236
+ const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
237
+ if (!line.startsWith("data: ")) return "continue";
238
+ const payload = line.slice(6).trim();
239
+ if (payload === "[DONE]") {
240
+ if (currentToolCallId) {
241
+ yield { type: "tool_call_end" };
242
+ currentToolCallId = "";
243
+ }
244
+ yield { type: "done", usage: pendingUsage };
245
+ return "terminate";
246
+ }
235
247
 
236
- for (const line of lines) {
237
- if (!line.startsWith("data: ")) continue;
238
- const payload = line.slice(6).trim();
239
- if (payload === "[DONE]") {
240
- if (currentToolCallId) {
241
- yield { type: "tool_call_end" };
242
- currentToolCallId = "";
243
- }
244
- yield { type: "done", usage: pendingUsage };
245
- return;
246
- }
248
+ let chunk: Record<string, unknown>;
249
+ try {
250
+ chunk = JSON.parse(payload) as Record<string, unknown>;
251
+ } catch {
252
+ debugDroppedFrame("openai-chat", payload);
253
+ return "continue";
254
+ }
247
255
 
248
- let chunk: Record<string, unknown>;
249
- try {
250
- chunk = JSON.parse(payload) as Record<string, unknown>;
251
- } catch {
252
- debugDroppedFrame("openai-chat", payload);
253
- continue;
254
- }
256
+ // A 200/OK chat-completions stream may carry an inline provider error envelope
257
+ // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
258
+ // classified response.failed (bridge case "error") never a truncated completion.
259
+ if (chunk.error) {
260
+ const err = chunk.error as { message?: string } | undefined;
261
+ if (currentToolCallId) yield { type: "tool_call_end" };
262
+ yield { type: "error", message: err?.message ?? "upstream error" };
263
+ return "terminate";
264
+ }
255
265
 
256
- // A 200/OK chat-completions stream may carry an inline provider error envelope
257
- // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
258
- // classified response.failed (bridge case "error") never a truncated completion.
259
- if (chunk.error) {
260
- const err = chunk.error as { message?: string } | undefined;
261
- if (currentToolCallId) yield { type: "tool_call_end" };
262
- yield { type: "error", message: err?.message ?? "upstream error" };
263
- return;
264
- }
266
+ if (chunk.usage) {
267
+ // Record usage but keep parsing: some providers send usage and the final content
268
+ // delta in the SAME chunk; a bail here would drop that content. The choices
269
+ // guard below no-ops a usage-only chunk.
270
+ pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
271
+ }
265
272
 
266
- if (chunk.usage) {
267
- // Record usage but keep parsing: some providers send usage and the final content
268
- // delta in the SAME chunk; a `continue` here would drop that content. The choices
269
- // guard below no-ops a usage-only chunk.
270
- pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
271
- }
273
+ const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
274
+ if (!choices || choices.length === 0) return "continue";
275
+ // Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set,
276
+ // no delta) is a graceful close and must mark sawFinish even though we skip it below.
277
+ if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) {
278
+ sawFinish = true;
279
+ }
280
+ const delta = choices[0].delta;
281
+ if (delta) {
282
+ if (typeof delta.content === "string" && delta.content.length > 0) {
283
+ yield { type: "text_delta", text: delta.content };
284
+ }
272
285
 
273
- const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
274
- if (!choices || choices.length === 0) continue;
275
- const delta = choices[0].delta;
276
- if (!delta) continue;
286
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
287
+ yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
288
+ }
277
289
 
278
- if (typeof delta.content === "string" && delta.content.length > 0) {
279
- yield { type: "text_delta", text: delta.content };
290
+ const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
291
+ if (toolCalls) {
292
+ for (const tc of toolCalls) {
293
+ if (tc.id && tc.id !== currentToolCallId) {
294
+ if (currentToolCallId) yield { type: "tool_call_end" };
295
+ currentToolCallId = tc.id;
296
+ currentToolCallName = tc.function?.name ?? "";
297
+ yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
298
+ }
299
+ if (tc.function?.arguments) {
300
+ yield { type: "tool_call_delta", arguments: tc.function.arguments };
301
+ }
280
302
  }
303
+ }
304
+ }
281
305
 
282
- if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
283
- yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
284
- }
306
+ if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
307
+ yield { type: "tool_call_end" };
308
+ currentToolCallId = "";
309
+ }
310
+ return "continue";
311
+ };
285
312
 
286
- const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
287
- if (toolCalls) {
288
- for (const tc of toolCalls) {
289
- if (tc.id && tc.id !== currentToolCallId) {
290
- if (currentToolCallId) yield { type: "tool_call_end" };
291
- currentToolCallId = tc.id;
292
- currentToolCallName = tc.function?.name ?? "";
293
- yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
294
- }
295
- if (tc.function?.arguments) {
296
- yield { type: "tool_call_delta", arguments: tc.function.arguments };
297
- }
298
- }
299
- }
313
+ try {
314
+ while (true) {
315
+ const { done, value } = await reader.read();
316
+ if (done) break;
317
+ buffer += decoder.decode(value, { stream: true });
300
318
 
301
- if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
302
- yield { type: "tool_call_end" };
303
- currentToolCallId = "";
304
- }
319
+ const lines = buffer.split("\n");
320
+ buffer = lines.pop() ?? "";
321
+
322
+ for (const line of lines) {
323
+ if ((yield* handleDataLine(line)) === "terminate") return;
305
324
  }
306
325
  }
307
326
 
327
+ // Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
328
+ // and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
329
+ // the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
330
+ // content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
331
+ // complete stream loses its last frame and may be falsely failed below.
332
+ if (buffer.length > 0) {
333
+ if ((yield* handleDataLine(buffer)) === "terminate") return;
334
+ }
308
335
  if (currentToolCallId) {
309
336
  yield { type: "tool_call_end" };
310
337
  }
311
- // EOF without a [DONE] sentinel: still surface any usage accumulated mid-stream.
338
+ // Reader EOF. A graceful close shows at least one terminal signal: `[DONE]` (returns above),
339
+ // a non-null finish_reason (sawFinish), or a trailing usage chunk (providers emit usage only
340
+ // at end-of-generation). If NONE of those were seen, the stream was cut mid-flight — fail
341
+ // closed so the bridge emits a classified response.failed rather than a silent truncation.
342
+ if (!sawFinish && pendingUsage === undefined) {
343
+ yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
344
+ return;
345
+ }
346
+ // Graceful close that omitted [DONE] but delivered finish_reason and/or final usage.
312
347
  yield { type: "done", usage: pendingUsage };
313
348
  } finally {
314
349
  reader.releaseLock();
@@ -42,6 +42,41 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
42
42
  return changed ? { ...raw, input } : body;
43
43
  }
44
44
 
45
+ /**
46
+ * Hosted (OpenAI-executed) tool types that specific native slugs reject at request time. Codex
47
+ * attaches these for app skills (e.g. `image_generation` for imagegen) regardless of the target
48
+ * model, and the passthrough path forwards the raw body untouched — so a slug that doesn't support
49
+ * the tool 400s (`Tool 'image_generation' is not supported with gpt-5.3-codex-spark.`). Each entry
50
+ * maps a model-slug matcher to the hosted tool types that must be stripped before forwarding.
51
+ * Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
52
+ */
53
+ const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
54
+ { match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
55
+ ];
56
+
57
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
58
+ return !!v && typeof v === "object" && !Array.isArray(v);
59
+ }
60
+
61
+ /**
62
+ * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
63
+ * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
64
+ * matches, keeping the common path allocation-free.
65
+ */
66
+ function stripUnsupportedHostedTools(body: unknown): unknown {
67
+ if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
68
+ const model = typeof body.model === "string" ? body.model : "";
69
+ const unsupported = UNSUPPORTED_HOSTED_TOOLS.filter(e => e.match(model));
70
+ if (unsupported.length === 0) return body;
71
+
72
+ const tools = body.tools.filter(t => {
73
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
74
+ if (!type) return true;
75
+ return !unsupported.some(e => e.tools.has(type));
76
+ });
77
+ return tools.length === body.tools.length ? body : { ...body, tools };
78
+ }
79
+
45
80
  export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
46
81
  return {
47
82
  name: "openai-responses",
@@ -82,7 +117,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
82
117
  url,
83
118
  method: "POST",
84
119
  headers,
85
- body: JSON.stringify(sanitizeReasoningInputContent(parsed._rawBody)),
120
+ body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(parsed._rawBody))),
86
121
  };
87
122
  },
88
123
 
package/src/cli-help.ts CHANGED
@@ -44,6 +44,7 @@ const helpEntries: Record<string, HelpEntry> = {
44
44
  sync: { usage: "ocx sync", summary: "Fetch provider models and inject them into Codex config." },
45
45
  "sync-cache": { usage: "ocx sync-cache", summary: "Refresh Codex's model cache from the active catalog." },
46
46
  status: { usage: "ocx status", summary: "Check proxy server status." },
47
+ doctor: { usage: "ocx doctor", summary: "Diagnose environment/network issues (paths, WSL /mnt, proxy env, ChatGPT reachability)." },
47
48
  login: { usage: "ocx login <provider>", summary: "OAuth or API-key login for a provider." },
48
49
  logout: { usage: "ocx logout <provider>", summary: "Remove a stored provider login." },
49
50
  gui: { usage: "ocx gui", summary: "Open the opencodex dashboard." },
@@ -80,6 +81,7 @@ Usage:
80
81
  ocx sync Fetch models from providers and inject into Codex config
81
82
  ocx sync-cache Refresh Codex's model cache from the active catalog
82
83
  ocx status Check proxy server status
84
+ ocx doctor Diagnose environment/network issues (WSL, proxy, ChatGPT reachability)
83
85
  ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
84
86
  ocx logout <provider> Remove a stored OAuth login
85
87
  ocx gui Open the opencodex dashboard
package/src/cli.ts CHANGED
@@ -396,6 +396,11 @@ switch (command) {
396
396
  case "status":
397
397
  await handleStatus();
398
398
  break;
399
+ case "doctor": {
400
+ const { runDoctor } = await import("./doctor");
401
+ await runDoctor();
402
+ break;
403
+ }
399
404
  case "ensure":
400
405
  await handleEnsure();
401
406
  break;
@@ -262,6 +262,57 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
262
262
  }
263
263
  }
264
264
 
265
+ let primeInFlight: Promise<void> | null = null;
266
+
267
+ /**
268
+ * Best-effort prime of pool-account (and main) quota so the rotation engine has
269
+ * real usage scores instead of leaving every account at the unknown sentinel.
270
+ *
271
+ * Quota is otherwise populated only from live upstream headers (an idle pool
272
+ * account never serves traffic, so it never gets scored) or from the dashboard
273
+ * WHAM fetch (a CLI-only user never opens it). Without priming, every account
274
+ * stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
275
+ * and lazily before routing when the active account is unknown.
276
+ *
277
+ * Single-flight: concurrent callers share one pass instead of stampeding N WHAM
278
+ * fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
279
+ * cost, so the worst case is one WHAM call per account per TTL window. Failures
280
+ * are swallowed: a blocked WSL network must never crash startup or a request.
281
+ */
282
+ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
283
+ if (primeInFlight) return primeInFlight;
284
+ primeInFlight = (async () => {
285
+ const runtimeConfig = getRuntimeConfig(config);
286
+ const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
287
+ const stale = pool.filter(a => {
288
+ const q = getAccountQuota(a.id);
289
+ return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
290
+ });
291
+ const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
292
+ try {
293
+ await Promise.allSettled([
294
+ primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
295
+ mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
296
+ if (!getCodexAccountCredential(a.id)) return;
297
+ await fetchPoolAccountQuota(a.id, false, a.plan);
298
+ }),
299
+ ]);
300
+ } catch {
301
+ // Priming is best-effort; never propagate.
302
+ }
303
+ if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
304
+ console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
305
+ }
306
+ })().finally(() => { primeInFlight = null; });
307
+ return primeInFlight;
308
+ }
309
+
310
+ /** Test-only: drop any in-flight prime pass so a leaked single-flight promise
311
+ * from another suite cannot coalesce into the next prime. */
312
+ export function clearCodexQuotaPrimeState(): void {
313
+ primeInFlight = null;
314
+ }
315
+
265
316
  export async function handleCodexAuthAPI(
266
317
  req: Request,
267
318
  url: URL,
@@ -37,18 +37,14 @@ function isWorkspacePlan(plan: string | undefined | null): boolean {
37
37
  return !!plan && /team|business|enterprise|workspace|edu/i.test(plan);
38
38
  }
39
39
 
40
- // Personal and workspace subscriptions are separate duplicate buckets.
41
- // Within each bucket, keep the original ChatGPT account id + email collision guard.
40
+ // Main login and managed pool accounts are separate duplicate buckets.
41
+ // Inside the pool, personal and workspace subscriptions are also separate buckets.
42
+ // Within each pool bucket, keep the original ChatGPT account id + email collision guard.
42
43
  export function checkAccountIdCollision(
43
44
  chatgptAccountId: string,
44
45
  email?: string | null,
45
46
  plan?: string | null,
46
47
  ): { collision: true; reason: string } | { collision: false } {
47
- const mainAccountId = getMainChatgptAccountId();
48
- if (mainAccountId && mainAccountId === chatgptAccountId) {
49
- return { collision: true, reason: "Account is already used by the main Codex login." };
50
- }
51
-
52
48
  const candidateEmail = normalizedEmail(email);
53
49
  const candidateWorkspace = isWorkspacePlan(plan);
54
50
  for (const account of loadConfig().codexAccounts ?? []) {
@@ -8,6 +8,7 @@ import { markAccountNeedsReauth } from "./codex-account-runtime-state";
8
8
  import { isCodexAccountUsable } from "./codex-account-usability";
9
9
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./codex-main-account";
10
10
  import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
11
+ import { getAccountQuota } from "./codex-quota";
11
12
  import type { OcxConfig, OcxProviderConfig } from "./types";
12
13
  import { FORWARD_HEADERS } from "./adapters/openai-responses";
13
14
 
@@ -76,6 +77,16 @@ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfi
76
77
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
77
78
  const accountId = resolution.status === "selected" ? resolution.accountId : null;
78
79
  if (!accountId) return { kind: "main", accountId: null };
80
+ // Lazy prime: if the selected account has no quota yet, the pool is likely
81
+ // unprimed (dashboard never opened, or startup prime was blocked). Kick a
82
+ // best-effort prime so the NEXT routing decision has real scores. This never
83
+ // blocks the current request, and the helper's single-flight guard collapses
84
+ // repeated triggers into one pass.
85
+ if (!getAccountQuota(accountId)) {
86
+ import("./codex-auth-api")
87
+ .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
88
+ .catch(() => {});
89
+ }
79
90
  const cooldownUntil = getCodexAccountCooldownUntil(accountId);
80
91
  if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
81
92
 
@@ -12,6 +12,9 @@ type ThreadAffinityEntry = {
12
12
  generation: number;
13
13
  createdAt: number;
14
14
  lastUsedAt: number;
15
+ // Last time the bound account's quota threshold was re-evaluated for this
16
+ // thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
17
+ lastReevalAt: number;
15
18
  };
16
19
 
17
20
  export type CodexThreadResolution =
@@ -32,6 +35,9 @@ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
32
35
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
33
36
  export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
34
37
  export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
38
+ // Min interval between quota threshold re-evaluations for a single bound thread.
39
+ // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
40
+ export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
35
41
 
36
42
  const upstreamHealth = new Map<string, CodexUpstreamHealth>();
37
43
 
@@ -200,6 +206,7 @@ function bindThreadAffinity(threadId: string, accountId: string, now: number): v
200
206
  generation: record.generation,
201
207
  createdAt: previous?.createdAt ?? now,
202
208
  lastUsedAt: now,
209
+ lastReevalAt: now,
203
210
  });
204
211
  pruneLruThreadAffinities();
205
212
  }
@@ -260,6 +267,20 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
260
267
  saveConfig(config);
261
268
  }
262
269
 
270
+ function isUnknownUsage(usage: number): boolean {
271
+ return usage >= CODEX_UNKNOWN_USAGE_SCORE;
272
+ }
273
+
274
+ // Round-robin among eligible unknown-quota candidates. `getEligiblePoolAccounts`
275
+ // already returns a deterministic order (config order, main unshifted first) and
276
+ // excludes the active id, so taking the first eligible unknown is a stable rotation
277
+ // without any new per-account state.
278
+ function pickNextUnknownAccount(config: OcxConfig, active: string, now: number): string | null {
279
+ const eligible = getEligiblePoolAccounts(config, active, now)
280
+ .filter(id => isUnknownUsage(computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id))));
281
+ return eligible.length > 0 ? eligible[0]! : null;
282
+ }
283
+
263
284
  function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
264
285
  const threshold = config.autoSwitchThreshold ?? 80;
265
286
  if (threshold <= 0) return active;
@@ -267,8 +288,27 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
267
288
  const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
268
289
  if (activeUsage < threshold) return active;
269
290
  const best = pickLowerUsageAccount(config, active, activeUsage, now);
270
- if (best !== active) setActiveCodexAccount(config, best);
271
- return best;
291
+ if (best !== active) {
292
+ setActiveCodexAccount(config, best);
293
+ return best;
294
+ }
295
+
296
+ // Deadlock guard: active is over threshold but no candidate scored strictly
297
+ // lower. When the active itself is unknown, every candidate is likely unknown
298
+ // too (100 < 100 never fires), which pins the pool to one account whose real
299
+ // usage we cannot see (e.g. quota never primed on WSL). Rotate to the next
300
+ // eligible unknown so rotation is not stuck; known-but-saturated accounts are
301
+ // intentionally left alone so a genuinely hot pool stays visible.
302
+ if (isUnknownUsage(activeUsage)) {
303
+ const next = pickNextUnknownAccount(config, active, now);
304
+ if (next) {
305
+ console.warn(`[codex-routing] quota unknown for active "${active}"; rotating to "${next}" (all candidates unknown, threshold=${threshold})`);
306
+ setActiveCodexAccount(config, next);
307
+ return next;
308
+ }
309
+ console.warn(`[codex-routing] quota unknown for active "${active}" and no eligible rotation target; staying put`);
310
+ }
311
+ return active;
272
312
  }
273
313
 
274
314
  function shouldFailover(config: OcxConfig, accountId: string, now: number): boolean {
@@ -314,6 +354,28 @@ export function resolveCodexAccountForThreadDetailed(
314
354
  && isCodexAccountSelectable(config, entry.accountId, now)
315
355
  ) {
316
356
  entry.lastUsedAt = now;
357
+ // Periodic quota re-eval: a long-lived bound thread must still switch when
358
+ // it crosses autoSwitchThreshold and a strictly-cooler account exists.
359
+ // Without this the reuse branch returns before applyQuotaAutoSwitch and the
360
+ // thread stays pinned for the full idle TTL (the WSL "never switches" report).
361
+ if (now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
362
+ entry.lastReevalAt = now;
363
+ const threshold = config.autoSwitchThreshold ?? 80;
364
+ if (threshold > 0) {
365
+ const usage = computeCodexUsageScore(
366
+ getAccountQuota(entry.accountId),
367
+ getPoolAccountPlan(config, entry.accountId),
368
+ );
369
+ if (usage >= threshold) {
370
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
371
+ if (best !== entry.accountId) {
372
+ setActiveCodexAccount(config, best);
373
+ bindThreadAffinity(threadId, best, now); // rebinds + resets clocks
374
+ return { status: "selected", accountId: best };
375
+ }
376
+ }
377
+ }
378
+ }
317
379
  return { status: "selected", accountId: entry.accountId };
318
380
  }
319
381
  threadAccountMap.delete(threadId);