@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 +56 -13
- package/README.md +19 -0
- package/dist/providers/openai/account-state.js +1 -0
- package/dist/providers/openai/codex-transport.js +26 -11
- package/dist/providers/openai/device-oauth.js +1 -1
- package/dist/providers/openai/token-pool.js +37 -2
- package/dist/providers/openai/token-refresher.js +120 -23
- package/dist/providers/openai/usage-fetch.js +14 -1
- package/dist/providers/openai/usage-reset.js +30 -0
- package/dist/proxy/account-usage-reset.js +64 -0
- package/dist/proxy/messages-cross-route.js +31 -9
- package/dist/proxy/openai-ingress.js +75 -22
- package/dist/proxy/pool-refresh.js +80 -0
- package/dist/proxy/responses-server.js +23 -4
- package/dist/proxy/server.js +71 -3
- package/dist/proxy/transport-diagnostics.js +32 -0
- package/dist/proxy/transport-timing.js +153 -0
- package/dist/ui/Dashboard.js +142 -23
- package/dist/ui/accountsApi.js +47 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
52
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
71
|
+
const existing = refreshLocks.get(account);
|
|
67
72
|
if (existing)
|
|
68
73
|
return existing;
|
|
69
74
|
const promise = doRefresh(account);
|
|
70
|
-
refreshLocks.set(account
|
|
75
|
+
refreshLocks.set(account, promise);
|
|
71
76
|
try {
|
|
72
77
|
return await promise;
|
|
73
78
|
}
|
|
74
79
|
finally {
|
|
75
|
-
refreshLocks.delete(account
|
|
80
|
+
refreshLocks.delete(account);
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
export async function prepareOpenAIAccountForRequest(account, allAccounts, saveAccounts) {
|
|
79
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|