@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -0,0 +1,177 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+ import {
4
+ getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, type MainQuotaWriter,
5
+ } from "./main-account-cache";
6
+ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
7
+ import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing";
8
+ import type { WhamUsageResponse } from "./quota-types";
9
+
10
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
11
+ const AUTHORIZATION_TTL_MS = 60_000;
12
+ const credentialSalt = randomBytes(32);
13
+ type Token = { accessToken: string; chatgptAccountId: string };
14
+ export interface MainReserveAuthorization {
15
+ readonly writer: MainQuotaWriter;
16
+ readonly observedAt: number;
17
+ readonly expiresAt: number;
18
+ }
19
+ type Input = {
20
+ token: Token;
21
+ writer: MainQuotaWriter | undefined;
22
+ signal?: AbortSignal;
23
+ observeOrdinaryQuota: (data: WhamUsageResponse, writer: MainQuotaWriter) => void;
24
+ };
25
+ type Slot = {
26
+ key: string;
27
+ writer: MainQuotaWriter;
28
+ credentialGeneration: number;
29
+ revision: number;
30
+ authorization?: MainReserveAuthorization;
31
+ flight?: Promise<MainReserveAuthorization | undefined>;
32
+ controller?: AbortController;
33
+ };
34
+ let current: Slot | undefined;
35
+ const authorizationKeys = new WeakMap<MainReserveAuthorization, string>();
36
+
37
+ function credentialKey(token: Token, writer: MainQuotaWriter): string {
38
+ return createHmac("sha256", credentialSalt).update(writer.identityKey)
39
+ .update(`:${writer.identityGeneration}:${getMainQuotaCredentialGeneration()}:`).update(token.accessToken).digest("hex");
40
+ }
41
+ function owned(token: Token, writer: MainQuotaWriter): boolean {
42
+ return isMainQuotaWriterLive(writer) && matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId);
43
+ }
44
+ function record(value: unknown): value is Record<string, unknown> {
45
+ return value !== null && typeof value === "object" && !Array.isArray(value);
46
+ }
47
+ function userId(token: string): string | undefined {
48
+ try {
49
+ const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
50
+ const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined;
51
+ if (!record(auth)) return;
52
+ const value = auth.chatgpt_user_id ?? auth.user_id;
53
+ return typeof value === "string" && value.length > 0 ? value : undefined;
54
+ } catch { return; }
55
+ }
56
+ function identityMatches(data: WhamUsageResponse, token: Token): boolean {
57
+ if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false;
58
+ const expectedUser = userId(token.accessToken);
59
+ return expectedUser === undefined || data.user_id == null || data.user_id === expectedUser;
60
+ }
61
+ function reserveLimits(data: WhamUsageResponse) {
62
+ return Array.isArray(data.additional_rate_limits)
63
+ ? data.additional_rate_limits.filter(entry => record(entry) && entry.limit_name === NATIVE_RESERVE_MODEL)
64
+ : [];
65
+ }
66
+ function grantsReserve(data: WhamUsageResponse): boolean {
67
+ const limits = reserveLimits(data);
68
+ return data.rate_limit?.allowed === false && data.rate_limit_upsell?.banner_type === "luna_reserve"
69
+ && limits.length === 1 && limits[0]?.rate_limit?.allowed === true;
70
+ }
71
+
72
+ /** An object copied/spread onto a refreshed credential is not an authorization for that credential. */
73
+ export function isMainReserveAuthorizationLive(
74
+ value: MainReserveAuthorization | undefined, token: Token, now = Date.now(),
75
+ ): boolean {
76
+ if (!value || !owned(token, value.writer) || value.expiresAt <= now || value.observedAt > now) return false;
77
+ const key = credentialKey(token, value.writer);
78
+ return current?.authorization === value && authorizationKeys.get(value) === key && current.key === key;
79
+ }
80
+
81
+ /** Passive usage may revoke, but never grant. Missing Reserve on a passive read is not revocation. */
82
+ export function observeMainReserveRevocation(data: WhamUsageResponse, writer: MainQuotaWriter | undefined): void {
83
+ const slot = current;
84
+ if (!slot || !writer || !isMainQuotaWriterLive(writer)
85
+ || writer.identityKey !== slot.writer.identityKey || writer.identityGeneration !== slot.writer.identityGeneration) return;
86
+ if (data.rate_limit?.allowed !== true && !reserveLimits(data).some(limit => limit.rate_limit?.allowed === false)) return;
87
+ slot.revision += 1;
88
+ slot.authorization = undefined;
89
+ slot.controller?.abort();
90
+ }
91
+
92
+ async function waitForCaller<T>(flight: Promise<T>, signal?: AbortSignal): Promise<T | undefined> {
93
+ if (!signal) return flight;
94
+ if (signal.aborted) return;
95
+ let abort!: () => void;
96
+ const aborted = new Promise<undefined>(resolve => { abort = () => resolve(undefined); signal.addEventListener("abort", abort, { once: true }); });
97
+ try { return await Promise.race([flight, aborted]); }
98
+ finally { signal.removeEventListener("abort", abort); }
99
+ }
100
+
101
+ async function readAuthorization(slot: Slot, input: Input & { writer: MainQuotaWriter }): Promise<MainReserveAuthorization | undefined> {
102
+ const controller = new AbortController();
103
+ slot.controller = controller;
104
+ const revision = slot.revision;
105
+ const deadline = Date.now() + WHAM_REQUEST_TIMEOUT_MS;
106
+ const live = () => current === slot && revision === slot.revision && !controller.signal.aborted
107
+ && Date.now() < deadline && slot.credentialGeneration === getMainQuotaCredentialGeneration()
108
+ && owned(input.token, input.writer);
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ let onAbort!: () => void;
111
+ const stopped = new Promise<undefined>(resolve => {
112
+ onAbort = () => resolve(undefined);
113
+ controller.signal.addEventListener("abort", onAbort, { once: true });
114
+ timer = setTimeout(() => controller.abort(), WHAM_REQUEST_TIMEOUT_MS);
115
+ });
116
+ const operation = (async () => {
117
+ const response = await fetch(USAGE_URL, {
118
+ method: "GET", redirect: "error", signal: controller.signal,
119
+ headers: {
120
+ authorization: `Bearer ${input.token.accessToken}`,
121
+ "chatgpt-account-id": input.token.chatgptAccountId,
122
+ "x-openai-codex-luna-reserve": "1", accept: "application/json",
123
+ },
124
+ });
125
+ if (!response.ok || !live()) {
126
+ void response.body?.cancel().catch(() => undefined);
127
+ return;
128
+ }
129
+ const body = await readBoundedResponseBody(response, {
130
+ signal: controller.signal, fatalUtf8: true,
131
+ totalTimeoutMs: Math.max(1, deadline - Date.now()), inactivityTimeoutMs: WHAM_REQUEST_TIMEOUT_MS,
132
+ });
133
+ if (!body.displaySafe || body.truncated || !live()) return;
134
+ const raw: unknown = JSON.parse(body.text);
135
+ if (!record(raw)) return;
136
+ const data = raw as WhamUsageResponse;
137
+ if (!identityMatches(data, input.token)) return;
138
+ // Keep malformed additional containers away from legacy ordinary parsers.
139
+ if (data.additional_rate_limits != null && !Array.isArray(data.additional_rate_limits)) return;
140
+ input.observeOrdinaryQuota(data, input.writer);
141
+ if (!live() || !grantsReserve(data)) { slot.authorization = undefined; return; }
142
+ const observedAt = Date.now();
143
+ const authorization = Object.freeze({
144
+ writer: Object.freeze({ ...input.writer }), observedAt, expiresAt: observedAt + AUTHORIZATION_TTL_MS,
145
+ });
146
+ authorizationKeys.set(authorization, slot.key);
147
+ slot.authorization = authorization;
148
+ return authorization;
149
+ })().catch(() => undefined);
150
+ try { return await Promise.race([operation, stopped]); }
151
+ finally {
152
+ clearTimeout(timer);
153
+ controller.signal.removeEventListener("abort", onAbort);
154
+ if (slot.controller === controller) slot.controller = undefined;
155
+ }
156
+ }
157
+
158
+ /** Capability-aware read with an already-owned token; no auth-file access or inference. */
159
+ export async function getMainReserveAuthorization(input: Input): Promise<MainReserveAuthorization | undefined> {
160
+ const writer = input.writer && { ...input.writer };
161
+ const token = { ...input.token };
162
+ if (input.signal?.aborted || !writer || !owned(token, writer)) return;
163
+ const key = credentialKey(token, writer);
164
+ if (!current || current.key !== key) {
165
+ current?.controller?.abort();
166
+ current = { key, writer: { ...writer }, credentialGeneration: getMainQuotaCredentialGeneration(), revision: 0 };
167
+ }
168
+ const slot = current;
169
+ if (isMainReserveAuthorizationLive(slot.authorization, token)) return slot.authorization;
170
+ if (!slot.flight) {
171
+ const flight = readAuthorization(slot, { ...input, token, writer });
172
+ slot.flight = flight;
173
+ void flight.finally(() => { if (slot.flight === flight) slot.flight = undefined; });
174
+ }
175
+ const result = await waitForCaller(slot.flight, input.signal);
176
+ return !input.signal?.aborted && isMainReserveAuthorizationLive(result, token) ? result : undefined;
177
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { saveConfigPreservingClaudeCode } from "../config";
3
3
  import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
4
4
  import { codexAccountLogLabel } from "./account-label";
5
+ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
5
6
  import { isCodexAccountPaused } from "./account-pause";
6
7
  import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
7
8
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
@@ -114,6 +115,14 @@ const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
114
115
  /** Minimum gap between probe leases for one cooled-down account. */
115
116
  export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
116
117
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
118
+ /**
119
+ * How recently a 100% burst reading must have been OBSERVED to exclude an account when it
120
+ * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
121
+ * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
122
+ * reading can never strand a recovered account, and long enough that a snapshot taken at
123
+ * admission is still fresh when selection reads it.
124
+ */
125
+ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
117
126
  /** How long a transient failure keeps the account out of pool selection. */
118
127
  export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
119
128
  const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
@@ -165,7 +174,7 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
165
174
  * Add a new explicit group here only when its independent upstream quota is
166
175
  * confirmed, so shared limits never receive cross-model bypasses.
167
176
  */
168
- export type CodexQuotaScope = "shared" | "spark";
177
+ export type CodexQuotaScope = "shared" | "spark" | "reserve";
169
178
 
170
179
  export type CodexQuotaRecoveryProbeClaim = {
171
180
  accountId: string;
@@ -200,6 +209,7 @@ function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelD
200
209
 
201
210
  const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
202
211
  "gpt-5.3-codex-spark": "spark",
212
+ [NATIVE_RESERVE_MODEL]: "reserve",
203
213
  };
204
214
 
205
215
  export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined {
@@ -365,6 +375,7 @@ export function computeCodexUsageScore(quota: {
365
375
  monthlyPercent?: number;
366
376
  shortPercent?: number;
367
377
  shortResetAt?: number;
378
+ shortObservedAt?: number;
368
379
  } | null, plan?: unknown, now: number = Date.now()): number {
369
380
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
370
381
  const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
@@ -404,15 +415,24 @@ export function computeCodexUsageScore(quota: {
404
415
  * direction here is the one that keeps an account selectable: a wrongly-selected account
405
416
  * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
406
417
  * by hand.
418
+ *
419
+ * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
420
+ * sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
421
+ * Old disk snapshots without short-window provenance remain unknown.
407
422
  */
408
423
  function isTerminalShortWindow(
409
- quota: { shortPercent?: number; shortResetAt?: number },
424
+ quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
410
425
  now: number,
411
426
  ): boolean {
412
427
  if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
413
428
  if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
414
429
  const resetAt = quota.shortResetAt;
415
- if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) return false;
430
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
431
+ const observedAt = quota.shortObservedAt;
432
+ if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
433
+ const age = now - observedAt;
434
+ return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
435
+ }
416
436
  // Both units reach storage: `normalizeResetAt` does not scale, and the GUI disambiguates
417
437
  // by magnitude at read time. A comparison written against one assumption is off by 1000x
418
438
  // against the other, and in the seconds-read-as-milliseconds direction every terminal
@@ -548,7 +568,7 @@ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now:
548
568
 
549
569
  /**
550
570
  * Claim due reset-derived cooldown probes without consulting account selection.
551
- * Pool credentials only: the main account has no quota-refresh single-flight.
571
+ * Added Pool credentials only; owned main usage recovery is handled separately.
552
572
  */
553
573
  export function claimDueCodexQuotaRecoveryProbes(
554
574
  config: OcxConfig,
@@ -575,11 +595,10 @@ export function claimDueCodexQuotaRecoveryProbes(
575
595
  { scope: undefined, health: upstreamHealth.get(account.id) },
576
596
  ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
577
597
  ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
578
- // `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope
579
- // parameter and returns generic weekly/monthly windows, so its result can never prove a
580
- // spark recovery a claim here would spend an upstream call to settle `false` every
581
- // time, and (with one claim per account per pass) delay the shared scope that CAN recover.
582
- entry.scope !== "spark"
598
+ // Generic WHAM evidence can recover only ordinary quota, never Spark or Reserve.
599
+ // Do not spend this account's one claim per pass on an independent scope and
600
+ // delay the shared scope that the response can actually recover.
601
+ (entry.scope === undefined || entry.scope === "shared")
583
602
  && entry.health?.cooldownSource === "reset-derived"
584
603
  && canAcquireQuotaProbeLease(entry.health, now))
585
604
  .sort((a, b) =>
package/src/codex/shim.ts CHANGED
@@ -766,7 +766,42 @@ exec ${shQuote(realCodexPath)} "$@"
766
766
  `;
767
767
  }
768
768
 
769
- type UnixShimProbeResult = "cleanup" | "descendants" | "failed" | "recursive" | "timeout" | null;
769
+ type UnixShimProbeCleanupPhase = "marker" | "reentry" | "group" | "stderr" | "group-id" | "termination" | "spawn" | "exception";
770
+ interface UnixShimProbeCleanup {
771
+ kind: "cleanup";
772
+ phase: UnixShimProbeCleanupPhase;
773
+ code: string;
774
+ status: number | null;
775
+ signal: string;
776
+ }
777
+ type UnixShimProbeResult = UnixShimProbeCleanup | "descendants" | "failed" | "recursive" | "timeout" | null;
778
+
779
+ const SHIM_PROBE_ERROR_CODES = new Set([
780
+ "EACCES", "EAGAIN", "EBADF", "ECANCELED", "EINTR", "EIO", "EMFILE", "ENFILE",
781
+ "ENOENT", "ENOEXEC", "ENOMEM", "ENOSPC", "EPERM", "EPIPE", "ESRCH", "ETIMEDOUT", "ETXTBSY",
782
+ ]);
783
+ const SHIM_PROBE_SIGNALS = new Set([
784
+ "SIGABRT", "SIGBUS", "SIGHUP", "SIGILL", "SIGINT", "SIGKILL", "SIGPIPE", "SIGQUIT",
785
+ "SIGSEGV", "SIGTERM", "SIGTRAP", "SIGXCPU", "SIGXFSZ",
786
+ ]);
787
+
788
+ /** Diagnostics cross a CLI boundary: never stringify arbitrary errors or metadata. */
789
+ function shimProbeCleanup(
790
+ phase: UnixShimProbeCleanupPhase, error?: unknown, status?: unknown, signal?: unknown,
791
+ ): UnixShimProbeCleanup {
792
+ let code = error === undefined ? "none" : "unknown";
793
+ if (error !== null && typeof error === "object") {
794
+ try {
795
+ const value = Object.getOwnPropertyDescriptor(error, "code")?.value;
796
+ if (typeof value === "string" && SHIM_PROBE_ERROR_CODES.has(value)) code = value;
797
+ } catch { /* hostile accessors/proxies cannot turn diagnostics into an exception */ }
798
+ }
799
+ return {
800
+ kind: "cleanup", phase, code,
801
+ status: typeof status === "number" && Number.isInteger(status) && status >= 0 && status <= 255 ? status : null,
802
+ signal: typeof signal === "string" && SHIM_PROBE_SIGNALS.has(signal) ? signal : "none",
803
+ };
804
+ }
770
805
 
771
806
  let codexShimProbeHookForTests: (() => void) | null = null;
772
807
  let codexShimProbeShellForTests: string | null = null;
@@ -842,6 +877,8 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult {
842
877
  delete env.OCX_SHIM_ACTIVE_DEPTH;
843
878
  delete env.OCX_SHIM_PROBE_ACTIVE;
844
879
  let groupId = 0;
880
+ let probeStatus: unknown;
881
+ let probeSignal: unknown;
845
882
  try {
846
883
  chmodSync(probeDir, 0o700);
847
884
  const result = spawnSync(process.execPath, [
@@ -863,26 +900,31 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult {
863
900
  timeout: CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS + CODEX_SHIM_INSTALL_PROBE_EXIT_TIMEOUT_MS,
864
901
  killSignal: "SIGKILL",
865
902
  });
903
+ probeStatus = result.status;
904
+ probeSignal = result.signal;
866
905
  const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
867
906
  const marker = readProbeMetadata(markerPath, 64);
868
907
  const reentryMarker = readProbeMetadata(reentryPath, 64);
869
908
  const groupText = readProbeMetadata(groupPath, 64);
870
909
  const launcherStderr = readProbeMetadata(stderrPath, MAX_DIAGNOSTIC_VALUE_BYTES);
871
910
  groupId = groupText === null ? 0 : Number.parseInt(groupText, 10);
872
- if (marker === null || reentryMarker === null || groupText === null || launcherStderr === null
873
- || !Number.isInteger(groupId) || groupId <= 0) return "cleanup";
911
+ if (marker === null) return shimProbeCleanup("marker", result.error, probeStatus, probeSignal);
912
+ if (reentryMarker === null) return shimProbeCleanup("reentry", result.error, probeStatus, probeSignal);
913
+ if (groupText === null) return shimProbeCleanup("group", result.error, probeStatus, probeSignal);
914
+ if (launcherStderr === null) return shimProbeCleanup("stderr", result.error, probeStatus, probeSignal);
915
+ if (!Number.isInteger(groupId) || groupId <= 0) return shimProbeCleanup("group-id", result.error, probeStatus, probeSignal);
874
916
  const groupSurvived = unixProcessGroupAlive(groupId);
875
917
  if (timedOut || marker || reentryMarker || groupSurvived) {
876
918
  try {
877
919
  terminateUnixProcessGroup(groupId);
878
- } catch {
879
- return "cleanup";
920
+ } catch (error) {
921
+ return shimProbeCleanup("termination", error, probeStatus, probeSignal);
880
922
  }
881
923
  }
882
- if (result.error && !timedOut) return "cleanup";
924
+ if (result.error && !timedOut) return shimProbeCleanup("spawn", result.error, probeStatus, probeSignal);
883
925
  if (timedOut || marker === "timeout") return "timeout";
884
926
  if (marker === "recursive" || reentryMarker === "recursive") return "recursive";
885
- if (reentryMarker !== "") return "cleanup";
927
+ if (reentryMarker !== "") return shimProbeCleanup("reentry", undefined, probeStatus, probeSignal);
886
928
  if (marker === "descendants") return "descendants";
887
929
  if (groupSurvived) return "descendants";
888
930
  if (result.status === CODEX_SHIM_REENTRY_EXIT_CODE && launcherStderr.includes(CODEX_SHIM_REENTRY_DIAGNOSTIC)) {
@@ -890,11 +932,11 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult {
890
932
  }
891
933
  if (result.status !== 0) return "failed";
892
934
  return null;
893
- } catch {
935
+ } catch (error) {
894
936
  if (Number.isInteger(groupId) && groupId > 0) {
895
937
  try { terminateUnixProcessGroup(groupId); } catch { /* cleanup classification below */ }
896
938
  }
897
- return "cleanup";
939
+ return shimProbeCleanup("exception", error, probeStatus, probeSignal);
898
940
  } finally {
899
941
  try { rmSync(probeDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
900
942
  }
@@ -2215,8 +2257,8 @@ function installCodexShimInternal(options: InstallCodexShimInternalOptions): { i
2215
2257
  ? `the saved launcher did not finish --version within ${CODEX_SHIM_INSTALL_PROBE_TIMEOUT_MS}ms`
2216
2258
  : unsafe === "descendants"
2217
2259
  ? "the saved launcher left background descendants running after --version"
2218
- : unsafe === "cleanup"
2219
- ? "the saved launcher's probe process group could not be terminated cleanly"
2260
+ : unsafe !== null && typeof unsafe === "object"
2261
+ ? `the saved launcher's probe process group could not be terminated cleanly [phase=${unsafe.phase}; code=${unsafe.code}; status=${unsafe.status ?? "none"}; signal=${unsafe.signal}]`
2220
2262
  : "the saved launcher failed its --version probe";
2221
2263
  return {
2222
2264
  installed: false,
@@ -38,7 +38,7 @@ import {
38
38
  import { routeModel, type RouteResult } from "../router";
39
39
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
40
40
  import { codexAccountNamespaceForModel } from "./account-namespace-match";
41
- import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
41
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models";
42
42
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
43
43
  import {
44
44
  getUpstreamHostHealth,
@@ -59,6 +59,8 @@ export type SubagentPoolAccountPreview = (
59
59
  export type SubagentModelEligibleAccountIds = (
60
60
  modelId: string | undefined,
61
61
  ) => ReadonlySet<string> | undefined;
62
+ /** Additional resolved routes that a restricted fallback caller has independently approved. */
63
+ export type SubagentFallbackRouteEligibility = (route: RouteResult) => boolean;
62
64
  let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null;
63
65
  let quotaPrimeInFlight: Promise<void> | null = null;
64
66
 
@@ -328,9 +330,17 @@ export function isSubagentModelUnavailable(
328
330
  // preserve the credential fence. If no non-main candidate can serve an unqualified
329
331
  // gated model, retain main only as a read-free sentinel: final auth owns the atomic
330
332
  // claim and returns maintenance instead of letting a routed fallback bypass it.
333
+ //
334
+ // The predicate is its OWN set, not the account-gated one. The sentinel protects the atomic
335
+ // main claim during a drain, which has nothing to do with entitlement; it read the gated set
336
+ // only because the two happened to hold the same slugs. Ungating the flagships (2026-09-04)
337
+ // would have flipped this false and let a drain silently rewrite the operator's configured
338
+ // subagent model instead of reporting maintenance -- a different model answering than was
339
+ // chosen. The set is explicit rather than every supported native, so gpt-5.5 and friends keep
340
+ // their existing fall-back-and-answer behaviour.
331
341
  const preserveDrainingMainCandidate = route.codexAccountId === undefined
332
342
  && candidateAccountUsabilityOptions?.nativeMainSelectionOnly === true
333
- && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId);
343
+ && NATIVE_MAIN_DRAIN_SENTINEL_MODELS.has(route.modelId);
334
344
  if (!preserveDrainingMainCandidate) return true;
335
345
  const drainingMainUsabilityOptions: CodexAccountUsabilityOptions = {
336
346
  ...candidateAccountUsabilityOptions,
@@ -356,13 +366,21 @@ export function selectAvailableSubagentModel(
356
366
  poolAccountPreview?: SubagentPoolAccountPreview,
357
367
  modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds,
358
368
  resolvedChain?: readonly string[],
369
+ restrictedRouteEligible?: SubagentFallbackRouteEligibility,
359
370
  ): { model: string; rewritten: boolean; skipped: string[] } {
360
371
  const chain = resolvedChain ?? normalizedChain(primary, config, extraFallback, trailingFallback);
361
372
  const skipped: string[] = [];
362
373
  for (const candidate of chain) {
363
374
  if (nativeFallbackOnly) {
364
375
  const route = tryRouteFallbackModel(config, candidate);
365
- if (!route || !isCanonicalOpenAiForwardProvider(route.provider)) {
376
+ if (
377
+ !route
378
+ || route.combo !== undefined
379
+ || (
380
+ !isCanonicalOpenAiForwardProvider(route.provider)
381
+ && restrictedRouteEligible?.(route) !== true
382
+ )
383
+ ) {
366
384
  skipped.push(candidate);
367
385
  continue;
368
386
  }
@@ -607,6 +625,7 @@ export function applySubagentModelFallback(
607
625
  poolAccountPreview?: SubagentPoolAccountPreview,
608
626
  modelEligibleAccountIdsForModel?: SubagentModelEligibleAccountIds,
609
627
  resolvedFallbackChain?: readonly string[] | null,
628
+ restrictedRouteEligible?: SubagentFallbackRouteEligibility,
610
629
  ): { from?: string; to?: string; skipped?: string[] } | null {
611
630
  if (!isThreadSpawnRequest(headers)) return null;
612
631
  const fallbackChain = resolvedFallbackChain === undefined
@@ -625,6 +644,7 @@ export function applySubagentModelFallback(
625
644
  poolAccountPreview,
626
645
  modelEligibleAccountIdsForModel,
627
646
  fallbackChain,
647
+ restrictedRouteEligible,
628
648
  );
629
649
  if (!selection.rewritten) return selection.skipped.length > 0
630
650
  ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }