@bitkyc08/opencodex 2.7.39 → 2.7.40-preview.20260725

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.
Files changed (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-BxQ8N_K5.js +52 -0
  3. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
- import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
4
4
  import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
5
- import { decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
5
+ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../responses/compaction";
6
+ import { decodeServerSentEvents } from "../lib/sse-decoder";
7
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
6
8
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
7
9
  import { modelRecordValue } from "../reasoning-effort";
8
10
 
@@ -518,6 +520,81 @@ function stripUnsupportedHostedTools(body: unknown): unknown {
518
520
  return tools.length === body.tools.length ? body : { ...body, tools };
519
521
  }
520
522
 
523
+ /** Replace every `input_image` part under a routed-compaction body with a short marker. */
524
+ function stripInputImagesDeep(value: unknown): unknown {
525
+ if (Array.isArray(value)) return value.map(stripInputImagesDeep);
526
+ if (!isPlainObject(value)) return value;
527
+ if (value.type === "input_image") {
528
+ return { type: "input_text", text: "[image omitted for compaction]" };
529
+ }
530
+ const out: Record<string, unknown> = {};
531
+ for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry);
532
+ return out;
533
+ }
534
+
535
+ /**
536
+ * Rewrite a compaction turn for an upstream that does not speak Codex's private
537
+ * `compaction_trigger` item: drop the trigger and the whole tool surface, and ask
538
+ * for the handoff summary in plain terms instead (#422).
539
+ *
540
+ * The adapter builds from `parsed._rawBody`, so the summarizer prompt that
541
+ * handleResponses() pushed onto `parsed.context` never reaches the wire — it has to
542
+ * be applied here. Images go too: a summary needs no pixels, and a text-only
543
+ * gateway would reject them.
544
+ */
545
+ function buildRoutedCompactionBody(body: unknown): unknown {
546
+ if (!isPlainObject(body)) return body;
547
+ const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, ...rest } = body;
548
+ const input = Array.isArray(body.input) ? body.input : [];
549
+ const kept = input.filter(item => !isPlainObject(item)
550
+ // `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
551
+ // leaving it in would break the no-tools invariant even with `tools` removed.
552
+ || (item.type !== "compaction_trigger" && item.type !== "additional_tools"));
553
+ return {
554
+ ...rest,
555
+ input: [
556
+ ...(stripInputImagesDeep(kept) as unknown[]),
557
+ { type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] },
558
+ ],
559
+ };
560
+ }
561
+
562
+ /** Read the Responses `usage` block, if the gateway sent one. */
563
+ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
564
+ if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
565
+ const usage = payload.usage;
566
+ const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
567
+ const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
568
+ if (inputTokens === 0 && outputTokens === 0) return undefined;
569
+ return {
570
+ inputTokens,
571
+ outputTokens,
572
+ ...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
573
+ };
574
+ }
575
+
576
+ function responsesPayloadText(response: unknown): string {
577
+ if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
578
+ return response.output
579
+ .filter(item => isPlainObject(item) && item.type === "message")
580
+ .flatMap(item => (Array.isArray((item as Record<string, unknown>).content)
581
+ ? (item as { content: unknown[] }).content
582
+ : []))
583
+ .filter(part => isPlainObject(part) && part.type === "output_text")
584
+ .map(part => String((part as { text?: unknown }).text ?? ""))
585
+ .join("");
586
+ }
587
+
588
+ function responsesErrorMessage(payload: unknown): string {
589
+ if (!isPlainObject(payload)) return "upstream compaction failed";
590
+ const err = payload.error;
591
+ if (typeof err === "string") return err;
592
+ if (isPlainObject(err) && typeof err.message === "string") return err.message;
593
+ const incomplete = payload.incomplete_details;
594
+ if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason;
595
+ return "upstream compaction failed";
596
+ }
597
+
521
598
  export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
522
599
  return {
523
600
  name: "openai-responses",
@@ -573,6 +650,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
573
650
  outBody = repairOversizedReplayCallIds(outBody);
574
651
  }
575
652
  outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
653
+ // Same predicate as the routedCompaction gate in handleResponses(): an
654
+ // authMode check would let a noncanonical custom forward provider skip this
655
+ // rewrite while the server still routes it as a summarizer turn (#422).
656
+ if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
657
+ outBody = buildRoutedCompactionBody(outBody);
658
+ }
576
659
  return {
577
660
  url,
578
661
  method: "POST",
@@ -585,8 +668,71 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
585
668
  };
586
669
  },
587
670
 
588
- async *parseStream(): AsyncGenerator<AdapterEvent> {
589
- yield { type: "error", message: "passthrough adapter should not parse stream" };
671
+ // The passthrough normally relays the upstream stream verbatim and never parses.
672
+ // The exception is a routed compaction turn: the server drives this adapter like
673
+ // an ordinary one so the bridge can build the single compaction item (#422).
674
+ async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
675
+ if (!response.body) {
676
+ yield { type: "error", message: "passthrough adapter received no response body" };
677
+ return;
678
+ }
679
+ let deltas = "";
680
+ let doneText = "";
681
+ let snapshot = "";
682
+ let usage: OcxUsage | undefined;
683
+ for await (const event of decodeServerSentEvents(response.body)) {
684
+ let payload: unknown;
685
+ try { payload = JSON.parse(event.data); } catch { continue; }
686
+ if (!isPlainObject(payload)) continue;
687
+ switch (payload.type) {
688
+ case "response.output_text.delta":
689
+ if (typeof payload.delta === "string") deltas += payload.delta;
690
+ break;
691
+ case "response.output_text.done":
692
+ if (typeof payload.text === "string") doneText += payload.text;
693
+ break;
694
+ case "response.failed":
695
+ case "error":
696
+ yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) };
697
+ return;
698
+ case "response.incomplete":
699
+ yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
700
+ return;
701
+ case "response.completed":
702
+ snapshot = responsesPayloadText(payload.response);
703
+ usage = usageFromResponsesPayload(payload.response);
704
+ break;
705
+ }
706
+ }
707
+ // Gateways differ in which of these they emit; prefer the authoritative
708
+ // completed snapshot so text is never double-counted.
709
+ const text = snapshot || doneText || deltas;
710
+ if (text) yield { type: "text_delta", text };
711
+ yield { type: "done", ...(usage ? { usage } : {}) };
712
+ },
713
+
714
+ async parseResponse(response: Response): Promise<AdapterEvent[]> {
715
+ let payload: unknown;
716
+ try { payload = await response.json(); } catch {
717
+ return [{ type: "error", message: "malformed upstream compaction response" }];
718
+ }
719
+ if (!isPlainObject(payload)) {
720
+ return [{ type: "error", message: "malformed upstream compaction response" }];
721
+ }
722
+ if (payload.error || payload.status === "failed") {
723
+ return [{ type: "error", message: responsesErrorMessage(payload) }];
724
+ }
725
+ if (payload.status === "incomplete") {
726
+ return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
727
+ }
728
+ const text = responsesPayloadText(payload);
729
+ if (!text) {
730
+ // A completed turn with no usable text cannot become a summary; saying so is
731
+ // better than installing an empty compaction as replacement history.
732
+ return [{ type: "error", message: "upstream compaction returned no summary text" }];
733
+ }
734
+ const usage = usageFromResponsesPayload(payload);
735
+ return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
590
736
  },
591
737
  };
592
738
  }
package/src/bridge.ts CHANGED
@@ -456,6 +456,17 @@ export function bridgeToResponsesSSE(
456
456
  if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue;
457
457
  }
458
458
  switch (event.type) {
459
+ case "assistant_boundary": {
460
+ // A guarded continuation starts a fresh assistant output item while keeping the
461
+ // intermediate, suspicious text in the same Responses turn.
462
+ if (currentMsg) closeCurrentMessage();
463
+ if (currentReasoning) closeCurrentReasoning();
464
+ if (currentRawReasoning) closeCurrentRawReasoning();
465
+ flushHiddenRawReasoning();
466
+ if (currentToolCall) closeCurrentToolCall();
467
+ flushHiddenReasoningEnvelope();
468
+ break;
469
+ }
459
470
  case "text_delta": {
460
471
  if (currentReasoning) closeCurrentReasoning();
461
472
  if (currentRawReasoning) closeCurrentRawReasoning();
@@ -971,6 +982,12 @@ export function buildResponseJSON(
971
982
 
972
983
  for (const e of events) {
973
984
  switch (e.type) {
985
+ case "assistant_boundary":
986
+ flushText();
987
+ flushSummaryReasoning();
988
+ flushRawReasoning();
989
+ flushToolCall();
990
+ break;
974
991
  case "text_delta":
975
992
  if (currentText && currentTextPhase !== e.phase) flushText();
976
993
  if (currentSummaryReasoning) flushSummaryReasoning();
@@ -1062,7 +1079,9 @@ export function buildResponseJSON(
1062
1079
  flushSummaryReasoning();
1063
1080
  flushRawReasoning();
1064
1081
  flushToolCall();
1065
- if (options?.compaction && !errorEvent) {
1082
+ // A truncated turn must never be installed as replacement history: emit the
1083
+ // compaction item only when the turn actually completed (#422).
1084
+ if (options?.compaction && !errorEvent && !incompleteEvent && stopReason !== "max_tokens") {
1066
1085
  output.push({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) });
1067
1086
  }
1068
1087
 
@@ -76,6 +76,43 @@ function sseFrame(name: string, data: Rec): string {
76
76
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
77
77
  }
78
78
 
79
+ /**
80
+ * Claude Code / Anthropic WebSearch domain filters are optional and mutually exclusive.
81
+ * Empty arrays are rejected ("ambiguous"); both fields together are rejected. Routed models
82
+ * often emit both shapes — sanitize before Claude Code sees the tool_use / server_tool_use
83
+ * input (issue #381).
84
+ */
85
+ export function isClaudeWebSearchToolName(name: string): boolean {
86
+ const trimmed = name.trim();
87
+ return trimmed === "WebSearch" || /^web_search/i.test(trimmed);
88
+ }
89
+
90
+ function normalizeWebSearchDomainList(value: unknown): string[] | undefined {
91
+ if (!Array.isArray(value)) return undefined;
92
+ const domains = value
93
+ .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
94
+ .map(entry => entry.trim());
95
+ return domains.length > 0 ? domains : undefined;
96
+ }
97
+
98
+ /** Strip empty domain filters; if both remain, keep `allowed_domains` and drop `blocked_domains`. */
99
+ export function sanitizeWebSearchInput(input: unknown): Rec {
100
+ const src: Rec = isRec(input) ? { ...input } : {};
101
+ const allowed = normalizeWebSearchDomainList(src.allowed_domains);
102
+ const blocked = normalizeWebSearchDomainList(src.blocked_domains);
103
+ delete src.allowed_domains;
104
+ delete src.blocked_domains;
105
+ if (allowed && blocked) {
106
+ // Prefer allow-list (restrict-to) when a routed model sets both non-empty fields.
107
+ src.allowed_domains = allowed;
108
+ } else if (allowed) {
109
+ src.allowed_domains = allowed;
110
+ } else if (blocked) {
111
+ src.blocked_domains = blocked;
112
+ }
113
+ return src;
114
+ }
115
+
79
116
  /**
80
117
  * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
81
118
  * input (query/queries) and the web_search_tool_result content (hits, or the error
@@ -87,7 +124,9 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
87
124
  ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
88
125
  : [];
89
126
  const query = typeof action.query === "string" ? action.query : "";
90
- const input: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
127
+ const input = sanitizeWebSearchInput(
128
+ queries.length > 1 ? { queries } : { query: queries[0] ?? query },
129
+ );
91
130
  const completed = item.status !== "failed";
92
131
  let resultContent: unknown;
93
132
  if (completed) {
@@ -125,6 +164,10 @@ interface OpenBlock {
125
164
  index: number;
126
165
  /** Responses item_id (tool calls) so output_item.done can match. */
127
166
  itemId?: string;
167
+ /** Buffer WebSearch args and emit one sanitized input_json_delta on close (#381). */
168
+ bufferWebSearchArgs?: boolean;
169
+ argsBuf?: string;
170
+ webSearchArgsEmitted?: boolean;
128
171
  }
129
172
 
130
173
  /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
@@ -170,6 +213,19 @@ export function responsesSseToAnthropicSse(
170
213
  }
171
214
  const closeOpenBlock = () => {
172
215
  if (!open) return;
216
+ if (open.kind === "tool_use" && open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
217
+ // Emit sanitized args even if output_item.done never supplied a full arguments field
218
+ // (finish/fail paths, or deltas-only streams).
219
+ let parsed: unknown = {};
220
+ const rawArgs = open.argsBuf ?? "";
221
+ try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
222
+ emit("content_block_delta", {
223
+ type: "content_block_delta",
224
+ index: open.index,
225
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
226
+ });
227
+ open.webSearchArgsEmitted = true;
228
+ }
173
229
  if (open.kind === "thinking") {
174
230
  // Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway.
175
231
  emit("content_block_delta", {
@@ -253,21 +309,34 @@ export function responsesSseToAnthropicSse(
253
309
  closeOpenBlock();
254
310
  sawToolUse = true;
255
311
  const index = blockIndex++;
312
+ const name = typeof item.name === "string" ? item.name : "";
313
+ const bufferWebSearchArgs = isClaudeWebSearchToolName(name);
256
314
  emit("content_block_start", {
257
315
  type: "content_block_start", index,
258
316
  content_block: {
259
317
  type: "tool_use",
260
318
  id: typeof item.call_id === "string" ? item.call_id : `toolu_${uuid()}`,
261
- name: typeof item.name === "string" ? item.name : "",
319
+ name,
262
320
  input: {},
263
321
  },
264
322
  });
265
- open = { kind: "tool_use", index, itemId: typeof item.id === "string" ? item.id : undefined };
323
+ open = {
324
+ kind: "tool_use",
325
+ index,
326
+ itemId: typeof item.id === "string" ? item.id : undefined,
327
+ bufferWebSearchArgs,
328
+ argsBuf: "",
329
+ webSearchArgsEmitted: false,
330
+ };
266
331
  break;
267
332
  }
268
333
  case "response.function_call_arguments.delta": {
269
334
  if (typeof data.delta !== "string" || data.delta.length === 0) break;
270
335
  if (!open || open.kind !== "tool_use") break;
336
+ if (open.bufferWebSearchArgs) {
337
+ open.argsBuf = `${open.argsBuf ?? ""}${data.delta}`;
338
+ break;
339
+ }
271
340
  emit("content_block_delta", {
272
341
  type: "content_block_delta", index: open.index,
273
342
  delta: { type: "input_json_delta", partial_json: data.delta },
@@ -307,7 +376,22 @@ export function responsesSseToAnthropicSse(
307
376
  if (!open) break;
308
377
  // Close the matching open block (message/reasoning items close implicitly on
309
378
  // the next block; function_call items must close here so tool input parses).
310
- if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
379
+ if (open.kind === "tool_use" && item.type === "function_call") {
380
+ if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
381
+ const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0
382
+ ? item.arguments
383
+ : (open.argsBuf ?? "");
384
+ let parsed: unknown = {};
385
+ try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
386
+ emit("content_block_delta", {
387
+ type: "content_block_delta",
388
+ index: open.index,
389
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
390
+ });
391
+ open.webSearchArgsEmitted = true;
392
+ }
393
+ closeOpenBlock();
394
+ }
311
395
  else if (open.kind === "text" && item.type === "message") closeOpenBlock();
312
396
  else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock();
313
397
  break;
@@ -442,11 +526,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
442
526
  if (typeof raw.arguments === "string" && raw.arguments.length > 0) {
443
527
  try { input = JSON.parse(raw.arguments); } catch { input = {}; }
444
528
  }
529
+ const name = typeof raw.name === "string" ? raw.name : "";
445
530
  content.push({
446
531
  type: "tool_use",
447
532
  id: typeof raw.call_id === "string" ? raw.call_id : `toolu_${uuid()}`,
448
- name: typeof raw.name === "string" ? raw.name : "",
449
- input,
533
+ name,
534
+ input: isClaudeWebSearchToolName(name) ? sanitizeWebSearchInput(input) : input,
450
535
  });
451
536
  break;
452
537
  }
@@ -19,11 +19,19 @@ import {
19
19
  getAccountQuota,
20
20
  listAccountQuotas,
21
21
  parseUsageQuota,
22
+ setAccountQuotaFromParsed,
22
23
  updateAccountQuota,
23
24
  type StoredAccountQuota,
24
25
  type WhamUsageResponse,
25
26
  } from "./quota";
26
- export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota";
27
+ export {
28
+ applyAccountQuotaFromUpstreamHeaders,
29
+ clearAccountQuota,
30
+ getAccountQuota,
31
+ parseUsageQuota,
32
+ setAccountQuotaFromParsed,
33
+ updateAccountQuota,
34
+ } from "./quota";
27
35
  import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
28
36
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
29
37
  import { maskEmail } from "../lib/privacy";
@@ -280,14 +288,7 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
280
288
  // score and auto-switch the main account exactly like a pool account (Option A).
281
289
  setMainAccountPlan(result.plan);
282
290
  if (result.quota) {
283
- updateAccountQuota(
284
- MAIN_CODEX_ACCOUNT_ID,
285
- result.quota.weeklyPercent,
286
- result.quota.weeklyResetAt,
287
- result.quota.monthlyPercent,
288
- result.quota.monthlyResetAt,
289
- result.quota.resetCredits,
290
- );
291
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
291
292
  }
292
293
  return result;
293
294
  } catch {
@@ -327,14 +328,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
327
328
  const data = (await resp.json()) as WhamUsageResponse;
328
329
  const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
329
330
  if (!quota) return { quota: existing ?? null, needsReauth: false };
330
- updateAccountQuota(
331
- accountId,
332
- quota.weeklyPercent,
333
- quota.weeklyResetAt,
334
- quota.monthlyPercent,
335
- quota.monthlyResetAt,
336
- quota.resetCredits,
337
- );
331
+ setAccountQuotaFromParsed(accountId, quota);
338
332
  return { quota: getAccountQuota(accountId), needsReauth: false };
339
333
  } catch (e) {
340
334
  if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
@@ -767,14 +761,7 @@ export async function handleCodexAuthAPI(
767
761
  markCodexAccountValidated(accountId, warmup.validatedAt);
768
762
  clearAccountNeedsReauth(accountId);
769
763
  if (quota) {
770
- updateAccountQuota(
771
- accountId,
772
- quota.weeklyPercent,
773
- quota.weeklyResetAt,
774
- quota.monthlyPercent,
775
- quota.monthlyResetAt,
776
- quota.resetCredits,
777
- );
764
+ setAccountQuotaFromParsed(accountId, quota);
778
765
  }
779
766
 
780
767
  const latestConfig = getRuntimeConfig(config);
@@ -9,6 +9,8 @@ import { isCodexAccountUsable } from "./account-usability";
9
9
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
10
10
  import {
11
11
  getCodexAccountCooldownUntil,
12
+ releaseCodexQuotaProbeLease,
13
+ tryAcquireCodexQuotaProbeLease,
12
14
  pickLowestUsageCodexAccount,
13
15
  resolveCodexAccountForThreadDetailed,
14
16
  } from "./routing";
@@ -24,6 +26,12 @@ export type CodexAuthContext =
24
26
  generation: number;
25
27
  accessToken: string;
26
28
  chatgptAccountId: string;
29
+ /**
30
+ * Set when this request was admitted through an active quota cooldown as
31
+ * the account's single probe. Must be echoed into the upstream outcome so
32
+ * only this request can clear the cooldown (#433).
33
+ */
34
+ probeLeaseId?: string;
27
35
  }
28
36
  | {
29
37
  // Main Codex account participating in rotation: token injected from ~/.codex/auth.json
@@ -32,8 +40,24 @@ export type CodexAuthContext =
32
40
  accountId: string;
33
41
  accessToken: string;
34
42
  chatgptAccountId: string;
43
+ /** See `pool.probeLeaseId`. */
44
+ probeLeaseId?: string;
35
45
  };
36
46
 
47
+ /** Probe lease carried by this context, when it holds one. */
48
+ export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined {
49
+ return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
50
+ }
51
+
52
+ /**
53
+ * Hand back a probe lease for a request that will not reach upstream. Safe to
54
+ * call with a context that holds no lease.
55
+ */
56
+ export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
57
+ const leaseId = codexProbeLeaseId(ctx);
58
+ if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
59
+ }
60
+
37
61
  export type OcxRuntimeProviderConfig = OcxProviderConfig & {
38
62
  _codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
39
63
  _codexAccountRequired?: boolean;
@@ -130,13 +154,30 @@ export async function resolveCodexAuthContext(
130
154
  .catch(() => {});
131
155
  }
132
156
  const cooldownUntil = getCodexAccountCooldownUntil(accountId);
133
- if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
157
+ // A cooled-down account never sends traffic, so upstream recovery can never be
158
+ // observed and the cooldown outlives the real limit. Admit one probe per
159
+ // interval; its outcome decides whether the cooldown ends (#433).
160
+ let probeLeaseId: string | undefined;
161
+ if (cooldownUntil) {
162
+ probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
163
+ if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil);
164
+ }
134
165
 
135
166
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
136
167
  // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
137
168
  const token = getMainAccountToken();
138
- if (!token) throw new CodexPoolAuthenticationError();
139
- return { kind: "main-pool", accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId };
169
+ if (!token) {
170
+ // Nothing will reach upstream, so give the probe back instead of burning it.
171
+ if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
172
+ throw new CodexPoolAuthenticationError();
173
+ }
174
+ return {
175
+ kind: "main-pool",
176
+ accountId,
177
+ accessToken: token.accessToken,
178
+ chatgptAccountId: token.chatgptAccountId,
179
+ ...(probeLeaseId ? { probeLeaseId } : {}),
180
+ };
140
181
  }
141
182
 
142
183
  try {
@@ -147,8 +188,10 @@ export async function resolveCodexAuthContext(
147
188
  generation: token.generation,
148
189
  accessToken: token.accessToken,
149
190
  chatgptAccountId: token.chatgptAccountId,
191
+ ...(probeLeaseId ? { probeLeaseId } : {}),
150
192
  };
151
193
  } catch (cause) {
194
+ if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
152
195
  if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
153
196
  markAccountNeedsReauth(accountId);
154
197
  }
@@ -158,6 +201,8 @@ export async function resolveCodexAuthContext(
158
201
 
159
202
  export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
160
203
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
204
+ // A context holding the probe lease was deliberately admitted through the cooldown.
205
+ if (ctx.probeLeaseId) return;
161
206
  const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
162
207
  if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
163
208
  }