@yansigit/opencodex 2.33.0 → 2.35.0

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 (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Reading the prompt text Codex actually assembles.
3
+ *
4
+ * The read-only dialog used to say Codex "does not expose" a layer's text. That
5
+ * was wrong: Codex is open source and ships `codex debug prompt-input`, which
6
+ * renders the model-visible input list as JSON. Reading it is the difference
7
+ * between describing a layer and showing it.
8
+ *
9
+ * What this does NOT cover, stated rather than implied:
10
+ *
11
+ * - `base-instructions` is absent. `prompt_debug.rs` returns `prompt.input` and
12
+ * discards `base_instructions`, so the base prompt never appears here.
13
+ * - World-state sections are DIFF-rendered (`add_section` registers state, it does
14
+ * not emit text). A section that renders nothing on a first turn is missing
15
+ * from this output even though its layer exists.
16
+ * - The output reflects the invoking directory and the current config, not a
17
+ * universal prompt.
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { resolveCodexHomeDir } from "./home";
24
+
25
+ /**
26
+ * Layer id -> the tag Codex actually renders it under.
27
+ *
28
+ * Every entry here was read off live `codex debug prompt-input` output across the
29
+ * feature flags that enable each section. None is inferred from the Rust section
30
+ * `ID` constants: those are identifiers for diffing, not wrapper tags, and an
31
+ * earlier guess from them mapped `permissions` to the wrong name while the real
32
+ * tag - `permissions instructions`, with a space - went unmatched.
33
+ *
34
+ * A layer absent from this map is reported as unsupported, never as silent.
35
+ */
36
+ const LAYER_SECTION_TAGS: Record<string, string> = {
37
+ skills: "skills_instructions",
38
+ apps: "apps_instructions",
39
+ plugins: "plugins_instructions",
40
+ environment: "environment_context",
41
+ permissions: "permissions instructions",
42
+ // Synthetic: the project doc carries no tag of its own (see extractSections).
43
+ "agents-md": "__agents_md",
44
+ };
45
+
46
+ /**
47
+ * Inventory ids with no confirmed tag in the rendered output. They are listed
48
+ * explicitly rather than left missing: an absent entry made the dialog report a
49
+ * successful probe as unavailable.
50
+ */
51
+ const UNMAPPED_LAYER_IDS = [
52
+ "model-switch",
53
+ "context-window-guidance",
54
+ "environments-instructions",
55
+ "tools",
56
+ "multi-agent-mode",
57
+ // Confirmed absent from live output under their own feature flags, so the
58
+ // previous `personality` / `realtime` guesses reported these as silent when the
59
+ // truth is that this extractor has no verified tag for them.
60
+ "personality",
61
+ "realtime",
62
+ "collaboration",
63
+ // The Rust source names a <git_attribution> marker pair, but a world-state section is
64
+ // DIFF-rendered: it emits nothing on a turn where its state has not changed. Live
65
+ // `codex debug prompt-input` (codex-cli 0.145.0, 32978 bytes) showed no such block and
66
+ // no attribution text. Listing the id here reports "not exposed" honestly instead of
67
+ // claiming a tag this extractor has never actually matched - the same mistake the
68
+ // header above records for permissions.
69
+ "git-attribution",
70
+ ] as const;
71
+
72
+ export interface LayerText {
73
+ /** Rendered text, when this layer produced a section on the probed turn. */
74
+ text: string | null;
75
+ /** Why the text is absent, when it is. */
76
+ reason: "ok" | "empty-source" | "not-rendered" | "not-exposed" | "unavailable";
77
+ bytes: number;
78
+ /** For `empty-source`: the file that exists but has nothing in it. */
79
+ sourcePath?: string;
80
+ }
81
+
82
+ export interface PromptTextProbe {
83
+ ok: boolean;
84
+ /** The Codex home the probe reported on. */
85
+ codexHome: string;
86
+ layers: Record<string, LayerText>;
87
+ detail?: string;
88
+ }
89
+
90
+ function resolveCodexBinary(): string | null {
91
+ const candidates = [
92
+ join(homedir(), ".codex/packages/standalone/current/bin/codex"),
93
+ join(homedir(), ".local/bin/codex"),
94
+ "/usr/local/bin/codex",
95
+ "/opt/homebrew/bin/codex",
96
+ ];
97
+ return candidates.find(path => existsSync(path)) ?? null;
98
+ }
99
+
100
+ /** 8 MiB is far above any real prompt and far below anything that hurts the server. */
101
+ const MAX_PROBE_OUTPUT_BYTES = 8 * 1024 * 1024;
102
+
103
+ function runProbe(binary: string, cwd: string, timeoutMs: number): Promise<string | null> {
104
+ return new Promise(resolve => {
105
+ // A probe must never hang OR balloon the management API: it is bounded in
106
+ // time AND in bytes, and every failure degrades to "unavailable" rather than
107
+ // an error page.
108
+ const child = spawn(binary, ["debug", "prompt-input"], {
109
+ cwd,
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ const chunks: Buffer[] = [];
113
+ let size = 0;
114
+ let settled = false;
115
+ // One settlement path: a timeout that resolved before `close` used to leave
116
+ // the child streaming into a buffer nobody would ever read.
117
+ const settle = (value: string | null) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ clearTimeout(timer);
121
+ child.stdout?.destroy();
122
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
123
+ resolve(value);
124
+ };
125
+ const timer = setTimeout(() => settle(null), timeoutMs);
126
+ child.stdout?.on("data", (chunk: Buffer) => {
127
+ size += chunk.length;
128
+ if (size > MAX_PROBE_OUTPUT_BYTES) { settle(null); return; }
129
+ chunks.push(chunk);
130
+ });
131
+ child.on("error", () => settle(null));
132
+ child.on("close", code => {
133
+ // Decode once, at the end: `String(chunk)` per chunk corrupts any UTF-8
134
+ // character that straddles a chunk boundary.
135
+ settle(code === 0 ? Buffer.concat(chunks).toString("utf8") : null);
136
+ });
137
+ });
138
+ }
139
+
140
+ /** Pull every `<tag>...</tag>` section out of the rendered developer messages. */
141
+ function extractSections(raw: string): Map<string, string> {
142
+ const sections = new Map<string, string>();
143
+ let parsed: unknown;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ } catch {
147
+ return sections;
148
+ }
149
+ if (!Array.isArray(parsed)) return sections;
150
+ for (const item of parsed) {
151
+ const content = (item as { content?: unknown }).content;
152
+ if (!Array.isArray(content)) continue;
153
+ const text = content.map(part => String((part as { text?: unknown }).text ?? "")).join("");
154
+ // Tag names are NOT all snake_case: Codex renders `<permissions instructions>`
155
+ // with a space. A `[a-z_]+` pattern silently skipped it, and the layer was
156
+ // reported as "sent nothing" while its text sat right there.
157
+ for (const match of text.matchAll(/<([a-zA-Z_][a-zA-Z0-9_ -]*)>([\s\S]*?)<\/\1>/g)) {
158
+ sections.set(match[1]!, match[2]!.trim());
159
+ }
160
+ // AGENTS.md is NOT tagged: it arrives as a plain `# AGENTS.md instructions
161
+ // for <path>` block among the tagged sections. Matching only on tags would
162
+ // report the layer as unrendered while its text sits in the same message.
163
+ //
164
+ // Bounded at both ends. Capturing to end-of-message swept up any unrelated
165
+ // untagged prose that happened to follow, and stripping tag-shaped blocks
166
+ // first also deleted XML-like text the user had written INSIDE their own
167
+ // AGENTS.md. Codex wraps the body in <INSTRUCTIONS>, so that is the boundary.
168
+ // No line anchor: the block is concatenated directly onto the previous
169
+ // section's closing tag, so requiring a newline before it never matched.
170
+ const projectDoc = /# AGENTS\.md instructions for [^\n]*\n+<INSTRUCTIONS>\n?([\s\S]*?)\n?<\/INSTRUCTIONS>/.exec(text);
171
+ if (projectDoc) sections.set("__agents_md", projectDoc[1]!.trim());
172
+ }
173
+ return sections;
174
+ }
175
+
176
+ /** Test seam: the extraction is the part worth pinning, not the spawn. */
177
+ export const extractSectionsForTests = extractSections;
178
+
179
+ /**
180
+ * Probe once and map every known layer to its rendered text.
181
+ *
182
+ * `cwd` matters: AGENTS.md and environment context are directory-dependent, so a
183
+ * probe from the wrong place would describe a prompt the user never sees.
184
+ */
185
+ export async function probePromptText(timeoutMs = 15_000): Promise<PromptTextProbe> {
186
+ // The probe runs in CODEX_HOME, never in a caller-supplied directory. A `cwd`
187
+ // parameter let an authenticated request read any readable folder's AGENTS.md,
188
+ // and it also described a prompt that depends on where Codex happened to run.
189
+ // The global home is the one context this page can honestly report on.
190
+ const codexHome = resolveCodexHomeDir();
191
+ const binary = resolveCodexBinary();
192
+ if (!binary) {
193
+ return { ok: false, codexHome, layers: {}, detail: "codex binary not found" };
194
+ }
195
+ const raw = await runProbe(binary, codexHome, timeoutMs);
196
+ if (raw === null) {
197
+ return { ok: false, codexHome, layers: {}, detail: "codex debug prompt-input failed" };
198
+ }
199
+ const sections = extractSections(raw);
200
+ if (sections.size === 0) {
201
+ // Zero sections from a zero-exit probe means the output did not parse, which
202
+ // is a failed read - not fifteen layers that each chose to send nothing.
203
+ return { ok: false, codexHome, layers: {}, detail: "prompt output could not be parsed" };
204
+ }
205
+ const layers: Record<string, LayerText> = {};
206
+ for (const [layerId, tag] of Object.entries(LAYER_SECTION_TAGS)) {
207
+ const text = sections.get(tag) ?? null;
208
+ layers[layerId] = text === null
209
+ // Registered but not rendered on this turn, which is an ordinary state for
210
+ // a diff-rendered section rather than an error.
211
+ ? { text: null, reason: "not-rendered", bytes: 0 }
212
+ : { text, reason: "ok", bytes: Buffer.byteLength(text, "utf8") };
213
+ }
214
+
215
+ // A file that exists and is empty is not the same as a layer that chose to send
216
+ // nothing. Reporting "sent nothing" for an empty AGENTS.md tells the user their
217
+ // layer is idle when the real answer is that the file they wrote is blank.
218
+ const agentsMdPath = join(codexHome, "AGENTS.md");
219
+ if (layers["agents-md"]?.reason === "not-rendered" && existsSync(agentsMdPath)) {
220
+ try {
221
+ if (statSync(agentsMdPath).size === 0) {
222
+ layers["agents-md"] = { text: null, reason: "empty-source", bytes: 0, sourcePath: agentsMdPath };
223
+ }
224
+ } catch {
225
+ // An unreadable file stays "not-rendered": we cannot claim it is empty.
226
+ }
227
+ }
228
+ // The base prompt travels outside prompt.input and cannot be read this way.
229
+ layers["base-instructions"] = { text: null, reason: "not-exposed", bytes: 0 };
230
+
231
+ // Layers whose rendered tag we have not confirmed against live output. Leaving
232
+ // them absent made the GUI fall through to "unavailable", which claims the probe
233
+ // failed when it succeeded. Saying we have no mapping is the smaller claim.
234
+ for (const id of UNMAPPED_LAYER_IDS) {
235
+ layers[id] ??= { text: null, reason: "not-exposed", bytes: 0 };
236
+ }
237
+ return { ok: true, codexHome, layers };
238
+ }
@@ -9,6 +9,8 @@ export type StoredAccountQuota = {
9
9
  monthlyPercent?: number;
10
10
  weeklyResetAt?: number;
11
11
  monthlyResetAt?: number;
12
+ fiveHourPercent?: number;
13
+ fiveHourResetAt?: number;
12
14
  /**
13
15
  * A sub-day burst window, when upstream declares one (#1791).
14
16
  *
@@ -23,6 +25,7 @@ export type StoredAccountQuota = {
23
25
  shortPercent?: number;
24
26
  shortResetAt?: number;
25
27
  shortWindowSeconds?: number;
28
+ customWindows?: Array<{ label: string; percent: number; resetAt?: number }>;
26
29
  resetCredits?: number;
27
30
  /**
28
31
  * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
@@ -61,6 +64,16 @@ export type WhamUsageResponse = {
61
64
  rate_limit_reset_credits?: {
62
65
  available_count: number;
63
66
  } | null;
67
+ additional_rate_limits?: WhamAdditionalRateLimit[] | null;
68
+ };
69
+
70
+ type WhamAdditionalRateLimit = {
71
+ limit_name?: unknown;
72
+ metered_feature?: unknown;
73
+ rate_limit?: {
74
+ primary_window?: WhamUsageWindow | null;
75
+ secondary_window?: WhamUsageWindow | null;
76
+ } | null;
64
77
  };
65
78
 
66
79
  type WhamUsageWindow = {
@@ -84,6 +97,9 @@ const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
84
97
  */
85
98
  const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60;
86
99
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
100
+ // Derived, never written as a literal: the header parser and the WHAM parser must not be able
101
+ // to drift to different thresholds, which is the class of defect this pair exists to prevent.
102
+ const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60;
87
103
 
88
104
  const accountQuota = new Map<string, StoredAccountQuota>();
89
105
  let lastReconciledGeneration = 0;
@@ -99,16 +115,17 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
99
115
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
100
116
 
101
117
  export function isCodexQuotaExhausted(
102
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
118
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "fiveHourPercent" | "shortPercent"> | null,
103
119
  plan?: unknown,
104
120
  ): boolean {
105
121
  if (!quota) return false;
106
122
  // The burst window counts on EVERY plan. It is upstream-enforced independently, so an
107
123
  // account at 100% there is blocked regardless of which longer window governs its plan;
108
124
  // omitting it would route traffic straight into a 429 (#1791).
125
+ const burst = quota.fiveHourPercent ?? quota.shortPercent;
109
126
  const values = codexQuotaWindowForPlan(plan) === "monthly"
110
- ? [quota.monthlyPercent, quota.shortPercent]
111
- : [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent];
127
+ ? [quota.monthlyPercent, burst]
128
+ : [quota.weeklyPercent, quota.monthlyPercent, burst];
112
129
  return values.some(value => typeof value === "number"
113
130
  && Number.isFinite(value)
114
131
  && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
@@ -134,7 +151,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
134
151
  }
135
152
 
136
153
  export function isCompleteCodexQuotaRecoverySnapshot(
137
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
154
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "fiveHourPercent" | "shortPercent"> | null,
138
155
  plan?: unknown,
139
156
  ): boolean {
140
157
  if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
@@ -186,8 +203,9 @@ function normalizeResetAt(value: unknown): number | undefined {
186
203
  }
187
204
 
188
205
  function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
189
- return [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent]
190
- .some(value => typeof value === "number" && Number.isFinite(value));
206
+ return [quota.weeklyPercent, quota.monthlyPercent, quota.fiveHourPercent, quota.shortPercent]
207
+ .some(value => typeof value === "number" && Number.isFinite(value))
208
+ || !!quota.customWindows?.some(window => Number.isFinite(window.percent));
191
209
  }
192
210
 
193
211
  /** True only for a window that DECLARES a duration shorter than a day. */
@@ -207,14 +225,24 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo
207
225
  }
208
226
 
209
227
  function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
210
- const minutes = typeof windowMinutes === "number"
211
- ? windowMinutes
212
- : typeof windowMinutes === "string" && windowMinutes.trim() !== ""
213
- ? Number(windowMinutes)
228
+ const minutes = windowMinutes_(windowMinutes);
229
+ return minutes !== undefined && minutes >= MONTHLY_WINDOW_MIN_MINUTES;
230
+ }
231
+
232
+ /** The header wire reports a window duration in MINUTES; WHAM reports it in seconds. */
233
+ function windowMinutes_(value: unknown): number | undefined {
234
+ const minutes = typeof value === "number"
235
+ ? value
236
+ : typeof value === "string" && value.trim() !== ""
237
+ ? Number(value)
214
238
  : undefined;
215
- return typeof minutes === "number"
216
- && Number.isFinite(minutes)
217
- && minutes >= MONTHLY_WINDOW_MIN_MINUTES;
239
+ return typeof minutes === "number" && Number.isFinite(minutes) ? minutes : undefined;
240
+ }
241
+
242
+ /** Minutes-domain twin of isExplicitShortWindow. Same strict `<`, same 24h discriminator. */
243
+ function isExplicitShortWindowMinutes(value: unknown): boolean {
244
+ const minutes = windowMinutes_(value);
245
+ return minutes !== undefined && minutes > 0 && minutes < WEEKLY_WINDOW_MIN_MINUTES;
218
246
  }
219
247
 
220
248
 
@@ -229,11 +257,17 @@ function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boole
229
257
  function snapshotHasShort(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
230
258
  return quota.shortPercent !== undefined
231
259
  || quota.shortResetAt !== undefined
232
- || quota.shortWindowSeconds !== undefined;
260
+ || quota.shortWindowSeconds !== undefined
261
+ || quota.fiveHourPercent !== undefined
262
+ || quota.fiveHourResetAt !== undefined;
263
+ }
264
+
265
+ function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
266
+ return quota.customWindows !== undefined;
233
267
  }
234
268
 
235
269
  function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
236
- return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota);
270
+ return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
237
271
  }
238
272
  export function setAccountQuotaFromParsed(
239
273
  accountId: string,
@@ -252,9 +286,12 @@ export function setAccountQuotaFromParsed(
252
286
  if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
253
287
  if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
254
288
  if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
289
+ if (existing?.fiveHourPercent !== undefined) next.fiveHourPercent = existing.fiveHourPercent;
290
+ if (existing?.fiveHourResetAt !== undefined) next.fiveHourResetAt = existing.fiveHourResetAt;
255
291
  if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
256
292
  if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
257
293
  if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
294
+ if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
258
295
  next.resetCredits = quota.resetCredits;
259
296
  accountQuota.set(accountId, next);
260
297
  schedulePersistAccountQuotas();
@@ -279,7 +316,7 @@ export function setAccountQuotaFromParsed(
279
316
  // while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to
280
317
  // any future reader, and that failure would be invisible.
281
318
  if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
282
- } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota))
319
+ } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota))
283
320
  && existing?.monthlyPercent !== undefined) {
284
321
  next.monthlyPercent = existing.monthlyPercent;
285
322
  if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
@@ -289,15 +326,21 @@ export function setAccountQuotaFromParsed(
289
326
  if (snapshotHasShort(quota)) {
290
327
  if (quota.shortPercent !== undefined) next.shortPercent = quota.shortPercent;
291
328
  if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt;
329
+ if (quota.fiveHourPercent !== undefined) next.fiveHourPercent = quota.fiveHourPercent;
330
+ if (quota.fiveHourResetAt !== undefined) next.fiveHourResetAt = quota.fiveHourResetAt;
292
331
  if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds;
293
332
  } else {
294
333
  // Header and reset-credit updates are partial snapshots. Preserve the last full WHAM
295
334
  // burst tuple when those updates do not carry enough window metadata to replace it.
335
+ if (existing?.fiveHourPercent !== undefined) next.fiveHourPercent = existing.fiveHourPercent;
336
+ if (existing?.fiveHourResetAt !== undefined) next.fiveHourResetAt = existing.fiveHourResetAt;
296
337
  if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
297
338
  if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
298
339
  if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
299
340
  }
300
341
 
342
+ if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows;
343
+
301
344
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
302
345
  else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
303
346
 
@@ -323,6 +366,12 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
323
366
  const secondaryResetAt = normalizeResetAt(secondaryResetRaw);
324
367
  const tertiaryResetAt = normalizeResetAt(tertiaryResetRaw);
325
368
  const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes);
369
+ // Codex removed the 5-hour window and has now restored it for Plus and Team (Pro stays
370
+ // weekly-only). A primary window that DECLARES a sub-day duration is a burst window: folding
371
+ // it into weeklyPercent both discards the real weekly reading and leaves the account looking
372
+ // exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
373
+ // already does for the WHAM payload — the two parsers must not disagree about the same data.
374
+ const primaryIsShort = primaryRaw !== null && isExplicitShortWindowMinutes(primaryWindowMinutes);
326
375
 
327
376
  if (primaryIsMonthly) {
328
377
  if (primaryPercent !== undefined) {
@@ -337,6 +386,23 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
337
386
  quota.weeklyPercent = secondaryPercent;
338
387
  if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
339
388
  }
389
+ } else if (primaryIsShort) {
390
+ if (primaryPercent !== undefined) {
391
+ quota.shortPercent = primaryPercent;
392
+ quota.fiveHourPercent = primaryPercent;
393
+ if (primaryResetAt !== undefined) {
394
+ quota.shortResetAt = primaryResetAt;
395
+ quota.fiveHourResetAt = primaryResetAt;
396
+ }
397
+ const minutes = windowMinutes_(primaryWindowMinutes);
398
+ if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
399
+ }
400
+ // The burst window vacates the primary slot, so the weekly reading is the secondary — which
401
+ // is where it was all along. Without this the true weekly value is silently dropped.
402
+ if (secondaryPercent !== undefined) {
403
+ quota.weeklyPercent = secondaryPercent;
404
+ if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
405
+ }
340
406
  } else {
341
407
  const weeklyPercent = primaryPercent ?? secondaryPercent;
342
408
  const weeklyResetAt = primaryPercent !== undefined
@@ -363,7 +429,10 @@ export function applyAccountQuotaFromUpstreamHeaders(
363
429
  ): void {
364
430
  const quota = parseUpstreamQuotaHeaders(headers);
365
431
  if (!quota) return;
366
- setAccountQuotaFromParsed(accountId, quota, writerGeneration);
432
+ // Header consumers historically persist the Codex pool window under `short*`; keep that
433
+ // storage shape while the parser exposes the canonical five-hour alias to direct callers.
434
+ const { fiveHourPercent: _fiveHourPercent, fiveHourResetAt: _fiveHourResetAt, ...legacyQuota } = quota;
435
+ setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration);
367
436
  }
368
437
 
369
438
  export function updateAccountQuota(
@@ -391,9 +460,12 @@ export function updateAccountQuota(
391
460
  : {}),
392
461
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
393
462
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
463
+ ...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}),
464
+ ...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}),
394
465
  ...(existing?.shortPercent !== undefined ? { shortPercent: existing.shortPercent } : {}),
395
466
  ...(existing?.shortResetAt !== undefined ? { shortResetAt: existing.shortResetAt } : {}),
396
467
  ...(existing?.shortWindowSeconds !== undefined ? { shortWindowSeconds: existing.shortWindowSeconds } : {}),
468
+ ...(existing?.customWindows !== undefined ? { customWindows: existing.customWindows } : {}),
397
469
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
398
470
  updatedAt: Date.now(),
399
471
  };
@@ -506,6 +578,9 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
506
578
  : undefined;
507
579
 
508
580
  if (!data.rate_limit) {
581
+ if (data.additional_rate_limits?.length) {
582
+ return parseUsageQuota({ ...data, rate_limit: {} });
583
+ }
509
584
  return resetCredits !== undefined ? { resetCredits } : null;
510
585
  }
511
586
 
@@ -540,7 +615,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
540
615
  // the account is blocked when it fills even though the weekly window is fine (#1791).
541
616
  if (primaryIsShort && primaryPercent !== undefined) {
542
617
  quota.shortPercent = primaryPercent;
543
- if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
618
+ quota.fiveHourPercent = primaryPercent;
619
+ if (primaryResetAt !== undefined) {
620
+ quota.shortResetAt = primaryResetAt;
621
+ quota.fiveHourResetAt = primaryResetAt;
622
+ }
544
623
  const seconds = primaryWindow?.limit_window_seconds;
545
624
  if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds;
546
625
  }
@@ -567,6 +646,32 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
567
646
  // different period, so recovery must not treat the two as interchangeable.
568
647
  if (primaryIsMonthly && primaryPercent !== undefined) quota.monthlyIsPrimaryWindow = true;
569
648
  }
649
+
650
+ const spark = data.additional_rate_limits?.find(additional => {
651
+ const name = String(additional.limit_name ?? "").toLowerCase();
652
+ const feature = String(additional.metered_feature ?? "").toLowerCase();
653
+ return feature === "codex_bengalfox" || name.includes("gpt-5.3-codex-spark");
654
+ });
655
+ const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window]
656
+ .filter((window): window is WhamUsageWindow => !!window);
657
+ const sparkWeekly = sparkWindows.find(window => {
658
+ const percent = normalizeUsagePercent(window.used_percent);
659
+ const seconds = window.limit_window_seconds;
660
+ return percent !== undefined
661
+ && !isExplicitShortWindow(window)
662
+ && !isExplicitMonthlyWindow(window)
663
+ && (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS);
664
+ });
665
+ const sparkPercent = normalizeUsagePercent(sparkWeekly?.used_percent);
666
+ if (sparkPercent !== undefined) {
667
+ const sparkWindow: { label: string; percent: number; resetAt?: number } = {
668
+ label: "GPT-5.3-Codex-Spark Weekly",
669
+ percent: sparkPercent,
670
+ };
671
+ const resetAt = normalizeResetAt(sparkWeekly?.reset_at);
672
+ if (resetAt !== undefined) sparkWindow.resetAt = resetAt;
673
+ quota.customWindows = [sparkWindow];
674
+ }
570
675
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
571
676
 
572
677
  return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
@@ -612,6 +612,15 @@ export function tryAcquireCodexQuotaScopeProbeLease(
612
612
  return probeLeaseId;
613
613
  }
614
614
 
615
+ /** Side-effect-free check for a confirmed model-specific quota probe. */
616
+ export function canAcquireCodexQuotaScopeProbeLease(
617
+ accountId: string,
618
+ scope: CodexQuotaScope,
619
+ now = Date.now(),
620
+ ): boolean {
621
+ return canAcquireQuotaProbeLease(scopedHealthFor(accountId, scope), now);
622
+ }
623
+
615
624
  /**
616
625
  * Hand a probe lease back without recording an upstream outcome. Used by paths
617
626
  * that take a lease and then fail before any request reaches upstream.