@timo972/cc-router 0.12.0 → 0.12.1-rc.1

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.
package/CHANGELOG.md CHANGED
@@ -8,22 +8,58 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- ---
11
+ ### Added
12
+
13
+ - Dashboard `Ctrl+R` confirms redemption of one banked usage-limit reset for
14
+ the focused ChatGPT account, with duplicate-submission protection and stable
15
+ retry IDs for uncertain outcomes. No custom Meta key mapping is needed.
16
+ Account usage is refreshed after redemption.
17
+
18
+ - Dashboard `[R]` reloads account usage and due tokens without restarting the
19
+ router or dropping active requests and sticky sessions. It also refreshes
20
+ Grok snapshots, CLI routing state, and an already-loaded model list, with
21
+ progress and partial-failure summaries.
22
+ - `POST /cc-router/refresh` runs a shared Claude/OpenAI refresh pass, sweeps
23
+ expired cooldowns, and returns per-provider results. Concurrent reloads
24
+ join the same pass instead of starting duplicate work.
25
+ - OpenAI transport diagnostics include correlation IDs and refresh, response
26
+ header, and first-byte timings. Failure logs use bounded diagnostic fields
27
+ rather than raw upstream error messages.
28
+
29
+ ### Changed
12
30
 
13
- ## [0.12.0-rc.1] 2026-08-30
31
+ - The configured proxy request timeout now bounds Codex response headers and
32
+ upstream stream inactivity on both `/v1/responses` and OpenAI-routed
33
+ `/v1/messages`, including failover attempts. Progressing streams can outlive
34
+ that interval; time spent waiting for a slow client does not count as
35
+ upstream inactivity.
36
+ - Permanently rejected OpenAI refresh credentials are quarantined from
37
+ inference routing without changing the account's saved `enabled` setting.
38
+ Health reports expose the authentication state and failure category.
39
+ Successful refresh or credential replacement restores eligibility;
40
+ quarantine is runtime-only and does not survive a router restart.
14
41
 
15
42
  ### Fixed
16
43
 
17
- - OpenAI Responses function calls and their outputs remain top-level input
18
- items across the Anthropic Messages bridge. JSON and SSE responses now
19
- preserve call IDs, streamed or atomic arguments, refusal text, and
20
- `tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
21
- streams that end before completion fail closed instead of fabricating a
22
- successful assistant turn.
44
+ - OpenAI OAuth refresh sends the required public `client_id` and has a
45
+ 15-second deadline covering response headers and JSON body parsing, so a
46
+ stalled refresh cannot retain its lock indefinitely.
47
+ - Refresh deduplication is scoped to the account object, preventing a deleted
48
+ and re-added account from sharing an old account's in-flight refresh.
49
+ - OpenAI usage authentication failures remain advisory rather than disabling
50
+ an account or overriding permanent credential quarantine. Background and
51
+ manual token refreshes continue to check quarantined accounts for recovery.
52
+ - OpenAI response relays honor downstream backpressure and cancel upstream
53
+ reads when the client disconnects. Failed partial streams close the
54
+ connection instead of appearing complete, and router-side stream failures
55
+ are no longer misclassified as client cancellations.
56
+ - Codex header timeouts return a safe HTTP 504 response, including after
57
+ account failover, while preserving the existing retry budget and shared
58
+ request correlation.
23
59
 
24
60
  ---
25
61
 
26
- ## [0.12.0-rc.0] — 2026-08-30
62
+ ## [0.12.0] — 2026-09-11
27
63
 
28
64
  ### Added
29
65
 
@@ -48,11 +84,19 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
48
84
  - Codex configuration rewrites now use TOML-aware parsing and validation rather
49
85
  than line-oriented edits, including safe CLI start/stop toggles for the
50
86
  managed block.
51
- - The status dashboard uses compact provider groups, keeps account headers
52
- visible in short terminals, and exposes the fleet-wide weekly-full count.
87
+ - The status dashboard has been redesigned with a more compact layout,
88
+ allowing even more accounts to be displayed at once. Compact provider
89
+ groups keep account headers visible in short terminals, and the dashboard
90
+ exposes the fleet-wide weekly-full count.
53
91
 
54
92
  ### Fixed
55
93
 
94
+ - OpenAI Responses function calls and their outputs remain top-level input
95
+ items across the Anthropic Messages bridge. JSON and SSE responses now
96
+ preserve call IDs, streamed or atomic arguments, refusal text, and
97
+ `tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
98
+ streams that end before completion fail closed instead of fabricating a
99
+ successful assistant turn.
56
100
  - `/v1/models` reports real context windows and includes bare `gpt-*` slugs used
57
101
  by the Codex CLI.
58
102
  - Codex routing accepts both dashed session-header spellings, logs route
@@ -670,8 +714,7 @@ cache-aware session routing and a round of security hardening.
670
714
  - `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
671
715
  affected APIs are not used here.
672
716
 
673
- [0.12.0-rc.1]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.1
674
- [0.12.0-rc.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.0
717
+ [0.12.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0
675
718
  [0.11.0]: https://github.com/Timo972/cc-router/releases/tag/v0.11.0
676
719
  [0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
677
720
  [0.8.3]: https://github.com/Timo972/cc-router/releases/tag/v0.8.3
package/README.md CHANGED
@@ -588,11 +588,30 @@ The dashboard is also a control surface. In local mode it controls the local pro
588
588
  | `e` | Enable/disable selected Claude account |
589
589
  | `w` / `s` | Change selected Claude account weekly/session cap |
590
590
  | `d` | Delete selected Claude account |
591
+ | `Ctrl+R` | Confirm redeeming one banked usage-limit reset for the focused ChatGPT account |
592
+ | `R` | Reload account usage and due credentials without restarting the router |
591
593
  | `m` / `r` | Load or refresh discovered provider models |
592
594
  | `c` | Toggle Claude Code routing (or set Claude model default when MODELS is focused) |
593
595
  | `x` | Toggle Codex CLI routing (proxy stays up) |
594
596
  | `o` | Set selected `openai/*` model as OpenAI default |
595
597
 
598
+ To redeem a ChatGPT reset, press `Tab` to focus accounts, select the account
599
+ with the arrow keys, then press `Ctrl+R` and confirm with `y` (`n` or `Esc`
600
+ cancels). The `rst` column shows banked reset credits. Accounts with zero or
601
+ unknown credits cannot start a new redemption. This spends a real reset credit;
602
+ it is not the same as refreshing usage with uppercase `R`.
603
+
604
+ Press **Control + R** (not Command + R or Shift + R). This uses the standard
605
+ terminal Ctrl+R sequence and does not require a custom Meta/Option key mapping.
606
+
607
+ Redemption targets only that account and refreshes its usage afterward. If the
608
+ network outcome is unknown, keep the dashboard open and retry the shortcut: it
609
+ reuses the same redemption ID instead of spending another credit. Those retry
610
+ IDs are held for the current dashboard session only; after restarting it,
611
+ inspect usage before requesting another reset. A successful redemption with a
612
+ failed usage refresh is reported separately. Confirmed resets plus fresh usage clear only superseded quota cooldowns.
613
+ Overload holds, unreported/exhausted limits, and newer quota signals are preserved.
614
+
596
615
  List and change models without waiting for a package update:
597
616
 
598
617
  ```bash
@@ -53,6 +53,7 @@ export function createOpenAIAccount(record) {
53
53
  lastRefresh: 0,
54
54
  rateLimits,
55
55
  modelBuckets: new Map(),
56
+ authState: "ok",
56
57
  };
57
58
  }
58
59
  /**
@@ -1,18 +1,33 @@
1
+ import { createHeaderDeadline, withStreamIdleTimeout } from "../../proxy/transport-timing.js";
1
2
  const CODEX_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
2
3
  const DEFAULT_CODEX_INSTRUCTIONS = "You are a concise coding assistant.";
3
4
  export async function forwardOpenAICodexResponse(opts) {
4
5
  const body = toCodexBackendRequest(opts.body);
5
- const upstream = await fetch(CODEX_RESPONSES_ENDPOINT, {
6
- method: "POST",
7
- headers: {
8
- authorization: `Bearer ${opts.account.accessToken}`,
9
- "content-type": "application/json",
10
- accept: "text/event-stream",
11
- },
12
- body: JSON.stringify(body),
13
- ...(opts.signal ? { signal: opts.signal } : {}),
14
- });
15
- return ensureEventStreamContentType(upstream);
6
+ const deadline = createHeaderDeadline(opts.timeoutMs, opts.signal);
7
+ let upstream;
8
+ try {
9
+ upstream = await fetch(CODEX_RESPONSES_ENDPOINT, {
10
+ method: "POST",
11
+ headers: {
12
+ authorization: `Bearer ${opts.account.accessToken}`,
13
+ "content-type": "application/json",
14
+ accept: "text/event-stream",
15
+ },
16
+ body: JSON.stringify(body),
17
+ ...(deadline.signal ? { signal: deadline.signal } : {}),
18
+ });
19
+ }
20
+ finally {
21
+ // The header deadline must not become an absolute generation deadline.
22
+ // Body progress and cancellation are owned by the stream wrapper below.
23
+ deadline.dispose();
24
+ }
25
+ const timedBody = withStreamIdleTimeout(upstream.body, opts.timeoutMs, opts.signal);
26
+ return ensureEventStreamContentType(new Response(timedBody, {
27
+ status: upstream.status,
28
+ statusText: upstream.statusText,
29
+ headers: upstream.headers,
30
+ }));
16
31
  }
17
32
  export function toCodexBackendRequest(body) {
18
33
  const { max_output_tokens: _maxOutputTokens, ...rest } = body;
@@ -1,6 +1,6 @@
1
1
  import { createOpenAIAccountRecord } from "./account-record.js";
2
2
  const DEFAULT_ISSUER = "https://auth.openai.com";
3
- const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
3
+ export const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
4
4
  const DEFAULT_SCOPE = "openid profile email offline_access";
5
5
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
6
6
  function issuerOf(opts) {
@@ -69,8 +69,13 @@ export class OpenAITokenPool {
69
69
  // Each cause extends its own horizon, so neither can misrepresent the
70
70
  // other's duration in either direction.
71
71
  state.globalUntil = Math.max(state.globalUntil, expiry);
72
- if (cause === "rate_limit")
72
+ if (cause === "rate_limit") {
73
73
  state.rateLimitedUntil = Math.max(state.rateLimitedUntil, expiry);
74
+ state.quotaRevision++;
75
+ }
76
+ else {
77
+ state.unavailableUntil = Math.max(state.unavailableUntil, expiry);
78
+ }
74
79
  }
75
80
  setBucketCooldownForAccount(account, limitId, durationMs) {
76
81
  const expiry = this.proposedExpiry(account, durationMs);
@@ -80,6 +85,7 @@ export class OpenAITokenPool {
80
85
  if (!state.bucketUntil.has(limitId))
81
86
  this.makeRoomForBucketCooldown(state);
82
87
  state.bucketUntil.set(limitId, Math.max(state.bucketUntil.get(limitId) ?? 0, expiry));
88
+ state.quotaRevision++;
83
89
  }
84
90
  /**
85
91
  * Keep `bucketUntil` bounded before a new limit id is added. Every distinct
@@ -114,6 +120,31 @@ export class OpenAITokenPool {
114
120
  if (soonestId !== undefined)
115
121
  state.bucketUntil.delete(soonestId);
116
122
  }
123
+ /** Capture before a redemption. Apply only after confirmed redemption and a
124
+ * fresh post-redemption usage fetch. New quota evidence conservatively fences
125
+ * the whole reconciliation; unrelated overload holds retain their own expiry. */
126
+ captureUsageReset(account) {
127
+ const state = this.cooldowns.get(account);
128
+ const revision = state?.quotaRevision;
129
+ return update => {
130
+ if (!state || this.findById(account.id) !== account
131
+ || this.cooldowns.get(account) !== state || state.quotaRevision !== revision)
132
+ return;
133
+ for (const bucket of update.buckets) {
134
+ const windows = [bucket.primary, bucket.secondary].filter(window => window !== undefined);
135
+ // An omitted bucket/window is not evidence of recovery. Exhaustion in
136
+ // retained windows is still independently enforced by hardBlock().
137
+ if (windows.length === 0 || windows.some(window => window.utilization >= 1))
138
+ continue;
139
+ if (bucket.limitId === DEFAULT_CODEX_LIMIT_ID) {
140
+ state.rateLimitedUntil = 0;
141
+ state.globalUntil = state.unavailableUntil;
142
+ }
143
+ state.bucketUntil.delete(bucket.limitId);
144
+ }
145
+ this.sweepExpiredCooldowns();
146
+ };
147
+ }
117
148
  getCooldownView(accountId) {
118
149
  const account = this.findById(accountId);
119
150
  if (!account)
@@ -220,6 +251,8 @@ export class OpenAITokenPool {
220
251
  return this.accounts;
221
252
  }
222
253
  hardBlock(account, context) {
254
+ if (account.authState === "quarantined")
255
+ return { reason: "unavailable" };
223
256
  if (!account.enabled || !account.healthy)
224
257
  return { reason: "unavailable" };
225
258
  const nowMs = this.now();
@@ -349,7 +382,7 @@ export class OpenAITokenPool {
349
382
  cooldownsFor(account) {
350
383
  let state = this.cooldowns.get(account);
351
384
  if (!state) {
352
- state = { globalUntil: 0, rateLimitedUntil: 0, bucketUntil: new Map() };
385
+ state = { globalUntil: 0, rateLimitedUntil: 0, unavailableUntil: 0, quotaRevision: 0, bucketUntil: new Map() };
353
386
  this.cooldowns.set(account, state);
354
387
  }
355
388
  return state;
@@ -361,6 +394,8 @@ export class OpenAITokenPool {
361
394
  return false;
362
395
  const now = this.now();
363
396
  let recovered = false;
397
+ if (state.unavailableUntil > 0 && state.unavailableUntil <= now)
398
+ state.unavailableUntil = 0;
364
399
  if (state.rateLimitedUntil > 0 && state.rateLimitedUntil <= now)
365
400
  state.rateLimitedUntil = 0;
366
401
  if (state.globalUntil > 0 && state.globalUntil <= now) {
@@ -1,8 +1,13 @@
1
1
  import { decodeOpenAIPlan } from "./usage.js";
2
+ import { createHeaderDeadline } from "../../proxy/transport-timing.js";
3
+ import { logError } from "../../proxy/logger.js";
4
+ import { createCorrelationId, formatTransportDiagnostic, safeCauseCode, } from "../../proxy/transport-diagnostics.js";
5
+ import { DEFAULT_CLIENT_ID } from "./device-oauth.js";
2
6
  const TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
3
7
  const REFRESH_BUFFER_MS = 10 * 60 * 1000;
4
8
  const CHECK_INTERVAL_MS = 5 * 60 * 1000;
5
- const refreshLocks = new Map();
9
+ export const OPENAI_REFRESH_TIMEOUT_MS = 15_000;
10
+ const refreshLocks = new WeakMap();
6
11
  /**
7
12
  * Accounts whose most recently rotated credentials have NOT been confirmed
8
13
  * on disk. Identity-keyed (by object reference, not `account.id`) so a
@@ -63,20 +68,22 @@ export function needsOpenAIRefresh(account) {
63
68
  return account.expiresAt - Date.now() < REFRESH_BUFFER_MS;
64
69
  }
65
70
  export async function refreshOpenAISubscriptionToken(account) {
66
- const existing = refreshLocks.get(account.id);
71
+ const existing = refreshLocks.get(account);
67
72
  if (existing)
68
73
  return existing;
69
74
  const promise = doRefresh(account);
70
- refreshLocks.set(account.id, promise);
75
+ refreshLocks.set(account, promise);
71
76
  try {
72
77
  return await promise;
73
78
  }
74
79
  finally {
75
- refreshLocks.delete(account.id);
80
+ refreshLocks.delete(account);
76
81
  }
77
82
  }
78
83
  export async function prepareOpenAIAccountForRequest(account, allAccounts, saveAccounts) {
79
- if (!needsOpenAIRefresh(account)) {
84
+ const runtime = account;
85
+ // A revoked but unexpired access token must not bypass the refresh gate.
86
+ if (!needsOpenAIRefresh(account) && runtime.authState !== "quarantined") {
80
87
  // No refresh due, but a previous rotation from this account never made it
81
88
  // to disk (e.g. a transient disk-full). This is the retry path: piggyback
82
89
  // on this otherwise-idle request to flush the still-current in-memory
@@ -104,63 +111,151 @@ export async function refreshAndPersistOpenAIAccount(account, allAccounts, saveA
104
111
  persistCredentials(account, allAccounts, saveAccounts);
105
112
  return ok;
106
113
  }
107
- export function startOpenAIRefreshLoop(accounts, saveAccounts) {
108
- const check = async () => {
109
- for (const account of accounts) {
110
- // One account's refresh throwing must not skip every account after it
111
- // in this tick isolate failures per-account.
112
- try {
113
- await prepareOpenAIAccountForRequest(account, accounts, saveAccounts);
114
- }
115
- catch (error) {
116
- console.error(error);
117
- }
114
+ /**
115
+ * One scheduled refresh pass over every OpenAI account: due tokens are
116
+ * refreshed, quarantined ones re-tried. Shared by the background loop and
117
+ * the manual reload endpoint so both isolate failures per account the same
118
+ * way. Returns how many accounts did not come out with usable credentials —
119
+ * an expected refresh rejection counts, not only a thrown error.
120
+ */
121
+ export async function refreshOpenAIAccountsOnce(accounts, saveAccounts, options = {}) {
122
+ let failed = 0;
123
+ for (const account of [...accounts]) {
124
+ // One account's refresh throwing must not skip every account after it
125
+ // in this tick — isolate failures per-account.
126
+ try {
127
+ if (!await prepareOpenAIAccountForRequest(account, accounts, saveAccounts))
128
+ failed++;
129
+ }
130
+ catch (error) {
131
+ failed++;
132
+ (options.onError ?? console.error)(error);
118
133
  }
119
- };
134
+ }
135
+ return { failed };
136
+ }
137
+ export function startOpenAIRefreshLoop(accounts, saveAccounts) {
138
+ const check = () => refreshOpenAIAccountsOnce(accounts, saveAccounts);
120
139
  const timer = setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
121
140
  queueMicrotask(() => { check().catch(console.error); });
122
141
  return () => clearInterval(timer);
123
142
  }
143
+ function refreshErrorCode(payload, depth = 0) {
144
+ if (typeof payload === "string")
145
+ return payload;
146
+ if (depth >= 3 || typeof payload !== "object" || payload === null)
147
+ return undefined;
148
+ const record = payload;
149
+ for (const key of ["code", "type", "error"]) {
150
+ const code = refreshErrorCode(record[key], depth + 1);
151
+ if (code)
152
+ return code;
153
+ }
154
+ return undefined;
155
+ }
156
+ function rejectIsPermanent(status, payload) {
157
+ if (status !== 400 && status !== 401)
158
+ return false;
159
+ const code = refreshErrorCode(payload);
160
+ // `token_expired` is the code the real endpoint returns for a refresh token it
161
+ // can no longer validate; like the OAuth2-standard codes it means re-auth, not
162
+ // a retriable blip, so it must quarantine rather than cooldown-loop forever.
163
+ return code === "invalid_grant" || code === "invalid_token" || code === "token_revoked" || code === "token_expired";
164
+ }
165
+ function markRefreshFailure(account, permanent) {
166
+ const runtime = account;
167
+ if (permanent) {
168
+ runtime.authFailure = "permanent";
169
+ runtime.authState = "quarantined";
170
+ }
171
+ else if (runtime.authState !== "quarantined") {
172
+ runtime.authFailure = "transient";
173
+ }
174
+ }
175
+ function logRefreshFailure(account, correlationId, status, error) {
176
+ logError(account.id, status !== undefined && status >= 400 ? status : 0, formatTransportDiagnostic({
177
+ correlationId,
178
+ operation: "refresh",
179
+ ...(status !== undefined ? { status } : {}),
180
+ causeCode: safeCauseCode(error),
181
+ }));
182
+ }
124
183
  async function doRefresh(account) {
125
184
  const body = new URLSearchParams({
126
185
  grant_type: "refresh_token",
127
186
  refresh_token: account.refreshToken,
187
+ // The token endpoint validates client_id before the grant; without it the
188
+ // refresh 400s as `missing_required_parameter` and never reaches token
189
+ // validation. Same public client the tokens were minted under (device-oauth).
190
+ client_id: DEFAULT_CLIENT_ID,
128
191
  });
129
192
  let data;
193
+ let responseStatus;
194
+ const correlationId = createCorrelationId();
195
+ const deadline = createHeaderDeadline(OPENAI_REFRESH_TIMEOUT_MS);
130
196
  try {
131
197
  const res = await fetch(TOKEN_ENDPOINT, {
132
198
  method: "POST",
133
199
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
134
200
  body: body.toString(),
201
+ signal: deadline.signal,
135
202
  });
136
- if (!res.ok)
203
+ responseStatus = res.status;
204
+ if (!res.ok) {
205
+ let payload;
206
+ try {
207
+ payload = await res.json();
208
+ }
209
+ catch { /* intentionally do not retain response bodies */ }
210
+ markRefreshFailure(account, rejectIsPermanent(res.status, payload));
211
+ logRefreshFailure(account, correlationId, res.status);
137
212
  return false;
213
+ }
138
214
  data = await res.json();
139
215
  }
140
- catch {
216
+ catch (error) {
141
217
  // Network failure (or malformed response body) must resolve to `false`,
142
218
  // exactly like a non-ok HTTP response — never propagate as a rejection.
219
+ markRefreshFailure(account, false);
220
+ logRefreshFailure(account, correlationId, responseStatus, error);
143
221
  return false;
144
222
  }
223
+ finally {
224
+ // OAuth is a small JSON exchange: unlike inference, the deadline covers
225
+ // both headers and body parsing so a stalled body cannot retain the lock.
226
+ deadline.dispose();
227
+ }
145
228
  // A 200 with an unusable payload is a failed refresh, not a successful one.
146
229
  // Writing it through would leave `expiresAt` as NaN, which then reads as
147
230
  // "never needs refreshing" in `needsOpenAIRefresh` and permanently strands
148
231
  // the account on a broken token.
149
- if (typeof data?.access_token !== "string" || data.access_token.length === 0)
232
+ if (typeof data?.access_token !== "string" || data.access_token.length === 0) {
233
+ markRefreshFailure(account, false);
234
+ logRefreshFailure(account, correlationId, 200);
150
235
  return false;
236
+ }
151
237
  // The lifetime has to be positive and has to still name a finite instant
152
238
  // once converted. A zero or negative `expires_in` would report success on a
153
239
  // token that is already due for another refresh, so every request re-enters
154
240
  // the refresh path; a value big enough to overflow the multiplication would
155
241
  // set `expiresAt` to Infinity, which `needsOpenAIRefresh` can never reach —
156
242
  // the same permanent strand as NaN, from the opposite direction.
157
- if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in))
243
+ if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in)) {
244
+ markRefreshFailure(account, false);
245
+ logRefreshFailure(account, correlationId, 200);
158
246
  return false;
159
- if (data.expires_in <= 0)
247
+ }
248
+ if (data.expires_in <= 0) {
249
+ markRefreshFailure(account, false);
250
+ logRefreshFailure(account, correlationId, 200);
160
251
  return false;
252
+ }
161
253
  const expiresAt = Date.now() + data.expires_in * 1000;
162
- if (!Number.isFinite(expiresAt))
254
+ if (!Number.isFinite(expiresAt)) {
255
+ markRefreshFailure(account, false);
256
+ logRefreshFailure(account, correlationId, 200);
163
257
  return false;
258
+ }
164
259
  account.accessToken = data.access_token;
165
260
  account.refreshToken = data.refresh_token ?? account.refreshToken;
166
261
  account.expiresAt = expiresAt;
@@ -175,6 +270,8 @@ async function doRefresh(account) {
175
270
  runtime.consecutiveErrors = 0;
176
271
  if (runtime.lastRefresh !== undefined)
177
272
  runtime.lastRefresh = Date.now();
273
+ runtime.authState = "ok";
274
+ runtime.authFailure = undefined;
178
275
  // The rotated access token can carry a different plan than the one decoded
179
276
  // at account creation (e.g. a Plus->Pro upgrade). Mirrors createOpenAIAccount's
180
277
  // semantics: only overwrite when the new token actually decodes a plan claim —
@@ -66,8 +66,21 @@ export class OpenAIUsageRefresher extends UsageRefresher {
66
66
  },
67
67
  cancelledResult: () => ({ ok: false, reason: "network" }),
68
68
  applyResult: (account, result) => {
69
- if (result.ok)
69
+ if (result.ok) {
70
70
  applyCodexRateLimits(account, result.update, now());
71
+ // A successful authenticated poll clears only the advisory usage
72
+ // trouble state; permanent OAuth quarantine is owned by refresh.
73
+ if (account.authFailure === "transient")
74
+ account.authFailure = undefined;
75
+ }
76
+ else if (result.reason === "auth") {
77
+ // A usage endpoint 403 can be entitlement/scope related. Surface it
78
+ // as transient diagnostic state but never quarantine on this alone.
79
+ // Refresh owns permanent quarantine, so an advisory poll must not
80
+ // overwrite that stronger diagnosis.
81
+ if (account.authState !== "quarantined")
82
+ account.authFailure = "transient";
83
+ }
71
84
  },
72
85
  ...(options.now !== undefined ? { now: options.now } : {}),
73
86
  ...(options.startupStaggerMs !== undefined ? { startupStaggerMs: options.startupStaggerMs } : {}),
@@ -0,0 +1,30 @@
1
+ export async function consumeCodexResetCredit(account, redeemRequestId) {
2
+ // Contract: openai/codex 1715e55076737158ba61d43158ede504de6d4ce1,
3
+ // codex-rs/backend-client/src/client/rate_limit_resets.rs and types.rs.
4
+ // Never retry a spend with a fresh ID: a lost response may have consumed it.
5
+ try {
6
+ const response = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
7
+ method: "POST",
8
+ headers: {
9
+ authorization: `Bearer ${account.accessToken}`,
10
+ "content-type": "application/json",
11
+ },
12
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
13
+ signal: AbortSignal.timeout(10_000),
14
+ redirect: "error",
15
+ });
16
+ if (response.ok) {
17
+ const body = await response.json();
18
+ if (body && typeof body === "object" && "code" in body) {
19
+ const code = body.code;
20
+ if (code === "reset" || code === "nothing_to_reset" || code === "no_credit" || code === "already_redeemed") {
21
+ return { code };
22
+ }
23
+ }
24
+ }
25
+ }
26
+ catch {
27
+ // Do not relay upstream bodies, credentials, or network error details.
28
+ }
29
+ throw new Error("Reset outcome unknown; retry with the same redemption ID");
30
+ }
@@ -0,0 +1,64 @@
1
+ export function createUsageResetHandler(options) {
2
+ const inFlight = new WeakSet();
3
+ // One retained snapshot per account allows an uncertain retry to reconcile
4
+ // using the ORIGINAL quota evidence, not quota learned after the first spend.
5
+ const snapshots = new WeakMap();
6
+ return async (req, res) => {
7
+ const id = req.params.id;
8
+ const requestId = req.body?.redeemRequestId;
9
+ if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) {
10
+ res.status(400).json({ error: "redeemRequestId must be a UUID" });
11
+ return;
12
+ }
13
+ const account = options.findAccount(id);
14
+ if (!account) {
15
+ res.status(404).json({ error: "ChatGPT account not found" });
16
+ return;
17
+ }
18
+ if (inFlight.has(account)) {
19
+ res.status(409).json({ error: "Reset already running for this account" });
20
+ return;
21
+ }
22
+ inFlight.add(account);
23
+ try {
24
+ if (!await options.prepare(account)) {
25
+ res.status(503).json({ error: "Account credentials unavailable; reset not submitted" });
26
+ return;
27
+ }
28
+ if (options.findAccount(id) !== account) {
29
+ res.status(404).json({ error: "ChatGPT account changed; reset not submitted" });
30
+ return;
31
+ }
32
+ const previous = snapshots.get(account);
33
+ const replay = previous?.id === requestId;
34
+ const snapshot = replay ? previous : { id: requestId, reconcile: options.captureReset?.(account) };
35
+ snapshots.set(account, snapshot);
36
+ const result = await options.consume(account, requestId);
37
+ if (result.code === "already_redeemed" && !replay) {
38
+ // This UUID predates our ownership. Repeated historical replays must
39
+ // never promote its newly captured quota snapshot into trusted evidence.
40
+ snapshot.reconcile = undefined;
41
+ }
42
+ // Refresh even for no_credit/nothing_to_reset: the displayed snapshot may
43
+ // be stale. Never fabricate windows or decrement credits locally.
44
+ let usageRefreshed = false;
45
+ if (options.findAccount(id) === account) {
46
+ try {
47
+ const usage = await options.refresh(account);
48
+ usageRefreshed = usage.ok;
49
+ if (usage.ok && (result.code === "reset" || (result.code === "already_redeemed" && replay))) {
50
+ snapshot.reconcile?.(usage.update);
51
+ }
52
+ }
53
+ catch { /* retain confirmed redemption */ }
54
+ }
55
+ res.json({ reset: { ...result, usageRefreshed } });
56
+ }
57
+ catch {
58
+ res.status(502).json({ error: "Reset outcome unknown; retry with the same redemption ID" });
59
+ }
60
+ finally {
61
+ inFlight.delete(account);
62
+ }
63
+ };
64
+ }