@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,425 @@
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE, codexAccountLogLabel } from "../account-label";
2
+ import { poolQuotaHistoryIdentity, readCodexAccountRecord } from "../account-store";
3
+ import { estimateCodexQuotaCapacity, insufficientCodexCapacity } from "../quota-capacity";
4
+ import type { CodexCapacityResult } from "../quota-capacity";
5
+ import { readUsageSnapshotForManagement } from "../../usage/log";
6
+ import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
7
+ import { deleteCodexAccount } from "../account-lifecycle";
8
+ import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
9
+ import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
10
+ import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
11
+ import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
12
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
13
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
14
+ import type { OcxConfig } from "../../types";
15
+ import { CODEX_ACCOUNT_ID_RE, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id";
16
+ import { isCodexResetCreditOperationId } from "../reset-credit-recovery";
17
+ import { listCodexAuthAccounts, selectFallbackAfterPause, pauseExhaustedCodexAccounts } from "./account-list";
18
+ import { jsonResponse, manualImportDisabledResponse } from "./http";
19
+ import { convergeAccountNamespaceCatalog, handleCodexAuthLoginStart, handleCodexAuthLoginCode, handleCodexAuthLoginCancel, handleCodexAuthLoginStatus } from "./login-flow";
20
+ import type { CodexAuthCatalogConvergence } from "./login-flow";
21
+ import { PoolQuotaProbeBusyError } from "./pool-quota-probe";
22
+ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service";
23
+ import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config";
24
+
25
+ export async function handleCodexAuthAPI(
26
+ req: Request,
27
+ url: URL,
28
+ config: OcxConfig,
29
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
30
+ principal?: import("../../server/management-auth").ManagementPrincipal,
31
+ ): Promise<Response | null> {
32
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
33
+ const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
34
+ return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
35
+ }
36
+
37
+ if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") {
38
+ // Inference spends quota: only a dashboard session carries the consent
39
+ // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational.
40
+ return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, {
41
+ validatePending: principal === "gui-session",
42
+ }) });
43
+ }
44
+
45
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
46
+ return manualImportDisabledResponse();
47
+ }
48
+
49
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
50
+ const id = url.searchParams.get("id");
51
+ if (!id) return jsonResponse({ error: "Missing id" }, 400);
52
+ const runtimeConfig = getRuntimeConfig(config);
53
+ const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
54
+ && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
55
+ if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
56
+ return jsonResponse({ error: "Invalid account id format" }, 400);
57
+ }
58
+ const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
59
+ saveRuntimeConfig(config, runtimeConfig);
60
+ reconcileLiveStateStores();
61
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
62
+ runtimeConfig,
63
+ pickerVisibilityChanged,
64
+ convergeCodexCatalog,
65
+ );
66
+ return jsonResponse({ ok: true, ...catalogRefresh });
67
+ }
68
+
69
+ if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
70
+ const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
71
+ const id = typeof body.id === "string" ? body.id.trim() : "";
72
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
73
+ if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
74
+ if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
75
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
76
+ return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
77
+ }
78
+ const runtimeConfig = getRuntimeConfig(config);
79
+ const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
80
+ if (!account) return jsonResponse({ error: "Account not found" }, 404);
81
+ if (alias) account.alias = alias;
82
+ else delete account.alias;
83
+ saveRuntimeConfig(config, runtimeConfig);
84
+ return jsonResponse({ ok: true, id, alias: alias || null });
85
+ }
86
+
87
+ if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
88
+ const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
89
+ const id = typeof body.id === "string" ? body.id.trim() : "";
90
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
91
+ return jsonResponse({ error: "Invalid account id format" }, 400);
92
+ }
93
+ if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
94
+
95
+ const runtimeConfig = getRuntimeConfig(config);
96
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
97
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
98
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
99
+
100
+ setCodexAccountPaused(runtimeConfig, id, body.paused);
101
+ if (body.paused) {
102
+ clearThreadAccountMapForAccount(id);
103
+ selectFallbackAfterPause(runtimeConfig, id);
104
+ }
105
+ saveRuntimeConfig(config, runtimeConfig);
106
+ return jsonResponse({
107
+ ok: true,
108
+ id,
109
+ paused: body.paused,
110
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
111
+ appliesImmediately: true,
112
+ });
113
+ }
114
+
115
+ // Deliberately a route of its own rather than a field on the alias PATCH: aliases
116
+ // are display-only and reject __main__, while selection order is routing metadata
117
+ // that the Desktop account must be able to carry. Re-ordering never kicks a live
118
+ // thread, so there is no affinity clearing and no appliesImmediately here.
119
+ if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
120
+ let parsedBody: unknown;
121
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
122
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
123
+ return jsonResponse({ error: "body must be an object" }, 400);
124
+ }
125
+ const body = parsedBody as { id?: unknown; priority?: unknown };
126
+ const id = typeof body.id === "string" ? body.id.trim() : "";
127
+ if (!isCodexAccountPriorityKey(id)) {
128
+ return jsonResponse({ error: "Invalid account id format" }, 400);
129
+ }
130
+
131
+ let priority = DEFAULT_ACCOUNT_PRIORITY;
132
+ if (body.priority !== null) {
133
+ const parsed = parseAccountPriority(body.priority);
134
+ if (parsed === null) {
135
+ return jsonResponse({
136
+ error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
137
+ }, 400);
138
+ }
139
+ priority = parsed;
140
+ }
141
+
142
+ const runtimeConfig = getRuntimeConfig(config);
143
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
144
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
145
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
146
+
147
+ setCodexAccountPriority(runtimeConfig, id, priority);
148
+ // Both a pin and an order are the operator saying which account to use, so the newer
149
+ // statement wins. Without this a pin made before any order existed — an ordinary
150
+ // account switch — would outrank the order forever: it blocks preemption and caps
151
+ // every eligibility list at its own tier until that account drains or is paused.
152
+ clearCodexAccountPin(runtimeConfig);
153
+ saveRuntimeConfig(config, runtimeConfig);
154
+ return jsonResponse({
155
+ ok: true,
156
+ id,
157
+ priority,
158
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
159
+ });
160
+ }
161
+
162
+ if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
163
+ const runtimeConfig = getRuntimeConfig(config);
164
+ const result = await pauseExhaustedCodexAccounts(
165
+ runtimeConfig,
166
+ () => saveRuntimeConfig(config, runtimeConfig),
167
+ );
168
+ const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
169
+ if (checkedAccountCount === 0 && failedAccountCount > 0) {
170
+ return jsonResponse({
171
+ ok: false,
172
+ error: "Failed to refresh any Codex account quota",
173
+ checkedAccountCount,
174
+ failedAccountCount,
175
+ }, 502);
176
+ }
177
+ return jsonResponse({
178
+ ok: true,
179
+ pausedAccountIds,
180
+ pausedCount: pausedAccountIds.length,
181
+ checkedAccountCount,
182
+ failedAccountCount,
183
+ complete: failedAccountCount === 0,
184
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
185
+ appliesImmediately: true,
186
+ });
187
+ }
188
+
189
+ // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
190
+ // model path for Codex Desktop, so a cooldown that outlives the real upstream limit
191
+ // otherwise leaves editing config.toml as the user's only recovery.
192
+ //
193
+ // Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
194
+ // exactly like an account that simply had no live cooldown, so this route cannot be used
195
+ // to enumerate configured accounts. Cooldown state is runtime-only and independent of the
196
+ // account list, so 404 would carry no useful meaning anyway.
197
+ if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
198
+ const body = await req.json().catch(() => ({})) as { id?: unknown };
199
+ const id = typeof body.id === "string" ? body.id.trim() : "";
200
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
201
+ return jsonResponse({ error: "Invalid account id format" }, 400);
202
+ }
203
+ return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
204
+ }
205
+
206
+ if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
207
+ let body: { accountId: string | null };
208
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
209
+ const runtimeConfig = getRuntimeConfig(config);
210
+ const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
211
+ if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
212
+ return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
213
+ }
214
+ if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
215
+ return jsonResponse({ error: "Account is paused" }, 409);
216
+ }
217
+ if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
218
+ if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
219
+ const exists = (runtimeConfig.codexAccounts ?? [])
220
+ .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
221
+ if (!exists) return jsonResponse({ error: "Account not found" }, 400);
222
+ if (readCodexAccountRecord(body.accountId)?.codexValidationPending) {
223
+ return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409);
224
+ }
225
+ }
226
+ runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
227
+ // "Use this account now" outranks selection order until the account is spent:
228
+ // persisted here rather than in resetCodexRoutingForManualSelection, which is
229
+ // runtime state only. A null id clears the selection instead of making one, so it
230
+ // must release the pin rather than record one: pinning the `targetAccountId`
231
+ // fallback would leave a pin that no effective active account matches, which
232
+ // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
233
+ // honours it as a ceiling — invisibly capping the pool at the main account's tier.
234
+ if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
235
+ else setCodexAccountPin(runtimeConfig, targetAccountId);
236
+ resetCodexRoutingForManualSelection(targetAccountId);
237
+ saveRuntimeConfig(config, runtimeConfig);
238
+ return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
239
+ }
240
+
241
+ if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
242
+ const runtimeConfig = getRuntimeConfig(config);
243
+ return jsonResponse({
244
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
245
+ pinned: isEffectiveCodexAccountPinned(runtimeConfig),
246
+ // Which account carries the pin, not just whether the active one does. Under
247
+ // round-robin or fill-first the pin caps the tier ceiling at its own tier while the
248
+ // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
249
+ // sibling's turn even though the pin is still suppressing every higher tier. The id
250
+ // lets a surface mark the account the operator actually chose.
251
+ pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
252
+ autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
253
+ upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
254
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
255
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
256
+ });
257
+ }
258
+
259
+ if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
260
+ let body: { threshold: number };
261
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
262
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
263
+ return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
264
+ }
265
+ const runtimeConfig = getRuntimeConfig(config);
266
+ runtimeConfig.autoSwitchThreshold = body.threshold;
267
+ saveRuntimeConfig(config, runtimeConfig);
268
+ return jsonResponse({ ok: true });
269
+ }
270
+
271
+ if (
272
+ url.pathname === "/api/codex-auth/pool-strategy"
273
+ && (req.method === "PUT" || req.method === "PATCH")
274
+ ) {
275
+ let parsedBody: unknown;
276
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
277
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
278
+ return jsonResponse({ error: "body must be an object" }, 400);
279
+ }
280
+ const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
281
+ if (body.strategy === undefined && body.stickyLimit === undefined) {
282
+ return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
283
+ }
284
+ const runtimeConfig = getRuntimeConfig(config);
285
+ let nextStrategy: NonNullable<ReturnType<typeof parseCodexAccountPoolStrategy>> | undefined;
286
+ let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
287
+ if (body.strategy !== undefined) {
288
+ const parsed = parseCodexAccountPoolStrategy(body.strategy);
289
+ if (parsed === null) {
290
+ return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400);
291
+ }
292
+ nextStrategy = parsed;
293
+ }
294
+ if (body.stickyLimit !== undefined) {
295
+ const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
296
+ if (parsed === null) {
297
+ return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
298
+ }
299
+ nextSticky = parsed;
300
+ }
301
+ if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
302
+ if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
303
+ saveRuntimeConfig(config, runtimeConfig);
304
+ return jsonResponse({
305
+ ok: true,
306
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
307
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
308
+ });
309
+ }
310
+
311
+ if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
312
+ let body: { threshold: number };
313
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
314
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
315
+ return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
316
+ }
317
+ const runtimeConfig = getRuntimeConfig(config);
318
+ runtimeConfig.upstreamFailoverThreshold = body.threshold;
319
+ saveRuntimeConfig(config, runtimeConfig);
320
+ return jsonResponse({ ok: true });
321
+ }
322
+
323
+ if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") {
324
+ const accountId = url.searchParams.get("accountId");
325
+ const rawLimit = url.searchParams.get("limit");
326
+ if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId)
327
+ || url.searchParams.getAll("limit").length > 1
328
+ || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit")
329
+ || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) {
330
+ return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400);
331
+ }
332
+ const runtimeConfig = getRuntimeConfig(config);
333
+ const account = configuredPoolAccount(runtimeConfig, accountId);
334
+ if (!account) return jsonResponse({ error: "Unknown pool account" }, 404);
335
+ const identity = poolQuotaHistoryIdentity(accountId);
336
+ const allHistory = getAccountQuotaHistory(accountId);
337
+ const limit = rawLimit === null ? 200 : Number(rawLimit);
338
+ const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit };
339
+ const label = account.logLabel;
340
+ const labelStillUnique = () => {
341
+ const current = getRuntimeConfig(config);
342
+ return configuredPoolAccount(current, accountId)?.logLabel === label
343
+ && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1;
344
+ };
345
+ let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable");
346
+ if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) {
347
+ try {
348
+ const usage = await readUsageSnapshotForManagement();
349
+ if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed");
350
+ else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable");
351
+ else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated");
352
+ else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label,
353
+ model => codexQuotaScopeForModel(model) === "shared");
354
+ } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); }
355
+ }
356
+ if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404);
357
+ if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) {
358
+ return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") });
359
+ }
360
+ return jsonResponse({ accountId, ...history, capacity });
361
+ }
362
+
363
+ if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
364
+ const quotas: Record<string, unknown> = {};
365
+ for (const [id, q] of listAccountQuotas()) quotas[id] = q;
366
+ return jsonResponse({ quotas });
367
+ }
368
+
369
+ if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
370
+ const accountId = url.searchParams.get("accountId");
371
+ if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
372
+
373
+ try {
374
+ return await inspectResetCredits(config, accountId, req.signal);
375
+ } catch (e) {
376
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
377
+ }
378
+ }
379
+
380
+ if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
381
+ const body = (await req.json().catch(() => ({}))) as {
382
+ accountId?: string;
383
+ operationId?: unknown;
384
+ };
385
+ if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
386
+ const accountId = body.accountId;
387
+ // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy
388
+ // behavior: a fresh random redeem_request_id and no durable ledger row.
389
+ // The ledger throws TypeError on a malformed id, so the format check has to
390
+ // happen here rather than at the call site, or it surfaces as a 500.
391
+ const hasOperationId = body.operationId !== undefined;
392
+ if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) {
393
+ return jsonResponse({ error: "Invalid operationId format" }, 400);
394
+ }
395
+ const requestedOperationId = hasOperationId ? body.operationId as string : undefined;
396
+ try {
397
+ return await consumeResetCredits(config, accountId, requestedOperationId);
398
+ } catch (e) {
399
+ if (e instanceof PoolQuotaProbeBusyError) {
400
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
401
+ response.headers.set("Retry-After", "1");
402
+ return response;
403
+ }
404
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
405
+ }
406
+ }
407
+
408
+ if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
409
+ return handleCodexAuthLoginStart(req, config, convergeCodexCatalog);
410
+ }
411
+
412
+ if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
413
+ return handleCodexAuthLoginCode(req);
414
+ }
415
+
416
+ if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
417
+ return handleCodexAuthLoginCancel(req);
418
+ }
419
+
420
+ if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
421
+ return handleCodexAuthLoginStatus(req, url, config);
422
+ }
423
+
424
+ return null;
425
+ }
@@ -0,0 +1,48 @@
1
+ import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
2
+ import { codexPlanValue } from "../plan";
3
+ import type { CodexAccount, OcxConfig } from "../../types";
4
+ import { isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id";
5
+
6
+ export function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
7
+ if (!isValidCodexAccountId(accountId)) return null;
8
+ return (config.codexAccounts ?? [])
9
+ .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
10
+ }
11
+
12
+ export function nonEmptyPlan(value: unknown): string | null {
13
+ return codexPlanValue(value) ?? null;
14
+ }
15
+
16
+ export function isRuntimeConfig(config: OcxConfig): boolean {
17
+ return !!config && typeof config === "object" && !!config.providers;
18
+ }
19
+
20
+ export function getRuntimeConfig(config: OcxConfig): OcxConfig {
21
+ return isRuntimeConfig(config) ? config : loadConfig();
22
+ }
23
+
24
+ export function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
25
+ saveConfigPreservingClaudeCode(nextConfig);
26
+ if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
27
+ for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
28
+ delete sourceConfig[key];
29
+ }
30
+ Object.assign(sourceConfig, nextConfig);
31
+ }
32
+
33
+ export async function mapWithConcurrency<T, R>(
34
+ items: T[],
35
+ concurrency: number,
36
+ mapper: (item: T) => Promise<R>,
37
+ ): Promise<R[]> {
38
+ const results = new Array<R>(items.length);
39
+ let next = 0;
40
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
41
+ while (next < items.length) {
42
+ const index = next++;
43
+ results[index] = await mapper(items[index]!);
44
+ }
45
+ });
46
+ await Promise.all(workers);
47
+ return results;
48
+ }