@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -1,4 +1,69 @@
1
1
  /** Quota wire/storage shapes. This leaf must not import credential or config owners. */
2
+ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
3
+
4
+ /**
5
+ * How recently a 100% burst reading must have been observed to exclude an account when it
6
+ * carries no reset timestamp (#3425). This is deliberately far tighter than the disk-hydration
7
+ * horizon so a persisted reading cannot strand a recovered account. Routing and UI share this
8
+ * value because both must answer whether the same short-window observation is still current.
9
+ */
10
+ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
11
+
12
+ /**
13
+ * A window reading at or above this is a measured refusal, not a position on a scale.
14
+ *
15
+ * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
16
+ * "we have not observed this account", 100 is "we observed it and it is full". It lives on
17
+ * this leaf, next to the freshness window, because the dashboard has to answer the same
18
+ * question and cannot import the routing or disk-cache owners to do it.
19
+ */
20
+ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
21
+
22
+ /**
23
+ * Above this a value is already milliseconds; at or below it, it is Unix seconds.
24
+ *
25
+ * Both reach storage, so the split has to live somewhere every reader can see. It lives on this
26
+ * leaf rather than beside the merge that uses it because the dashboard reads the same stored
27
+ * value and cannot import the disk-cache owner.
28
+ */
29
+ const RESET_AT_SECONDS_MAX = 10_000_000_000;
30
+
31
+ /** Normalize a stored reset instant to milliseconds. */
32
+ export function resetAtToMs(resetAt: number): number {
33
+ return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
34
+ }
35
+
36
+ /**
37
+ * A short-only reading that proves the account is blocked NOW.
38
+ *
39
+ * Routing and the dashboard's account-switch warning must answer this identically for the same
40
+ * snapshot, or the warning tells an operator an account is usable while the router refuses it.
41
+ * They did not: the dashboard compared a stored reset against `Date.now()` without normalizing
42
+ * units, so a seconds-form future reset looked expired there and live here, and it treated a
43
+ * fresh observation as sufficient even when an ELAPSED reset was present, where routing treats
44
+ * the reset as authoritative once it exists (#5045).
45
+ *
46
+ * Freshness is not optional in the reset-less branch. `getAccountQuota` performs no expiry
47
+ * check, partial updates carry a still-open short tuple forward, and disk hydration accepts a
48
+ * persisted reading for hours, so scoring exhausted from `shortPercent` alone would keep
49
+ * excluding an account whose burst window has since reset.
50
+ */
51
+ export function isTerminalShortWindow(
52
+ quota: Pick<StoredAccountQuota, "shortPercent" | "shortResetAt" | "shortObservedAt">,
53
+ now: number,
54
+ ): boolean {
55
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
56
+ if (!finite(quota.shortPercent) || quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
57
+ const resetAt = quota.shortResetAt;
58
+ if (!finite(resetAt) || resetAt <= 0) {
59
+ const observedAt = quota.shortObservedAt;
60
+ if (!finite(observedAt)) return false;
61
+ const age = now - observedAt;
62
+ return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
63
+ }
64
+ return resetAtToMs(resetAt) > now;
65
+ }
66
+
2
67
  export type StoredAccountQuota = {
3
68
  weeklyPercent?: number;
4
69
  monthlyPercent?: number;
@@ -8,8 +8,11 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
8
8
 
9
9
  import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
10
10
  import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
11
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_PERCENT, resetAtToMs } from "./quota-types";
11
12
  import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
13
+
12
14
  export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
15
+ export { CODEX_EXHAUSTED_USAGE_PERCENT } from "./quota-types";
13
16
 
14
17
  /** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */
15
18
  const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
@@ -54,14 +57,13 @@ const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60;
54
57
  * Both units reach storage — `normalizeResetAt` does not scale, and the GUI disambiguates by
55
58
  * magnitude at read time — so a comparison written against one assumption is off by 1000x
56
59
  * against the other. In the seconds-read-as-milliseconds direction every reading looks like it
57
- * elapsed in 1970, which is a check that passes its own test and does nothing. Exported so
58
- * `isTerminalShortWindow` in routing.ts shares this one split instead of repeating the literal.
60
+ * elapsed in 1970, which is a check that passes its own test and does nothing.
61
+ *
62
+ * The split now lives on `./quota-types`, the leaf the dashboard can also import, because the
63
+ * dashboard was the reader that did not have it (#5045). Re-exported here so the existing
64
+ * callers of this module keep their import path.
59
65
  */
60
- const RESET_AT_SECONDS_MAX = 10_000_000_000;
61
-
62
- export function resetAtToMs(resetAt: number): number {
63
- return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
64
- }
66
+ export { resetAtToMs };
65
67
 
66
68
  const accountQuota = new Map<string, StoredAccountQuota>();
67
69
  const quotaHistory = new CodexQuotaHistory();
@@ -75,14 +77,6 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
75
77
  // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
76
78
  // actually exhausted account is still eligible for threshold rotation.
77
79
  export const CODEX_UNKNOWN_USAGE_SCORE = 101;
78
- /**
79
- * A window reading at or above this is a measured refusal, not a position on a scale.
80
- *
81
- * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
82
- * "we have not observed this account", 100 is "we observed it and it is full".
83
- */
84
- export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
85
-
86
80
  export function isCodexQuotaExhausted(
87
81
  quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
88
82
  plan?: unknown,
@@ -146,7 +140,7 @@ export function isCompleteCodexQuotaRecoverySnapshot(
146
140
  return quota.monthlyIsPrimaryWindow === true && finitePercent(quota.monthlyPercent);
147
141
  }
148
142
 
149
- function finitePercent(value: number | undefined): boolean {
143
+ function finitePercent(value: number | undefined): value is number {
150
144
  return typeof value === "number" && Number.isFinite(value);
151
145
  }
152
146
 
@@ -245,14 +239,20 @@ function shortResetHasElapsed(resetAt: number | undefined, now: number): boolean
245
239
  return resetAtToMs(resetAt) <= now;
246
240
  }
247
241
 
248
- /** Display/rotation carry expires; a reset clock cannot retract hard-lock evidence. */
242
+ /** Display/rotation carry expires; a reset clock cannot retract blocking hard-lock evidence. */
249
243
  function assignCarriedShort(
250
244
  next: StoredAccountQuota,
251
245
  existing: StoredAccountQuota | undefined,
252
246
  now: number,
253
247
  policyEvidence = false,
254
248
  ): void {
255
- if (!existing || (!policyEvidence && shortResetHasElapsed(existing.shortResetAt, now))) return;
249
+ if (!existing) return;
250
+ const existingShortPercent = existing.shortPercent;
251
+ const preserveBlockingEvidence = policyEvidence
252
+ && finitePercent(existingShortPercent)
253
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
254
+ && existingShortPercent <= 100;
255
+ if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return;
256
256
  if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
257
257
  if (existing.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
258
258
  if (existing.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
@@ -363,7 +363,12 @@ function mergeAccountQuota(
363
363
  if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
364
364
  }
365
365
 
366
- const preserveKnownShort = policyEvidence && quota.shortPercent === undefined && finitePercent(existing?.shortPercent);
366
+ const existingShortPercent = existing?.shortPercent;
367
+ const preserveKnownShort = policyEvidence
368
+ && quota.shortPercent === undefined
369
+ && finitePercent(existingShortPercent)
370
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
371
+ && existingShortPercent <= 100;
367
372
  if (snapshotHasShort(quota) && !preserveKnownShort) {
368
373
  if (quota.shortPercent !== undefined) {
369
374
  next.shortPercent = quota.shortPercent;
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  CODEX_EXHAUSTED_USAGE_PERCENT,
3
3
  CODEX_UNKNOWN_USAGE_SCORE,
4
- resetAtToMs,
5
4
  } from "../quota";
6
5
  import { isThirtyDayOnlyCodexPlan } from "../plan";
6
+ import { isTerminalShortWindow } from "../quota-types";
7
7
  import type { CodexQuotaScope } from "./health-store";
8
8
  import type { TransientProbeGrant } from "./thread-affinity";
9
9
 
10
+ export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
11
+
10
12
  export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
11
13
  export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
12
14
  /**
@@ -25,14 +27,6 @@ export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
25
27
  /** Minimum gap between probe leases for one cooled-down account. */
26
28
  export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
27
29
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
28
- /**
29
- * How recently a 100% burst reading must have been OBSERVED to exclude an account when it
30
- * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
31
- * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
32
- * reading can never strand a recovered account, and long enough that a snapshot taken at
33
- * admission is still fresh when selection reads it.
34
- */
35
- export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
36
30
  /** How long a transient failure keeps the account out of pool selection. */
37
31
  export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
38
32
  export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
@@ -138,44 +132,11 @@ export function computeCodexUsageScore(quota: {
138
132
  return Math.max(...values);
139
133
  }
140
134
 
141
- /**
142
- * A short-only reading that proves the account is blocked NOW.
143
- *
144
- * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
145
- * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
146
- * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
147
- * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
148
- * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
149
- * That is #3029 pointed the other way: the issue is that
150
- * an exhausted account stays selected, and "a recovered account stays excluded" trades one
151
- * unusable pool for another.
152
- *
153
- * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
154
- * direction here is the one that keeps an account selectable: a wrongly-selected account
155
- * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
156
- * by hand.
157
- *
158
- * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
159
- * sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
160
- * Old disk snapshots without short-window provenance remain unknown.
161
- */
162
- function isTerminalShortWindow(
163
- quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
164
- now: number,
165
- ): boolean {
166
- if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
167
- if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
168
- const resetAt = quota.shortResetAt;
169
- if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
170
- const observedAt = quota.shortObservedAt;
171
- if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
172
- const age = now - observedAt;
173
- return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
174
- }
175
- // Seconds and milliseconds both reach storage, so the split lives in one place next to the
176
- // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
177
- return resetAtToMs(resetAt) > now;
178
- }
135
+ // `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing
136
+ // and the account-switch warning have to answer this identically for the same snapshot, and
137
+ // they did not: see the note on the shared function (#5045). Its #3029 and #3425 reasoning —
138
+ // why freshness is not optional, and why a reading with no reset and no observation stays
139
+ // unknown rather than exhausted — moved with it.
179
140
 
180
141
  export function classifyCodexUpstreamOutcome(
181
142
  outcome: CodexUpstreamOutcome,
@@ -0,0 +1,57 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isCodexAccountPaused } from "../account-pause";
3
+ import { isAccountNeedsReauth } from "../account-runtime-state";
4
+ import type { CodexAccountUsabilityOptions } from "../account-usability";
5
+ import { isCodexAccountUsable } from "../account-usability";
6
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
7
+ import { hasCodexQuotaHeadroom } from "./selection";
8
+
9
+ /** Why routing would drop a manual pin, or undefined when the pin survives. */
10
+ export type CodexPinDrainReason = "needs_reauth" | "paused" | "unusable" | "quota_threshold";
11
+
12
+ /**
13
+ * Would routing release a pin on this account the next time it resolves?
14
+ *
15
+ * This lives beside selection rather than inside `routing.ts` for the same reason
16
+ * {@link ./cache-affinity} does: it is a policy question asked by two callers that must answer
17
+ * identically. One is `releaseDrainedCodexAccountPin`, which acts on it. The other is
18
+ * `PUT /api/codex-auth/active`, which reports it.
19
+ *
20
+ * The two-step contradiction in #4521 is what happens when only the first exists. That route
21
+ * validates existence, pause and pending validation, answers 200, and says nothing about quota;
22
+ * the very next resolve runs this rule and drops the pin. The operator sees a setting accepted
23
+ * and then ignored. Reporting it from the same predicate, rather than from a second copy on the
24
+ * accepting surface, is what keeps the answer and the action from drifting -- a client-side
25
+ * re-derivation of the usage score has to track {@link ./cooldown-math} exactly, including the
26
+ * Free/Go plan windows and short-window freshness.
27
+ *
28
+ * Ordering is load-bearing. Cached reauth and configured pause are classified FIRST, because
29
+ * they hold for the main account even while its fenced native profile is unreadable; a
30
+ * selection-only caller then makes every later classification answer "no drain", so reading
31
+ * reauth after that guard would make a pin on a signed-out main look durable.
32
+ *
33
+ * This answers only whether the pin survives. It is not an admission check: the caller that
34
+ * acts on it releases a preference, and every involuntary release -- quota refusal, failover
35
+ * streak, cooldown, lost generation, affinity expiry -- is decided elsewhere and earlier.
36
+ */
37
+ export function codexAccountPinDrainReason(
38
+ config: OcxConfig,
39
+ accountId: string,
40
+ selectionOptions?: Pick<
41
+ CodexAccountUsabilityOptions,
42
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
43
+ >,
44
+ now: number = Date.now(),
45
+ ): CodexPinDrainReason | undefined {
46
+ if (isAccountNeedsReauth(accountId)) return "needs_reauth";
47
+ if (isCodexAccountPaused(config, accountId)) return "paused";
48
+ // Temporary drain deliberately forbids every native-main read. A pin on main cannot be
49
+ // classified by credential liveness or quota until the fenced profile is readable. Cached
50
+ // reauth and configured pause state were handled above.
51
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
52
+ return undefined;
53
+ }
54
+ if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
55
+ if (!hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)) return "quota_threshold";
56
+ return undefined;
57
+ }
@@ -4,7 +4,8 @@ import { codexAccountLogLabel } from "./account-label";
4
4
  import { isCodexAccountPaused } from "./account-pause";
5
5
  import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
6
6
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
7
- import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
7
+ import { markAccountNeedsReauth } from "./account-runtime-state";
8
+ import { codexAccountPinDrainReason } from "./routing/pin-drain";
8
9
  import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation";
9
10
  import { getAccountQuota, isRetiredCodexSparkModel } from "./quota";
10
11
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
@@ -202,6 +203,8 @@ export {
202
203
  getEffectiveActiveCodexAccountId,
203
204
  isEffectiveCodexAccountPinned,
204
205
  } from "./routing/active-account";
206
+ export { codexAccountPinDrainReason } from "./routing/pin-drain";
207
+ export type { CodexPinDrainReason } from "./routing/pin-drain";
205
208
  function hasConfiguredPoolAccount(
206
209
  config: OcxConfig,
207
210
  accountId: string,
@@ -392,7 +395,14 @@ function pickLineageServingAccount(
392
395
  selectionOptions?: CodexAccountUsabilityOptions,
393
396
  modelId?: string,
394
397
  ): { accountId: string; reason: CodexAffinityReason } | null {
395
- if (lineage.parentConversationKey !== undefined) {
398
+ // Cohort keying (#4780) makes a tree share one key, so for a same-session family the parent's
399
+ // key IS this request's and the lookup below would re-ask a question the caller already
400
+ // answered by finding no binding entry. What remains is the case cohort keying cannot unify:
401
+ // a session-less chain whose parent this scope has not recorded, where the keys differ.
402
+ if (
403
+ lineage.parentConversationKey !== undefined
404
+ && lineage.parentConversationKey !== lineage.conversationKey
405
+ ) {
396
406
  const parent = lineageServingAccountId(
397
407
  lineage.parentConversationKey, config, now, quotaScope, selectionOptions, modelId,
398
408
  );
@@ -455,19 +465,7 @@ function releaseDrainedCodexAccountPin(
455
465
  ): void {
456
466
  const pinned = pinnedCodexAccountId(config);
457
467
  if (pinned === undefined) return;
458
- const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned);
459
- if (knownUnavailable) {
460
- clearCodexAccountPin(config);
461
- saveConfigPreservingClaudeCode(config);
462
- return;
463
- }
464
- // Temporary drain deliberately forbids every native-main read. A pin on main
465
- // cannot be classified by credential liveness or quota until the fenced profile
466
- // is readable. Cached reauth and configured pause state were handled above.
467
- if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
468
- const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
469
- || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now);
470
- if (!drained) return;
468
+ if (codexAccountPinDrainReason(config, pinned, selectionOptions, now) === undefined) return;
471
469
  clearCodexAccountPin(config);
472
470
  saveConfigPreservingClaudeCode(config);
473
471
  }
@@ -909,6 +909,100 @@ export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME)
909
909
  return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
910
910
  }
911
911
 
912
+ const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/;
913
+
914
+ /**
915
+ * TOML-aware read of the root `model` pin, or null when there is none.
916
+ *
917
+ * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted
918
+ * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong
919
+ * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`,
920
+ * an indented key, or a trailing comment are all valid TOML that Codex honours and that a
921
+ * stricter matcher would report as unpinned.
922
+ *
923
+ * It shares the scanner used for `model_fallback` for the reason that matters here: an imported
924
+ * role file keeps its instructions in a multiline string, and that string contains the very words
925
+ * this scan looks for. A line matcher would read a key out of prose.
926
+ *
927
+ * Table context is not tracked, matching the `model_fallback` parse. A `model` key under a later
928
+ * table header would be read as the root pin; Codex role files are flat in practice, and for a
929
+ * warning the conservative direction is to stay quiet.
930
+ */
931
+ function parseTomlModelPin(content: string): string | null {
932
+ const lines = content.split(/\r?\n/);
933
+ const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 };
934
+ for (let i = 0; i < lines.length; i += 1) {
935
+ const line = lines[i]!;
936
+ if (state.inMultilineString) {
937
+ const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!);
938
+ if (end === -1) continue;
939
+ state.inMultilineString = null;
940
+ scanTomlLine(line.slice(end + 3), state);
941
+ continue;
942
+ }
943
+ if (state.arrayDepth === 0) {
944
+ const key = line.match(TOML_MODEL_KEY);
945
+ if (key) {
946
+ const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`;
947
+ let at = 0;
948
+ while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1;
949
+ if (rest[at] !== '"' && rest[at] !== "'") return null;
950
+ const value = parseTomlStringAt(rest, at)?.value.trim() ?? "";
951
+ return value === "" ? null : value;
952
+ }
953
+ }
954
+ scanTomlLine(line, state);
955
+ }
956
+ return null;
957
+ }
958
+
959
+ /** Filename prefix opencodex gives the Claude agents it generates. */
960
+ const OPENCODEX_DERIVED_ROLE_PREFIX = "ocx-";
961
+
962
+ /**
963
+ * Body markers that survive the Codex desktop external-agent import.
964
+ *
965
+ * The import carries the generated Claude agent's instructions across, so both the provenance
966
+ * marker and the routing directive end up inside the role TOML. `ocx-route:` is matched without
967
+ * its `<!--` comment prefix on purpose: the same import text-replaces "Claude Code" with "Codex"
968
+ * inside that body, so anything around the directive should be assumed rewritten.
969
+ */
970
+ const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"] as const;
971
+
972
+ /**
973
+ * Roles that look opencodex-derived but pin no model, so Codex runs them on the parent model.
974
+ *
975
+ * opencodex does not write Codex role TOMLs. These arrive when the Codex desktop external-agent
976
+ * import converts `~/.claude/agents/ocx-*.md` into `$CODEX_HOME/agents/ocx-*.toml`, dropping the
977
+ * `model:` frontmatter because a `claude-ocx-native--` id is not a Codex model and keeping only the
978
+ * instructions. The surviving `ocx-route` directive cannot make up the difference: it is honoured
979
+ * only on the Claude `/v1/messages` path and is inert on `/v1/responses`. So the role file names
980
+ * one model while every spawn runs on another, which is invisible until someone diffs
981
+ * `session_meta.agent_role` against `turn_context.model` (#4790).
982
+ *
983
+ * Detection is a heuristic for a warning, deliberately not an ownership claim. Nothing here
984
+ * authorizes writing to, repairing, or removing these files, and the marker-based ownership rules
985
+ * that govern the files opencodex does write are unchanged.
986
+ */
987
+ export function scanOpencodexDerivedCodexAgentRolesWithoutModelPin(codexHome = CODEX_HOME): string[] {
988
+ const findings: string[] = [];
989
+ for (const role of listCodexAgentRoles(codexHome)) {
990
+ let content: string;
991
+ try {
992
+ content = readFileSync(join(codexHome, "agents", `${role}.toml`), "utf8");
993
+ } catch {
994
+ // An unreadable file is not evidence of a missing pin.
995
+ continue;
996
+ }
997
+ const derived = role.startsWith(OPENCODEX_DERIVED_ROLE_PREFIX)
998
+ || OPENCODEX_DERIVED_ROLE_MARKERS.some(marker => content.includes(marker));
999
+ if (!derived) continue;
1000
+ if (parseTomlModelPin(content) !== null) continue;
1001
+ findings.push(role);
1002
+ }
1003
+ return findings.sort();
1004
+ }
1005
+
912
1006
  export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
913
1007
  const dir = join(codexHome, "agents");
914
1008
  if (!existsSync(dir)) return [];