@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -0,0 +1,93 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Server } from "bun";
3
+ import { loadConfig } from "../config";
4
+ import { removePid, removeRuntimePort, writePid, writeRuntimePort } from "../config/process-state";
5
+ import { installCrashGuards } from "../lib/crash-guard";
6
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
7
+ import { findAvailablePort } from "../server/ports";
8
+ import { startMachineListener } from "./machine-listener";
9
+ import { readClientConnectionState } from "./state";
10
+
11
+ let activeServer: Server<unknown> | null = null;
12
+ let activePort: number | null = null;
13
+ let recycleScheduled = false;
14
+
15
+ function cleanup(): void {
16
+ removePid(process.pid);
17
+ removeRuntimePort(process.pid);
18
+ }
19
+
20
+ export function scheduleStandaloneRecycle(): void {
21
+ if (recycleScheduled) return;
22
+ recycleScheduled = true;
23
+ const timer = setTimeout(() => {
24
+ const port = activePort;
25
+ try { activeServer?.stop(true); } catch { /* best effort */ }
26
+ cleanup();
27
+ // Recycling back to standalone after `ocx disconnect` must actually bring a standalone
28
+ // proxy back, under either launch shape.
29
+ //
30
+ // Unsupervised: spawn the replacement ourselves and exit 0.
31
+ //
32
+ // Supervised (`OCX_SERVICE=1`): do NOT spawn — the supervisor owns the process, and a
33
+ // second copy would fight it for the port. But exit 0 does not work either: the real
34
+ // supervisor configs are failure-only (systemd `Restart=on-failure`, WinSW
35
+ // `<onfailure action="restart"/>`, the Task Scheduler ERRORLEVEL loop), so a clean exit
36
+ // reads as "the service finished" and nothing restarts. The client stayed down until the
37
+ // operator noticed. Exit 1 is what those configs are watching for, and it is the same
38
+ // policy the dashboard recycle already uses (src/server/management/system-restart.ts).
39
+ //
40
+ // launchd's KeepAlive restarts on any exit, so it is correct under both branches.
41
+ if (process.env.OCX_SERVICE === "1") {
42
+ process.exit(1);
43
+ }
44
+ if (port) {
45
+ const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(port)]), {
46
+ detached: true,
47
+ stdio: "ignore",
48
+ windowsHide: true,
49
+ env: { ...process.env },
50
+ });
51
+ child.unref();
52
+ }
53
+ process.exit(0);
54
+ }, 50);
55
+ if (typeof timer === "object" && "unref" in timer) timer.unref();
56
+ }
57
+
58
+ export async function startClientRuntime(
59
+ options: { port?: number; block?: boolean } = {},
60
+ ): Promise<void> {
61
+ const state = readClientConnectionState();
62
+ if (state.kind !== "connected") throw new Error(`client runtime refused: client state is ${state.kind}`);
63
+ const config = loadConfig();
64
+ const preferred = options.port ?? config.port ?? 10100;
65
+ const port = await findAvailablePort(preferred, "127.0.0.1", {
66
+ preferRetryMs: options.port === undefined ? 750 : 5_000,
67
+ preferRetryIntervalMs: 50,
68
+ allowEphemeralFallback: options.port === undefined,
69
+ });
70
+ const server = startMachineListener(port, { state: state.value });
71
+ const boundPort = server.port ?? port;
72
+ activeServer = server;
73
+ activePort = boundPort;
74
+ installCrashGuards();
75
+ writePid(process.pid);
76
+ writeRuntimePort({ pid: process.pid, port: boundPort, hostname: "127.0.0.1" });
77
+
78
+ let shuttingDown = false;
79
+ const shutdown = () => {
80
+ if (shuttingDown) return;
81
+ shuttingDown = true;
82
+ try { server.stop(true); } finally {
83
+ cleanup();
84
+ process.exit(0);
85
+ }
86
+ };
87
+ process.on("SIGINT", shutdown);
88
+ process.on("SIGTERM", shutdown);
89
+ if (process.platform !== "win32") process.on("SIGHUP", shutdown);
90
+ process.on("exit", cleanup);
91
+
92
+ if (options.block ?? true) await new Promise<void>(() => {});
93
+ }
@@ -0,0 +1,175 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ getConfigPath,
4
+ deleteConfigTopLevelKey,
5
+ getDefaultConfig,
6
+ mutatePersistedConfig,
7
+ readConfigDiagnostics,
8
+ saveConfig,
9
+ } from "../config";
10
+ import type { OcxClientConnectionConfig } from "../types";
11
+ import {
12
+ readServiceApiTokenState,
13
+ readTokenBackupState,
14
+ removeOrphanTokenBackup,
15
+ } from "../lib/service-secrets";
16
+
17
+ export type ClientConnectionState =
18
+ | { kind: "disconnected" }
19
+ | { kind: "connected"; value: OcxClientConnectionConfig }
20
+ | { kind: "invalid"; reason: string }
21
+ | { kind: "mismatched"; reason: string };
22
+
23
+ export type ClientRotationRecoveryGate =
24
+ | { kind: "clean" }
25
+ | { kind: "orphan-cleaned" }
26
+ | { kind: "recovery-required"; reason: string }
27
+ | { kind: "unsafe"; reason: string };
28
+
29
+ function rawTopLevelConfig(): Record<string, unknown> | null {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(getConfigPath(), "utf8").replace(/^\uFEFF/, "")) as unknown;
32
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
33
+ ? parsed as Record<string, unknown>
34
+ : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function readClientConnectionState(): ClientConnectionState {
41
+ const raw = rawTopLevelConfig();
42
+ const diagnostics = readConfigDiagnostics();
43
+ if (!raw) {
44
+ return diagnostics.source === "default"
45
+ ? { kind: "disconnected" }
46
+ : { kind: "invalid", reason: "config.json is missing or unreadable" };
47
+ }
48
+ const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
49
+ const role = raw.runtimeRole;
50
+ if (role !== undefined && role !== "standalone" && role !== "hub" && role !== "client") {
51
+ return { kind: "invalid", reason: "config.json.runtimeRole is invalid" };
52
+ }
53
+ if (!hasClient && (role === undefined || role === "standalone")) return { kind: "disconnected" };
54
+ // A hub is a server role, not a broken client: without client state it simply is not
55
+ // connected, and refusing here blocked `ocx start` on every hub (found on the first
56
+ // clisu-oracle dogfood boot). Hub role WITH client state remains mismatched below.
57
+ if (!hasClient && role === "hub") return { kind: "disconnected" };
58
+ if (!hasClient || role !== "client") {
59
+ return {
60
+ kind: "mismatched",
61
+ reason: hasClient
62
+ ? "config.json.client is present without runtimeRole=client"
63
+ : "runtimeRole=client is present without config.json.client",
64
+ };
65
+ }
66
+ const client = diagnostics.config.client;
67
+ if (!client) {
68
+ const warning = diagnostics.warnings?.find(value => value.startsWith("client"));
69
+ return { kind: "invalid", reason: warning ?? "config.json.client is malformed" };
70
+ }
71
+ return { kind: "connected", value: client };
72
+ }
73
+
74
+ /**
75
+ * Does the persisted config record a rotation that has not finished?
76
+ *
77
+ * Read fresh rather than taken from a caller-supplied snapshot: the whole point is to see a
78
+ * `pendingOperation` that landed after that snapshot was taken.
79
+ */
80
+ function rotationInFlight(): boolean {
81
+ const current = readClientConnectionState();
82
+ return current.kind === "connected" && current.value.pendingOperation !== undefined;
83
+ }
84
+
85
+ export function inspectClientRotationRecoveryGate(
86
+ state: ClientConnectionState = readClientConnectionState(),
87
+ ): ClientRotationRecoveryGate {
88
+ const current = readServiceApiTokenState();
89
+ const backup = readTokenBackupState();
90
+ if (state.kind === "connected" && state.value.pendingOperation) {
91
+ if (current.kind !== "present" || backup.kind !== "present") {
92
+ return {
93
+ kind: "unsafe",
94
+ reason: "pending key rotation requires owner-only service-api-token and service-api-token.prev files",
95
+ };
96
+ }
97
+ return {
98
+ kind: "recovery-required",
99
+ reason: "rerun ocx connect rotate with --pairing-code-stdin or --admin-token-stdin",
100
+ };
101
+ }
102
+ if (backup.kind === "unsafe") return { kind: "unsafe", reason: backup.reason };
103
+ if (backup.kind === "present" && current.kind === "present") {
104
+ // Only an ORPHAN backup is cleanable, and this branch cannot always tell an orphan from
105
+ // a backup belonging to a rotation that is mid-flight.
106
+ //
107
+ // `rotateConnectedClientKey` writes the .prev backup BEFORE it persists
108
+ // `pendingOperation`. A concurrent `ocx connect status` landing in that window sees
109
+ // "backup present, token present, no pending marker" — indistinguishable from a stale
110
+ // leftover — and deleted the live rollback target. If the rotation then failed, its
111
+ // restore had nothing to restore from.
112
+ //
113
+ // Re-reading the persisted state closes most of the window: the caller's `state` may
114
+ // have been captured before the marker landed, while a fresh read sees it. The
115
+ // remaining window is narrow enough that the rotation's own lock is the right owner,
116
+ // and deleting nothing is the safe side of it.
117
+ if (rotationInFlight()) {
118
+ return { kind: "recovery-required", reason: "a key rotation is in flight; leave service-api-token.prev in place" };
119
+ }
120
+ try {
121
+ removeOrphanTokenBackup();
122
+ return { kind: "orphan-cleaned" };
123
+ } catch (error) {
124
+ return { kind: "unsafe", reason: error instanceof Error ? error.message : "token backup cleanup failed" };
125
+ }
126
+ }
127
+ return { kind: "clean" };
128
+ }
129
+
130
+ export function commitClientConnection(
131
+
132
+ state: OcxClientConnectionConfig,
133
+ ): "committed" | "unchanged" {
134
+ const outcome = mutatePersistedConfig(config => {
135
+ const unchanged = config.runtimeRole === "client"
136
+ && JSON.stringify(config.client) === JSON.stringify(state);
137
+ if (!unchanged) {
138
+ config.runtimeRole = "client";
139
+ config.client = structuredClone(state);
140
+ }
141
+ return { changed: !unchanged, value: undefined };
142
+ });
143
+ if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status;
144
+ if (outcome.status === "unavailable" && outcome.reason === "missing") {
145
+ // First ocx run on a fresh machine: ocx connect is the expected first command in
146
+ // client mode, so there is no config.json yet. mutatePersistedConfig correctly
147
+ // refuses to invent one (a lost config must fail closed), but a genuinely absent
148
+ // file is the bootstrap case, not corruption — seed defaults plus the client
149
+ // block atomically. Found on the first MacBook↔oracle dogfood connect.
150
+ const seeded = getDefaultConfig();
151
+ seeded.runtimeRole = "client";
152
+ seeded.client = structuredClone(state);
153
+ saveConfig(seeded);
154
+ return "committed";
155
+ }
156
+ throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`);
157
+ }
158
+
159
+ export function clearClientConnection(
160
+ expectedApiKeyId: string,
161
+ ): "committed" | "absent" | "conflict" {
162
+ const outcome = mutatePersistedConfig(config => {
163
+ if (!config.client && config.runtimeRole !== "client") {
164
+ return { changed: false, value: "absent" as const };
165
+ }
166
+ if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) {
167
+ return { changed: false, value: "conflict" as const };
168
+ }
169
+ deleteConfigTopLevelKey(config, "client");
170
+ deleteConfigTopLevelKey(config, "runtimeRole");
171
+ return { changed: true, value: "committed" as const };
172
+ });
173
+ if (outcome.status === "unavailable") return "conflict";
174
+ return outcome.value;
175
+ }
@@ -1034,10 +1034,15 @@ export interface HermesProviderBlock {
1034
1034
  api_mode: "chat_completions";
1035
1035
  /** We supply the list, so skip their live `/models` probe. */
1036
1036
  discover_models: false;
1037
- models: string[];
1037
+ models: Record<string, HermesModelEntry>;
1038
1038
  extra_headers?: Record<string, string>;
1039
1039
  }
1040
1040
 
1041
+ /** Capability metadata Hermes cannot discover for a custom local provider. */
1042
+ export interface HermesModelEntry {
1043
+ supports_vision?: boolean;
1044
+ }
1045
+
1041
1046
  export interface HermesGeneratedConfig {
1042
1047
  providers: Record<string, HermesProviderBlock>;
1043
1048
  }
@@ -1311,7 +1316,13 @@ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): R
1311
1316
  }
1312
1317
 
1313
1318
  function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
1314
- const models = normalizeExportModels(ctx.models).map(model => model.namespaced);
1319
+ const models: Record<string, HermesModelEntry> = {};
1320
+ for (const model of normalizeExportModels(ctx.models)) {
1321
+ const declared = model.inputModalities;
1322
+ models[model.namespaced] = declared && declared.length > 0
1323
+ ? { supports_vision: declared.includes("image") }
1324
+ : {};
1325
+ }
1315
1326
  const headers = proxyAdmissionHeaders(ctx.config, HERMES_API_KEY_ENV_REF);
1316
1327
  return {
1317
1328
  providers: {
@@ -1606,9 +1617,9 @@ function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLim
1606
1617
  }
1607
1618
 
1608
1619
  function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1609
- const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? [];
1610
- // Hermes carries selectors only; it has no per-model limit to be missing.
1611
- return { modelCount: models.length, modelsWithoutLimits: 0 };
1620
+ const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {};
1621
+ // Hermes carries capability metadata but no per-model limit to be missing.
1622
+ return { modelCount: Object.keys(models).length, modelsWithoutLimits: 0 };
1612
1623
  }
1613
1624
 
1614
1625
  function summarizeOpenclaw(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
@@ -389,6 +389,49 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } {
389
389
  return { code: typeof obj.code === "string" ? obj.code : "unknown" };
390
390
  }
391
391
 
392
+ /**
393
+ * Background reset-credit access for the auto-redeemer (#822). Goes through the same
394
+ * account/lease wrapper as the management routes, but takes a caller-owned
395
+ * `redeem_request_id` so a journaled id can be replayed idempotently after a crash.
396
+ * Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous.
397
+ */
398
+ export function createResetCreditWhamClient(config: OcxConfig, accountId: string): {
399
+ inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>;
400
+ consume: (redeemRequestId: string) => Promise<{ code: string }>;
401
+ } {
402
+ const run = async <T>(operation: (auth: ResetCreditAuth) => Promise<T>): Promise<T> => {
403
+ const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation);
404
+ if (result.ok) return result.value;
405
+ throw new Error(`reset-credit auth unavailable (${result.response.status})`);
406
+ };
407
+ return {
408
+ inspect: () => run(async auth => {
409
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", {
410
+ headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId },
411
+ signal: AbortSignal.timeout(8000),
412
+ });
413
+ if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
414
+ const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000));
415
+ if (!parsed.ok) throw new Error("invalid upstream reset-credit response");
416
+ return { credits: safeResetCreditsDto(parsed.value).credits };
417
+ }),
418
+ consume: redeemRequestId => run(async auth => {
419
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
420
+ method: "POST",
421
+ headers: {
422
+ Authorization: `Bearer ${auth.accessToken}`,
423
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
424
+ "Content-Type": "application/json",
425
+ },
426
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
427
+ signal: AbortSignal.timeout(10_000),
428
+ });
429
+ if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); }
430
+ return safeResetCreditConsumeDto(await resp.json());
431
+ }),
432
+ };
433
+ }
434
+
392
435
  type ResetCreditJsonRead =
393
436
  | { ok: true; value: unknown }
394
437
  | { ok: false };
@@ -843,6 +886,23 @@ interface PoolQuotaResult {
843
886
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
844
887
  freshResetCredits?: number;
845
888
  quotaProbeSkipped?: true;
889
+ /** Positive evidence captured immediately before an upstream WHAM dispatch. */
890
+ quotaProbeAttempted?: { at: number; credentialGeneration: number };
891
+ }
892
+
893
+ interface PoolQuotaProbeEvidence {
894
+ attempted?: NonNullable<PoolQuotaResult["quotaProbeAttempted"]>;
895
+ }
896
+
897
+ function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void {
898
+ evidence.attempted = { at: Date.now(), credentialGeneration };
899
+ }
900
+
901
+ function withQuotaProbeEvidence(
902
+ result: PoolQuotaResult,
903
+ evidence: PoolQuotaProbeEvidence,
904
+ ): PoolQuotaResult {
905
+ return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result;
846
906
  }
847
907
 
848
908
  interface PoolQuotaRefreshFlight {
@@ -988,6 +1048,7 @@ async function recoverPoolQuotaFrom401(ctx: {
988
1048
  rejectedAccessToken: string;
989
1049
  rejectedGeneration: number;
990
1050
  resp: Response;
1051
+ quotaProbeEvidence: PoolQuotaProbeEvidence;
991
1052
  onCredentialGeneration?: (generation: number) => void;
992
1053
  }): Promise<PoolQuotaResult> {
993
1054
  const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
@@ -1061,6 +1122,7 @@ async function recoverPoolQuotaFrom401(ctx: {
1061
1122
  ctx.onCredentialGeneration?.(refreshed.generation);
1062
1123
 
1063
1124
  const writerGeneration = captureConfigGeneration();
1125
+ markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
1064
1126
  const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1065
1127
  headers: {
1066
1128
  Authorization: `Bearer ${refreshed.accessToken}`,
@@ -1147,57 +1209,75 @@ async function fetchFreshPoolAccountQuota(
1147
1209
  existing: StoredAccountQuota | null,
1148
1210
  configuredPlan?: string,
1149
1211
  onCredentialGeneration?: (generation: number) => void,
1212
+ getValidToken: typeof getValidCodexToken = getValidCodexToken,
1150
1213
  ): Promise<PoolQuotaResult> {
1151
1214
  const writerGeneration = captureConfigGeneration();
1152
1215
  let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
1216
+ const quotaProbeEvidence: PoolQuotaProbeEvidence = {};
1153
1217
  try {
1154
- const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
1218
+ const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
1155
1219
  requestCredentialGeneration = generation;
1156
1220
  onCredentialGeneration?.(generation);
1221
+ markQuotaProbeAttempted(quotaProbeEvidence, generation);
1157
1222
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1158
1223
  headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
1159
1224
  signal: AbortSignal.timeout(8000),
1160
1225
  });
1161
1226
  if (!resp.ok) {
1162
1227
  if (resp.status !== 401) {
1163
- return { quota: existing ?? null, needsReauth: false, credentialGeneration: generation };
1228
+ return withQuotaProbeEvidence(
1229
+ { quota: existing ?? null, needsReauth: false, credentialGeneration: generation },
1230
+ quotaProbeEvidence,
1231
+ );
1164
1232
  }
1165
1233
  // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
1166
1234
  // quarantining on it tells the operator to re-authenticate an account that was fine
1167
1235
  // (#3019). Refresh once, replay once, and only then decide.
1168
- return await recoverPoolQuotaFrom401({
1236
+ const recovered = await recoverPoolQuotaFrom401({
1169
1237
  accountId,
1170
1238
  existing,
1171
1239
  configuredPlan,
1172
1240
  rejectedAccessToken: accessToken,
1173
1241
  rejectedGeneration: generation,
1174
1242
  resp,
1243
+ quotaProbeEvidence,
1175
1244
  onCredentialGeneration,
1176
1245
  });
1246
+ return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
1177
1247
  }
1178
- return await commitPoolQuotaResponse(resp, {
1248
+ const committed = await commitPoolQuotaResponse(resp, {
1179
1249
  accountId, existing, configuredPlan, generation, writerGeneration,
1180
1250
  });
1251
+ return withQuotaProbeEvidence(committed, quotaProbeEvidence);
1181
1252
  } catch (e) {
1182
1253
  if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
1183
1254
  || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
1184
- return {
1255
+ return withQuotaProbeEvidence({
1185
1256
  quota: existing ?? null,
1186
1257
  needsReauth: false,
1187
1258
  credentialGeneration: requestCredentialGeneration,
1188
- ...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
1189
- ? { quotaProbeSkipped: true as const }
1190
- : {}),
1191
- };
1259
+ quotaProbeSkipped: true,
1260
+ }, quotaProbeEvidence);
1192
1261
  }
1193
1262
  if (e instanceof TokenRefreshError) {
1194
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
1263
+ return withQuotaProbeEvidence(
1264
+ { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
1265
+ quotaProbeEvidence,
1266
+ );
1195
1267
  }
1196
- return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
1268
+ return withQuotaProbeEvidence(
1269
+ { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration },
1270
+ quotaProbeEvidence,
1271
+ );
1197
1272
  }
1198
1273
  }
1199
1274
 
1200
- async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
1275
+ async function fetchPoolAccountQuota(
1276
+ accountId: string,
1277
+ forceRefresh = false,
1278
+ configuredPlan?: string,
1279
+ getValidToken: typeof getValidCodexToken = getValidCodexToken,
1280
+ ): Promise<PoolQuotaResult> {
1201
1281
  const existing = getAccountQuota(accountId);
1202
1282
  if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
1203
1283
  return {
@@ -1227,6 +1307,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
1227
1307
  existing,
1228
1308
  configuredPlan,
1229
1309
  generation => { state.resolvedCredentialGeneration = generation; },
1310
+ getValidToken,
1230
1311
  );
1231
1312
  const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
1232
1313
  const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
@@ -1243,6 +1324,16 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
1243
1324
  }
1244
1325
 
1245
1326
  let primeInFlight: Promise<void> | null = null;
1327
+ /**
1328
+ * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
1329
+ * without this the account stays "unknown" and every later prime trigger re-selects
1330
+ * it as stale and repeats the same failing request. Successful lookups are already
1331
+ * throttled by their stored updatedAt; this gives failures the same TTL backoff.
1332
+ *
1333
+ * Keyed by credential generation so a re-authentication, refresh, or account removal
1334
+ * retries immediately instead of waiting out a backoff earned by the old credential.
1335
+ */
1336
+ const poolQuotaPrimeAttemptedAt = new Map<string, { generation: number; at: number }>();
1246
1337
  let cooldownRecoveryInFlight: Promise<void> | null = null;
1247
1338
 
1248
1339
  export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
@@ -1294,6 +1385,19 @@ export interface PrimeCodexPoolQuotasOptions {
1294
1385
  fetchMainInfo?: typeof fetchMainAccountInfo;
1295
1386
  }
1296
1387
 
1388
+ let getValidPoolTokenForPrime = getValidCodexToken;
1389
+
1390
+ /** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */
1391
+ export function setCodexPoolQuotaTokenResolverForTests(
1392
+ resolver: typeof getValidCodexToken,
1393
+ ): () => void {
1394
+ const previous = getValidPoolTokenForPrime;
1395
+ getValidPoolTokenForPrime = resolver;
1396
+ return () => {
1397
+ if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous;
1398
+ };
1399
+ }
1400
+
1297
1401
  function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
1298
1402
  return tryAcquireNativeMainProfileClaim();
1299
1403
  }
@@ -1319,6 +1423,16 @@ export async function primeCodexPoolQuotas(
1319
1423
  options: PrimeCodexPoolQuotasOptions = {},
1320
1424
  ): Promise<void> {
1321
1425
  const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
1426
+ // Prune attempt markers for accounts that no longer exist BEFORE the eligibility
1427
+ // return. A removal that happens while the provider is disabled or out of pool mode
1428
+ // would otherwise leave a stale failure marker behind; restoring the same account id
1429
+ // within POOL_CACHE_TTL would then read that old failure as current and skip the
1430
+ // retry the restored credential is entitled to.
1431
+ const runtimeConfig = getRuntimeConfig(config);
1432
+ const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id));
1433
+ for (const accountId of poolQuotaPrimeAttemptedAt.keys()) {
1434
+ if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId);
1435
+ }
1322
1436
  if (
1323
1437
  !openai
1324
1438
  || openai.disabled === true
@@ -1327,11 +1441,18 @@ export async function primeCodexPoolQuotas(
1327
1441
  ) return;
1328
1442
  if (primeInFlight) return primeInFlight;
1329
1443
  primeInFlight = (async () => {
1330
- const runtimeConfig = getRuntimeConfig(config);
1331
1444
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
1332
1445
  const stale = pool.filter(a => {
1333
1446
  const q = getAccountQuota(a.id);
1334
- return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
1447
+ if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
1448
+ // No stored quota: either never primed, or the last attempt failed. Retry only
1449
+ // once per TTL window so an unreachable or rejecting account cannot turn every
1450
+ // prime trigger into another upstream request.
1451
+ const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id);
1452
+ if (!lastAttempt) return true;
1453
+ // A newer credential invalidates the previous failure: retry without waiting.
1454
+ if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
1455
+ return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
1335
1456
  });
1336
1457
  const primeMain = async () => {
1337
1458
  const mainLease = tryAcquireNativeMainPrimeLease();
@@ -1359,7 +1480,31 @@ export async function primeCodexPoolQuotas(
1359
1480
  primeMain(),
1360
1481
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
1361
1482
  if (!getCodexAccountCredential(a.id)) return;
1362
- await fetchPoolAccountQuota(a.id, false, a.plan);
1483
+ let result: PoolQuotaResult;
1484
+ try {
1485
+ result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime);
1486
+ } catch (error) {
1487
+ // Local quota-flight saturation proves no WHAM request existed for this account.
1488
+ // Consume it per item so sibling workers remain inside the shared prime lifetime.
1489
+ if (error instanceof PoolQuotaProbeBusyError) return;
1490
+ throw error;
1491
+ }
1492
+ // Only the data-plane function knows whether upstream dispatch began. Any
1493
+ // cache hit, credential deferral, or local admission failure remains eligible.
1494
+ const attempted = result.quotaProbeAttempted;
1495
+ if (!attempted) return;
1496
+ if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) {
1497
+ poolQuotaPrimeAttemptedAt.delete(a.id);
1498
+ return;
1499
+ }
1500
+ poolQuotaPrimeAttemptedAt.set(a.id, {
1501
+ // getValidCodexToken may rotate the credential before WHAM is sent.
1502
+ // Bind the backoff to the generation that actually made the request;
1503
+ // otherwise the next prime sees a false generation change and retries
1504
+ // the same failed WHAM call immediately.
1505
+ generation: attempted.credentialGeneration,
1506
+ at: attempted.at,
1507
+ });
1363
1508
  }),
1364
1509
  ]);
1365
1510
  } catch {
@@ -1376,6 +1521,15 @@ export async function primeCodexPoolQuotas(
1376
1521
  * from another suite cannot coalesce into the next prime. */
1377
1522
  export function clearCodexQuotaPrimeState(): void {
1378
1523
  primeInFlight = null;
1524
+ poolQuotaPrimeAttemptedAt.clear();
1525
+ getValidPoolTokenForPrime = getValidCodexToken;
1526
+ }
1527
+
1528
+ /** Test-only: drop the shared single-flight promise while keeping the per-account
1529
+ * failure backoff, so a test can trigger a second real prime pass and still observe
1530
+ * the throttle a production caller would see. */
1531
+ export function clearCodexQuotaPrimeSingleFlightForTests(): void {
1532
+ primeInFlight = null;
1379
1533
  }
1380
1534
 
1381
1535
  /** Test-only reset for the worker-level single-flight. */