@bitkyc08/opencodex 2.90.0 → 2.92.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -10,13 +10,18 @@
10
10
  * This rewrites one thing: a saved `models` list that is still byte-for-byte the roster
11
11
  * this file names as `from`, on a provider that still carries the registry adapter, and
12
12
  * only while the registry currently seeds `to`. A list the user changed does not match and
13
- * is left alone. For each added id it also fills a per-model context window and default
14
- * effort, but only inside a container the row already has: enrichment fills those records
15
- * all-or-nothing, so creating one here would hide the rest of the registry seed.
13
+ * is left alone. For each added id it also fills a per-model context window, output limit
14
+ * and default effort, but only inside a container the row already has: enrichment fills
15
+ * those records all-or-nothing, so creating one here would hide the rest of the registry seed.
16
16
  * Same shape and restraint as `stale-context-window-migration`.
17
17
  */
18
- import { PROVIDER_REGISTRY } from "./registry";
18
+ import { ALIBABA_INTL_TOKEN_PLAN_BASE_URL } from "./base-url-choices";
19
+ import { PROVIDER_REGISTRY, normalizedProviderEndpoint } from "./registry";
19
20
  import {
21
+ ALIBABA_TOKEN_PLAN_MODELS,
22
+ ALIBABA_INTL_TOKEN_PLAN_MODELS,
23
+ ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
24
+ ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
20
25
  MINIMAX_M31_DEFAULT_REASONING_EFFORT,
21
26
  MINIMAX_M31_FLASH_PREVIEW,
22
27
  MINIMAX_MODELS,
@@ -33,7 +38,18 @@ export interface StaleModelRoster {
33
38
  to: readonly string[];
34
39
  /** Per-model seed values for the added ids, written only into containers that exist. */
35
40
  contextWindows?: Readonly<Record<string, number>>;
41
+ /**
42
+ * Custom catalog rows read the saved map directly, so an added id missing from a
43
+ * partial map would publish without the registry output limit.
44
+ */
45
+ maxOutputTokens?: Readonly<Record<string, number>>;
36
46
  defaultReasoningEfforts?: Readonly<Record<string, string>>;
47
+ /**
48
+ * For a provider whose saved URL may point elsewhere (pay-as-you-go or Custom), the
49
+ * destinations whose roster `to` describes. A row on any other URL keeps its list; a row
50
+ * with no saved URL uses the registry default and is refreshed.
51
+ */
52
+ destinationBaseUrls?: readonly string[];
37
53
  }
38
54
 
39
55
  export interface StaleModelRosterProjection {
@@ -42,17 +58,45 @@ export interface StaleModelRosterProjection {
42
58
  warnings: string[];
43
59
  }
44
60
 
61
+ const ALIBABA_TOKEN_PLAN_MODELS_BEFORE_1017 = [
62
+ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash",
63
+ "deepseek-v4-pro", "deepseek-v4-flash-0731", "deepseek-v4.1-flash", "glm-5.2", "glm-5.3",
64
+ ];
65
+ const ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017 = [
66
+ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
67
+ "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash", "deepseek-v4-flash-0731", "deepseek-v4.1-flash", "deepseek-v3.2",
68
+ "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
69
+ "glm-5.2", "glm-5.3", "glm-5.1", "glm-5",
70
+ "MiniMax-M2.5",
71
+ ];
72
+
45
73
  /**
46
74
  * MiniMax-M3.1-Flash-Preview (2026-09-30). MiniMax's /v1/models roster does not list the
47
75
  * preview, so live discovery cannot add it; the catalog keeps it only when it is configured.
48
76
  */
49
- export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = ["minimax", "minimax-cn"].map(provider => ({
50
- provider,
51
- from: MINIMAX_MODELS_BEFORE_M31,
52
- to: MINIMAX_MODELS,
53
- contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! },
54
- defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT },
55
- }));
77
+ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [
78
+ ...["minimax", "minimax-cn"].map(provider => ({
79
+ provider,
80
+ from: MINIMAX_MODELS_BEFORE_M31,
81
+ to: MINIMAX_MODELS,
82
+ contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! },
83
+ defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT },
84
+ })),
85
+ ...[
86
+ { provider: "alibaba-token-plan", from: ALIBABA_TOKEN_PLAN_MODELS_BEFORE_1017, to: ALIBABA_TOKEN_PLAN_MODELS },
87
+ // International honors pay-as-you-go and Custom URLs, which may still serve the retired ids.
88
+ // Beijing needs no guard: the router pins it to the registry Token Plan host.
89
+ {
90
+ provider: "alibaba-token-plan-intl", from: ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017, to: ALIBABA_INTL_TOKEN_PLAN_MODELS,
91
+ destinationBaseUrls: [ALIBABA_INTL_TOKEN_PLAN_BASE_URL],
92
+ },
93
+ ].map(entry => ({
94
+ ...entry,
95
+ contextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
96
+ maxOutputTokens: ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
97
+ defaultReasoningEfforts: { "deepseek-v4.1-flash-1017": "high" },
98
+ })),
99
+ ];
56
100
 
57
101
  function sameList(value: unknown, expected: readonly string[]): boolean {
58
102
  return Array.isArray(value)
@@ -65,12 +109,24 @@ function registrySeeds(provider: string, adapter: unknown, roster: readonly stri
65
109
  return entry !== undefined && entry.adapter === adapter && sameList(entry.models, roster);
66
110
  }
67
111
 
68
- function fillExisting<T>(container: Record<string, T> | undefined, values: Readonly<Record<string, T>> | undefined, ids: readonly string[]): void {
69
- if (!container || !values) return;
112
+ function servesRoster(baseUrl: unknown, destinations: readonly string[] | undefined): boolean {
113
+ if (!destinations || baseUrl === undefined || baseUrl === "") return true;
114
+ if (typeof baseUrl !== "string") return false;
115
+ const saved = normalizedProviderEndpoint(baseUrl);
116
+ return destinations.some(url => normalizedProviderEndpoint(url) === saved);
117
+ }
118
+
119
+ function fillExisting<T>(container: Record<string, T> | undefined, values: Readonly<Record<string, T>> | undefined, ids: readonly string[]): boolean {
120
+ if (!container || !values) return false;
121
+ let filled = false;
70
122
  for (const id of ids) {
71
123
  const value = values[id];
72
- if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) container[id] = value;
124
+ if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) {
125
+ container[id] = value;
126
+ filled = true;
127
+ }
73
128
  }
129
+ return filled;
74
130
  }
75
131
 
76
132
  /** Pure projection. The caller decides whether to persist. */
@@ -85,10 +141,22 @@ export function projectStaleModelRosters(
85
141
  const prov = config.providers?.[entry.provider];
86
142
  if (!prov) continue;
87
143
  if (!registrySeeds(entry.provider, prov.adapter, entry.to)) continue;
88
- if (!sameList(prov.models, entry.from)) continue;
144
+ if (!servesRoster(prov.baseUrl, entry.destinationBaseUrls)) continue;
89
145
  const added = entry.to.filter(id => !entry.from.includes(id));
146
+ if (sameList(prov.models, entry.to)) {
147
+ // A row refreshed before output limits joined the refresh still has a partial map.
148
+ // Only that record is revisited: the earlier pass already filled windows and efforts,
149
+ // so refilling them could restore a key the operator has since removed.
150
+ if (fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added)) {
151
+ changed = true;
152
+ warnings.push(`filled output limits for ${added.join(", ")} on the saved "${entry.provider}" row.`);
153
+ }
154
+ continue;
155
+ }
156
+ if (!sameList(prov.models, entry.from)) continue;
90
157
  prov.models = [...entry.to];
91
158
  fillExisting(prov.modelContextWindows, entry.contextWindows, added);
159
+ fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added);
92
160
  fillExisting(prov.modelDefaultReasoningEfforts, entry.defaultReasoningEfforts, added);
93
161
  changed = true;
94
162
  warnings.push(
@@ -0,0 +1,81 @@
1
+ export interface WorkBuddyCreditAmount {
2
+ total: number;
3
+ used: number;
4
+ remaining: number;
5
+ frozen: number;
6
+ }
7
+
8
+ export const WORKBUDDY_CREDIT_KINDS = ["plan_base", "plan_bonus", "addon", "benefit", "other"] as const;
9
+ export type WorkBuddyCreditKind = typeof WORKBUDDY_CREDIT_KINDS[number];
10
+
11
+ /** Display-only credits; they are neither USD nor provider-wide model eligibility. */
12
+ export interface WorkBuddyCreditBalance extends WorkBuddyCreditAmount {
13
+ groups: Array<WorkBuddyCreditAmount & { kind: WorkBuddyCreditKind }>;
14
+ expiring?: { amount: number; at: number };
15
+ }
16
+
17
+ export function workBuddyRecord(value: unknown): Record<string, unknown> {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
19
+ }
20
+
21
+ export function workBuddyCapacity(value: unknown): number | undefined {
22
+ if (typeof value !== "number" && !(typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value))) return undefined;
23
+ const number = Number(value);
24
+ return Number.isFinite(number) && number >= 0 && number <= Number.MAX_SAFE_INTEGER ? number : undefined;
25
+ }
26
+
27
+ function amount(value: unknown): WorkBuddyCreditAmount | undefined {
28
+ const row = workBuddyRecord(value);
29
+ const total = workBuddyCapacity(row.total), used = workBuddyCapacity(row.used);
30
+ const remaining = workBuddyCapacity(row.remaining), frozen = workBuddyCapacity(row.frozen);
31
+ if (total === undefined || used === undefined || remaining === undefined || frozen === undefined) return undefined;
32
+ return { total, used, remaining, frozen };
33
+ }
34
+
35
+ /** Narrow wire/cache input before rendering, without admitting account or package identifiers. */
36
+ export function normalizeWorkBuddyCredits(value: unknown): WorkBuddyCreditBalance | undefined {
37
+ const row = workBuddyRecord(value), balance = amount(value);
38
+ if (!balance || !Array.isArray(row.groups) || row.groups.length > WORKBUDDY_CREDIT_KINDS.length) return undefined;
39
+ const groups: WorkBuddyCreditBalance["groups"] = [];
40
+ const seen = new Set<string>();
41
+ for (const raw of row.groups) {
42
+ const group = workBuddyRecord(raw), quantities = amount(group);
43
+ const kind = WORKBUDDY_CREDIT_KINDS.find(kind => kind === group.kind);
44
+ if (!quantities || !kind || seen.has(kind)) return undefined;
45
+ seen.add(kind); groups.push({ kind, ...quantities });
46
+ }
47
+ const expiring = workBuddyRecord(row.expiring), quantity = workBuddyCapacity(expiring.amount);
48
+ const at = typeof expiring.at === "number" && expiring.at > 0 && Number.isFinite(new Date(expiring.at).getTime()) ? expiring.at : undefined;
49
+ return { ...balance, groups, ...(quantity !== undefined && quantity > 0 && at !== undefined ? { expiring: { amount: quantity, at } } : {}) };
50
+ }
51
+
52
+ function packageAmount(row: Record<string, unknown>): WorkBuddyCreditAmount | undefined {
53
+ const total = workBuddyCapacity(row.CycleTotalCapacity), used = workBuddyCapacity(row.CycleUsedCapacity);
54
+ const frozen = row.CycleFrozenCapacity === undefined ? 0 : workBuddyCapacity(row.CycleFrozenCapacity);
55
+ if (total === undefined || used === undefined || frozen === undefined) return undefined;
56
+ const remaining = row.CycleRemainCapacity === undefined ? Math.max(0, total - used - frozen) : workBuddyCapacity(row.CycleRemainCapacity);
57
+ return remaining === undefined ? undefined : { total, used, remaining, frozen };
58
+ }
59
+
60
+ export function parseWorkBuddyCredits(value: unknown): WorkBuddyCreditBalance | undefined {
61
+ const summary = workBuddyRecord(value);
62
+ if (!Array.isArray(summary.Packages) || summary.Packages.length > 512) return undefined;
63
+ const balance: WorkBuddyCreditBalance = { total: 0, used: 0, remaining: 0, frozen: 0, groups: [] };
64
+ for (const raw of summary.Packages) {
65
+ const quantities = packageAmount(workBuddyRecord(raw));
66
+ if (!quantities) return undefined;
67
+ for (const key of ["total", "used", "remaining", "frozen"] as const) balance[key] += quantities[key];
68
+ }
69
+ // CreditGroups is a breakdown of Packages, not another balance to add to it.
70
+ if (Array.isArray(summary.CreditGroups) && summary.CreditGroups.length <= 16) {
71
+ for (const raw of summary.CreditGroups) {
72
+ const row = workBuddyRecord(raw), quantities = packageAmount(row);
73
+ if (!quantities) continue;
74
+ const kind = WORKBUDDY_CREDIT_KINDS.find(kind => kind === row.Kind) ?? "other";
75
+ const existing = balance.groups.find(group => group.kind === kind);
76
+ if (existing) for (const key of ["total", "used", "remaining", "frozen"] as const) existing[key] += quantities[key];
77
+ else balance.groups.push({ kind, ...quantities });
78
+ }
79
+ }
80
+ return normalizeWorkBuddyCredits(balance);
81
+ }
@@ -0,0 +1,37 @@
1
+ import { isValidModelDiscoveryModelId } from "./model-discovery-limits";
2
+ import type { ProviderModelItemsResult, ProviderModelsApiItem } from "./model-discovery";
3
+
4
+ function record(value: unknown): Record<string, unknown> {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
6
+ }
7
+
8
+ /** The product catalog contains non-chat models too; only the CLI agent's roster is routable. */
9
+ export function extractWorkBuddyModels(value: unknown, limit: number): ProviderModelItemsResult {
10
+ const envelope = record(value), data = record(envelope.data);
11
+ if ((envelope.code !== 0 && envelope.code !== "0") || !Array.isArray(data.agents) || !Array.isArray(data.models)) return { ok: false, reason: "invalid_shape" };
12
+ if (data.models.length > limit || data.agents.length > limit) return { ok: false, reason: "too_many_models" };
13
+ const agent = data.agents.map(record).find(a => a.name === "cli");
14
+ if (!Array.isArray(agent?.models)) return { ok: false, reason: "invalid_shape" };
15
+ if (agent.models.length > limit) return { ok: false, reason: "too_many_models" };
16
+ const definitions = new Map(data.models.map(raw => { const row = record(raw); return [row.id, row] as const; }));
17
+ const items: ProviderModelsApiItem[] = [], seen = new Set<string>();
18
+ for (const id of agent.models) {
19
+ if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
20
+ if (seen.has(id)) continue;
21
+ seen.add(id);
22
+ const row = definitions.get(id) ?? {}, reasoning = record(row.reasoning);
23
+ const rawEfforts = Array.isArray(reasoning.supportedEfforts) ? reasoning.supportedEfforts
24
+ : row.supportsReasoning === true ? ["low", "high", "max"] : [];
25
+ const efforts = rawEfforts.filter(e => typeof e === "string" && ["low", "medium", "high", "xhigh", "max"].includes(e));
26
+ if (efforts.length && row.onlyReasoning !== true && reasoning.canDisableThinking !== false) efforts.unshift("none");
27
+ const positive = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v > 0 ? v : undefined;
28
+ items.push({
29
+ id, ...(typeof row.name === "string" && row.name.length <= 256 ? { name: row.name } : {}),
30
+ context_length: positive(row.maxInputTokens), max_input_tokens: positive(row.maxInputTokens),
31
+ max_output_tokens: positive(row.maxOutputTokens), reasoning_efforts: efforts,
32
+ // WorkBuddy AI filters images from GLM requests despite advertising supportsImages.
33
+ input_modalities: row.supportsImages === true && !id.startsWith("glm-") ? ["text", "image"] : ["text"],
34
+ });
35
+ }
36
+ return { ok: true, items, rawCount: agent.models.length };
37
+ }
@@ -0,0 +1,60 @@
1
+ import type { WorkBuddyOAuthMetadata } from "../oauth/types";
2
+ import { createHash } from "node:crypto";
3
+
4
+ export const WORKBUDDY_ORIGIN = "https://www.workbuddy.ai";
5
+ export const WORKBUDDY_BASE_URL = `${WORKBUDDY_ORIGIN}/v2`;
6
+ export const WORKBUDDY_CONFIG_URL = `${WORKBUDDY_ORIGIN}/v3/config`;
7
+ export const WORKBUDDY_USER_AGENT = "WorkBuddy/5.5.6";
8
+
9
+ export function workBuddyCatalogIdentity(access: string, metadata: WorkBuddyOAuthMetadata | undefined): string {
10
+ return createHash("sha256").update(JSON.stringify([access, metadata])).digest("hex");
11
+ }
12
+
13
+ export function workBuddyHeaderValue(value: unknown, max = 1024): value is string {
14
+ return typeof value === "string" && value.length > 0 && value.length <= max && /^[\x21-\x7e]+$/.test(value);
15
+ }
16
+
17
+ export function normalizeWorkBuddyMetadata(value: unknown): WorkBuddyOAuthMetadata | undefined {
18
+ if (!value || typeof value !== "object") return undefined;
19
+ const v = value as Partial<WorkBuddyOAuthMetadata>;
20
+ if (!workBuddyHeaderValue(v.uid) || !workBuddyHeaderValue(v.domain)) return undefined;
21
+ if (v.enterpriseId !== undefined && !workBuddyHeaderValue(v.enterpriseId)) return undefined;
22
+ return {
23
+ uid: v.uid, domain: v.domain,
24
+ ...(v.enterpriseId ? { enterpriseId: v.enterpriseId } : {}),
25
+ ...(typeof v.refreshExpiresAt === "number" && Number.isFinite(v.refreshExpiresAt) && v.refreshExpiresAt > 0
26
+ ? { refreshExpiresAt: v.refreshExpiresAt } : {}),
27
+ };
28
+ }
29
+
30
+ export function assertWorkBuddyDestination(baseUrl: string): void {
31
+ const u = new URL(baseUrl);
32
+ if (u.origin !== WORKBUDDY_ORIGIN || u.username || u.password || u.search || u.hash
33
+ || !["", "/v2", "/v2/chat/completions"].includes(u.pathname.replace(/\/+$/, ""))) {
34
+ throw new Error("WorkBuddy AI credentials require https://www.workbuddy.ai/v2");
35
+ }
36
+ }
37
+
38
+ export function workBuddyHeaders(access: string | undefined, metadata?: WorkBuddyOAuthMetadata): Record<string, string> {
39
+ const headers: Record<string, string> = {
40
+ "User-Agent": WORKBUDDY_USER_AGENT,
41
+ "X-Product": "SaaS", "X-IDE-Type": "WorkBuddy", "X-IDE-Name": "WorkBuddy",
42
+ "X-IDE-Version": "5.5.6", "X-Requested-With": "XMLHttpRequest",
43
+ "X-Agent-Intent": "craft", "X-Agent-Purpose": "conversation", "X-Agent-Type": "main",
44
+ };
45
+ if (access) {
46
+ if (!workBuddyHeaderValue(access, 32768)) throw new Error("Invalid WorkBuddy AI access token");
47
+ headers.Authorization = `Bearer ${access}`;
48
+ }
49
+ if (metadata) {
50
+ const valid = normalizeWorkBuddyMetadata(metadata);
51
+ if (!valid) throw new Error("WorkBuddy AI account identity unavailable; sign in again");
52
+ headers["X-User-Id"] = valid.uid;
53
+ headers["X-Domain"] = valid.domain;
54
+ if (valid.enterpriseId) {
55
+ headers["X-Enterprise-Id"] = valid.enterpriseId;
56
+ headers["X-Tenant-Id"] = valid.enterpriseId;
57
+ }
58
+ }
59
+ return headers;
60
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Assemble `ocx.quota-pool/1` (`GET /api/quota-pool`) from what this Home already holds.
3
+ *
4
+ * Strictly passive. Every source below is a cache another path filled; this module starts no
5
+ * probe, token refresh, quota refresh or upstream call and writes nothing. The whole assembly runs
6
+ * inside `runPassiveRead`, so even the shared readers it calls (the Codex account list, the OAuth
7
+ * store) take their passive branch: no WHAM probe, no native-main claim, no file hardening or
8
+ * malformed-store backup. Sources:
9
+ *
10
+ * - Codex pool accounts: the passive `listCodexAuthAccountsSnapshot` (stored `StoredAccountQuota`
11
+ * per account plus the cached main-account reading);
12
+ * - provider-level reports: `heldProviderQuotaReports`, the reports the Home last published
13
+ * (for the Codex pool provider this is OpenCodex's own pool aggregate, reported as `pooled`);
14
+ * - per-account OAuth quota (Anthropic instances, Kiro, Antigravity, xAI, Cursor, Kimi, Command
15
+ * Code, Devin, DSH, Meta): the last-good per-account cache;
16
+ * - Claude CLI: the usage-admission snapshot (5-hour, weekly and family windows, plus a parsed
17
+ * CLI limit refusal).
18
+ *
19
+ * An unknown window is omitted, never emitted as 0%. A window whose reset has passed is unknown
20
+ * again and is omitted too. Optional fields are left out instead of set to null.
21
+ */
22
+ import type { OcxConfig, OcxProviderConfig } from "../types";
23
+ import type { ProviderQuota, ProviderQuotaWindow } from "../providers/quota-types";
24
+ import type { StoredAccountQuota } from "../codex/quota-types";
25
+ import { isTerminalShortWindow, resetAtToMs } from "../codex/quota-types";
26
+ import { runPassiveRead } from "../lib/passive-read";
27
+ import { packageVersion } from "../lib/package-version";
28
+ import { listCodexAuthAccountsSnapshot } from "../codex/auth-api";
29
+ import { latestCodexQuotaObservationSource } from "../codex/quota";
30
+ import { ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
31
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination";
32
+ import { hasCodexPoolProvider, heldProviderQuotaReports, type ProviderQuotaReport } from "../providers/quota/report-cache";
33
+ import {
34
+ accountCacheKey,
35
+ accountQuotaCache,
36
+ getCachedProviderAccountQuota,
37
+ hasPassiveAccountQuota,
38
+ supportsPerAccountQuota,
39
+ } from "../providers/quota/account-cache";
40
+ import { parseQuotaFailureCode } from "../providers/quota-types";
41
+ import { getAccountSet } from "../oauth/store";
42
+ import { peekClaudeUsageObservation } from "../adapters/claude-cli/usage-admission";
43
+ import {
44
+ QUOTA_POOL_SCHEMA,
45
+ type QuotaPool,
46
+ type QuotaPoolAccount,
47
+ type QuotaPoolAccountState,
48
+ type QuotaPoolProvider,
49
+ type QuotaPoolRole,
50
+ type QuotaPoolSource,
51
+ type QuotaPoolWindow,
52
+ } from "./contract";
53
+ import { quotaPoolAccountRef, readQuotaPoolIdentity, type QuotaPoolIdentity } from "./identity";
54
+
55
+ const VERSION = packageVersion("0.0.0");
56
+ const SHORT_BURST_MAX_SECONDS = 6 * 3600;
57
+ const SHORT_DAILY_MAX_SECONDS = 36 * 3600;
58
+
59
+ export interface QuotaPoolDeps {
60
+ now?: () => number;
61
+ /** Test seam for the Claude CLI admission file. */
62
+ claudeUsageStatePath?: string;
63
+ }
64
+
65
+ export type QuotaPoolResult = { ok: true; pool: QuotaPool } | { ok: false; error: "quota_pool_identity_unavailable" };
66
+
67
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
68
+
69
+ function iso(ms: number): string | undefined {
70
+ if (!finite(ms) || ms <= 0) return undefined;
71
+ const date = new Date(ms);
72
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
73
+ }
74
+
75
+ /** A used percent inside the contract's 0..100, or undefined when there is no reading. */
76
+ function percent(value: unknown): number | undefined {
77
+ if (!finite(value) || value < 0) return undefined;
78
+ return Math.min(100, value);
79
+ }
80
+
81
+ type WindowDraft = Omit<QuotaPoolWindow, "observedAt" | "source"> & { resetMs?: number };
82
+
83
+ /**
84
+ * Finish a window, or drop it when it carries nothing (no percent, no counters) or its reset has
85
+ * already passed: an elapsed window is unknown now, not empty.
86
+ */
87
+ function finish(draft: WindowDraft, source: QuotaPoolSource, observedMs: number, now: number): QuotaPoolWindow | null {
88
+ const observedAt = iso(observedMs);
89
+ if (!observedAt) return null;
90
+ const { resetMs, ...rest } = draft;
91
+ if (resetMs !== undefined && resetMs <= now) return null;
92
+ if (rest.usedPercent === undefined && rest.used === undefined && rest.limit === undefined && rest.exhausted === undefined) return null;
93
+ const resetsAt = resetMs !== undefined ? iso(resetMs) : undefined;
94
+ const exhausted = rest.exhausted ?? (rest.usedPercent !== undefined ? rest.usedPercent >= 100 : undefined);
95
+ const window: QuotaPoolWindow = { ...rest, source, observedAt };
96
+ if (resetsAt) window.resetsAt = resetsAt;
97
+ if (exhausted !== undefined) window.exhausted = exhausted;
98
+ return window;
99
+ }
100
+
101
+ function resetMs(value: unknown): number | undefined {
102
+ return finite(value) && value > 0 ? resetAtToMs(value) : undefined;
103
+ }
104
+
105
+ function shortKind(seconds: number | undefined): Pick<QuotaPoolWindow, "id" | "kind" | "label"> {
106
+ if (seconds === undefined || seconds <= SHORT_BURST_MAX_SECONDS) return { id: "5h", kind: "burst_5h", label: "5-hour" };
107
+ if (seconds <= SHORT_DAILY_MAX_SECONDS) return { id: "daily", kind: "daily", label: "Daily" };
108
+ return { id: "short", kind: "custom", label: "Short window" };
109
+ }
110
+
111
+ /** Codex `StoredAccountQuota`, mapped as the header and WHAM parsers stored it. */
112
+ export function codexQuotaWindows(quota: StoredAccountQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] {
113
+ const out: QuotaPoolWindow[] = [];
114
+ const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); };
115
+ const shortPercent = percent(quota.shortPercent);
116
+ const shortSeconds = finite(quota.shortWindowSeconds) && quota.shortWindowSeconds > 0 ? quota.shortWindowSeconds : undefined;
117
+ if (shortPercent !== undefined) {
118
+ push(finish({
119
+ ...shortKind(shortSeconds),
120
+ usedPercent: shortPercent,
121
+ ...(shortSeconds !== undefined ? { windowSeconds: shortSeconds } : {}),
122
+ resetMs: resetMs(quota.shortResetAt),
123
+ exhausted: isTerminalShortWindow(quota, now),
124
+ }, source, quota.shortObservedAt ?? quota.updatedAt, now));
125
+ }
126
+ const weekly = percent(quota.weeklyPercent);
127
+ if (weekly !== undefined) {
128
+ push(finish({ id: "weekly", kind: "weekly", label: "Weekly", usedPercent: weekly, resetMs: resetMs(quota.weeklyResetAt) }, source, quota.updatedAt, now));
129
+ }
130
+ const monthly = percent(quota.monthlyPercent);
131
+ if (monthly !== undefined) {
132
+ push(finish({ id: "monthly", kind: "monthly", label: "Monthly", usedPercent: monthly, resetMs: resetMs(quota.monthlyResetAt) }, source, quota.updatedAt, now));
133
+ }
134
+ for (const custom of quota.customWindows ?? []) {
135
+ const used = percent(custom?.percent);
136
+ if (typeof custom?.label !== "string" || !custom.label.trim() || used === undefined) continue;
137
+ push(finish({ id: `custom:${custom.label}`, kind: "custom", label: custom.label, usedPercent: used, resetMs: resetMs(custom.resetAt) }, source, quota.updatedAt, now));
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function customWindow(window: ProviderQuotaWindow, source: QuotaPoolSource, updatedAt: number, now: number): QuotaPoolWindow | null {
143
+ if (typeof window?.label !== "string" || !window.label.trim()) return null;
144
+ // An exact-model row (Antigravity diagnostics, CCA fallback) restates the pool its family
145
+ // window already reports and carries no proven scope; reporting it unscoped would let it gate
146
+ // every model.
147
+ if (window.modelId !== undefined && window.scope !== "model") return null;
148
+ const scoped = window.scope === "model";
149
+ const passive = finite(window.passiveObservedAt);
150
+ return finish({
151
+ id: scoped ? `family:${window.label.toLowerCase()}` : `custom:${window.label}`,
152
+ kind: "custom",
153
+ label: window.label,
154
+ ...(percent(window.percent) !== undefined ? { usedPercent: percent(window.percent) } : {}),
155
+ ...(scoped ? { scopeModel: window.label } : {}),
156
+ ...(window.rejected === true ? { exhausted: true } : {}),
157
+ resetMs: resetMs(window.resetAt),
158
+ }, passive ? "header" : source, passive ? window.passiveObservedAt! : updatedAt, now);
159
+ }
160
+
161
+ /** A `ProviderQuota` (provider report or per-account cache row). */
162
+ export function providerQuotaWindows(quota: ProviderQuota, source: QuotaPoolSource, now: number): QuotaPoolWindow[] {
163
+ const out: QuotaPoolWindow[] = [];
164
+ const push = (window: QuotaPoolWindow | null) => { if (window) out.push(window); };
165
+ const at = quota.updatedAt;
166
+ const fixed = [
167
+ ["5h", "burst_5h", "5-hour", quota.fiveHourPercent, quota.fiveHourResetAt],
168
+ ["weekly", "weekly", "Weekly", quota.weeklyPercent, quota.weeklyResetAt],
169
+ ["monthly", "monthly", "Monthly", quota.monthlyPercent, quota.monthlyResetAt],
170
+ ] as const;
171
+ for (const [id, kind, label, value, reset] of fixed) {
172
+ const used = percent(value);
173
+ if (used === undefined) continue;
174
+ push(finish({ id, kind, label, usedPercent: used, resetMs: resetMs(reset) }, source, at, now));
175
+ }
176
+ const kiroUsed = finite(quota.kiroCreditsUsed) && quota.kiroCreditsUsed >= 0 ? quota.kiroCreditsUsed : undefined;
177
+ const kiroLimit = finite(quota.kiroCreditsLimit) && quota.kiroCreditsLimit > 0 ? quota.kiroCreditsLimit : undefined;
178
+ if (kiroUsed !== undefined || kiroLimit !== undefined) {
179
+ push(finish({
180
+ id: "credits:kiro", kind: "credits", label: "Kiro credits", unit: "credits",
181
+ ...(kiroUsed !== undefined ? { used: kiroUsed } : {}),
182
+ ...(kiroLimit !== undefined ? { limit: kiroLimit } : {}),
183
+ ...(kiroUsed !== undefined && kiroLimit !== undefined ? { usedPercent: percent((kiroUsed / kiroLimit) * 100) } : {}),
184
+ }, source, at, now));
185
+ }
186
+ const usd = quota.creditsUsd;
187
+ if (usd && finite(usd.used) && usd.used >= 0) {
188
+ const unlimited = usd.unlimited === true;
189
+ push(finish({
190
+ id: "credits:usd", kind: "credits", label: "USD credits", unit: "usd", used: usd.used,
191
+ ...(!unlimited && finite(usd.limit) && usd.limit > 0 ? { limit: usd.limit } : {}),
192
+ ...(!unlimited && percent(usd.percent) !== undefined ? { usedPercent: percent(usd.percent) } : {}),
193
+ ...(unlimited ? { exhausted: false } : finite(usd.remaining) ? { exhausted: usd.remaining <= 0 } : {}),
194
+ }, source, at, now));
195
+ }
196
+ for (const custom of quota.customWindows ?? []) push(customWindow(custom, source, at, now));
197
+ return out;
198
+ }
199
+
200
+ function accountState(input: { reauth: boolean; paused: boolean; windows: readonly QuotaPoolWindow[] }): QuotaPoolAccountState {
201
+ if (input.reauth) return "reauth";
202
+ if (input.paused) return "paused";
203
+ // A model-scoped window never gates the account as a whole.
204
+ if (input.windows.some(window => window.exhausted === true && window.scopeModel === undefined)) return "exhausted";
205
+ return input.windows.length === 0 ? "unknown" : "available";
206
+ }
207
+
208
+ function nextResetAt(windows: readonly QuotaPoolWindow[]): string | undefined {
209
+ const resets = windows.filter(window => window.scopeModel === undefined && window.resetsAt).map(window => window.resetsAt!).sort();
210
+ return resets[0];
211
+ }
212
+
213
+ function reportSource(report: ProviderQuotaReport): QuotaPoolSource {
214
+ if (report.aggregation) return "estimated";
215
+ return report.observed === true ? "header" : "measured";
216
+ }
217
+
218
+ function codexAccounts(identity: QuotaPoolIdentity, accounts: Awaited<ReturnType<typeof listCodexAuthAccountsSnapshot>>["accounts"], now: number): QuotaPoolAccount[] {
219
+ const provider = OPENAI_CODEX_PROVIDER_ID;
220
+ return accounts.map(dto => {
221
+ const source: QuotaPoolSource = latestCodexQuotaObservationSource(dto.id) === "response-header" ? "header" : "measured";
222
+ const windows = dto.quota ? codexQuotaWindows(dto.quota as StoredAccountQuota, source, now) : [];
223
+ const account: QuotaPoolAccount = {
224
+ provider,
225
+ accountRef: quotaPoolAccountRef(identity, provider, dto.id),
226
+ // needsReauth only: a passive read leaves hasCredential false when it did not check, and an
227
+ // account without a credential already carries needsReauth.
228
+ state: accountState({ reauth: dto.needsReauth === true, paused: dto.paused, windows }),
229
+ windows,
230
+ };
231
+ if (typeof dto.logLabel === "string" && ACCOUNT_LOG_LABEL_RE.test(dto.logLabel)) account.label = dto.logLabel;
232
+ if (typeof dto.plan === "string" && dto.plan.trim()) account.plan = dto.plan.trim();
233
+ return account;
234
+ });
235
+ }
236
+
237
+ function oauthAccounts(identity: QuotaPoolIdentity, provider: string, now: number): QuotaPoolAccount[] {
238
+ const set = getAccountSet(provider);
239
+ if (!set) return [];
240
+ return set.accounts.map(row => {
241
+ const quota = getCachedProviderAccountQuota(provider, row.id);
242
+ const entry = accountQuotaCache.get(accountCacheKey(provider, row.id));
243
+ const windows = quota ? providerQuotaWindows(quota, "measured", now) : [];
244
+ const account: QuotaPoolAccount = {
245
+ provider,
246
+ accountRef: quotaPoolAccountRef(identity, provider, row.id),
247
+ label: oauthAccountLogLabel(row.id, provider),
248
+ state: accountState({ reauth: row.needsReauth === true, paused: row.paused === true, windows }),
249
+ windows,
250
+ };
251
+ const failure = entry?.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? parseQuotaFailureCode(entry.quotaFailure) : undefined;
252
+ if (failure) account.failure = failure;
253
+ return account;
254
+ });
255
+ }
256
+
257
+ function claudeCliAccounts(identity: QuotaPoolIdentity, provider: string, deps: QuotaPoolDeps, now: number): QuotaPoolAccount[] {
258
+ const observation = peekClaudeUsageObservation(deps.claudeUsageStatePath ? { statePath: deps.claudeUsageStatePath } : {});
259
+ if (!observation) return [];
260
+ const windows = observation.quota ? providerQuotaWindows(observation.quota, "measured", now) : [];
261
+ if (finite(observation.refusalUntil) && observation.refusalUntil > now) {
262
+ const refusal = finish({ id: "refusal", kind: "custom", label: "Claude CLI limit", exhausted: true, resetMs: observation.refusalUntil },
263
+ "estimated", observation.checkedAt, now);
264
+ if (refusal) windows.push(refusal);
265
+ }
266
+ return [{
267
+ provider,
268
+ accountRef: quotaPoolAccountRef(identity, provider, `claude-cli:${observation.identity}`),
269
+ state: accountState({ reauth: false, paused: false, windows }),
270
+ windows,
271
+ }];
272
+ }
273
+
274
+ function role(config: OcxConfig): QuotaPoolRole {
275
+ return config.runtimeRole === "hub" || config.runtimeRole === "client" ? config.runtimeRole : "standalone";
276
+ }
277
+
278
+ function enabled(provider: OcxProviderConfig | undefined): provider is OcxProviderConfig {
279
+ return !!provider && provider.disabled !== true;
280
+ }
281
+
282
+ async function assemble(config: OcxConfig, identity: QuotaPoolIdentity, deps: QuotaPoolDeps): Promise<QuotaPool> {
283
+ const now = (deps.now ?? Date.now)();
284
+ const names = Object.keys(config.providers).filter(name => enabled(config.providers[name])).sort();
285
+ const reports = new Map(heldProviderQuotaReports(names).reports.map(report => [report.provider, report]));
286
+ const codexPool = hasCodexPoolProvider(config);
287
+ const codex = codexPool ? (await listCodexAuthAccountsSnapshot(config)).accounts : [];
288
+ const providers: QuotaPoolProvider[] = [];
289
+ for (const name of names) {
290
+ const provider = config.providers[name]!;
291
+ let accounts: QuotaPoolAccount[] = [];
292
+ let tracked = false;
293
+ if (codexPool && name === OPENAI_CODEX_PROVIDER_ID) {
294
+ tracked = true;
295
+ accounts = codexAccounts(identity, codex, now);
296
+ } else if (provider.adapter === "claude-cli") {
297
+ tracked = true;
298
+ accounts = claudeCliAccounts(identity, name, deps, now);
299
+ } else if (supportsPerAccountQuota(name) || hasPassiveAccountQuota(name)) {
300
+ tracked = true;
301
+ accounts = oauthAccounts(identity, name, now);
302
+ }
303
+ const report = reports.get(name);
304
+ const pooledWindows = report ? providerQuotaWindows(report.quota, reportSource(report), now) : [];
305
+ if (!tracked && !report) continue;
306
+ const pooled: QuotaPoolProvider["pooled"] = { windows: pooledWindows };
307
+ const next = nextResetAt(pooledWindows);
308
+ if (next) pooled.nextResetAt = next;
309
+ providers.push({ provider: name, accounts, pooled });
310
+ }
311
+ return {
312
+ schema: QUOTA_POOL_SCHEMA,
313
+ producer: { version: VERSION, role: role(config), homeId: identity.homeId },
314
+ generatedAt: iso(now) ?? new Date().toISOString(),
315
+ providers,
316
+ };
317
+ }
318
+
319
+ /** The quota pool, or an identity error. Always a passive read, whoever asks. */
320
+ export function readQuotaPool(config: OcxConfig, deps: QuotaPoolDeps = {}): Promise<QuotaPoolResult> {
321
+ return runPassiveRead(async () => {
322
+ const identity = readQuotaPoolIdentity();
323
+ if (!identity) return { ok: false, error: "quota_pool_identity_unavailable" } as const;
324
+ return { ok: true, pool: await assemble(config, identity, deps) } as const;
325
+ });
326
+ }