@timo972/cc-router 0.12.0 → 0.12.1-rc.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.
- package/CHANGELOG.md +51 -13
- 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 +2 -0
- package/dist/providers/openai/token-refresher.js +120 -23
- package/dist/providers/openai/usage-fetch.js +14 -1
- 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 +62 -3
- package/dist/proxy/transport-diagnostics.js +32 -0
- package/dist/proxy/transport-timing.js +153 -0
- package/dist/ui/Dashboard.js +75 -22
- package/dist/ui/accountsApi.js +31 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,22 +8,53 @@ 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 `[R]` reloads account usage and due tokens without restarting the
|
|
14
|
+
router or dropping active requests and sticky sessions. It also refreshes
|
|
15
|
+
Grok snapshots, CLI routing state, and an already-loaded model list, with
|
|
16
|
+
progress and partial-failure summaries.
|
|
17
|
+
- `POST /cc-router/refresh` runs a shared Claude/OpenAI refresh pass, sweeps
|
|
18
|
+
expired cooldowns, and returns per-provider results. Concurrent reloads
|
|
19
|
+
join the same pass instead of starting duplicate work.
|
|
20
|
+
- OpenAI transport diagnostics include correlation IDs and refresh, response
|
|
21
|
+
header, and first-byte timings. Failure logs use bounded diagnostic fields
|
|
22
|
+
rather than raw upstream error messages.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
12
25
|
|
|
13
|
-
|
|
26
|
+
- The configured proxy request timeout now bounds Codex response headers and
|
|
27
|
+
upstream stream inactivity on both `/v1/responses` and OpenAI-routed
|
|
28
|
+
`/v1/messages`, including failover attempts. Progressing streams can outlive
|
|
29
|
+
that interval; time spent waiting for a slow client does not count as
|
|
30
|
+
upstream inactivity.
|
|
31
|
+
- Permanently rejected OpenAI refresh credentials are quarantined from
|
|
32
|
+
inference routing without changing the account's saved `enabled` setting.
|
|
33
|
+
Health reports expose the authentication state and failure category.
|
|
34
|
+
Successful refresh or credential replacement restores eligibility;
|
|
35
|
+
quarantine is runtime-only and does not survive a router restart.
|
|
14
36
|
|
|
15
37
|
### Fixed
|
|
16
38
|
|
|
17
|
-
- OpenAI
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
- OpenAI OAuth refresh sends the required public `client_id` and has a
|
|
40
|
+
15-second deadline covering response headers and JSON body parsing, so a
|
|
41
|
+
stalled refresh cannot retain its lock indefinitely.
|
|
42
|
+
- Refresh deduplication is scoped to the account object, preventing a deleted
|
|
43
|
+
and re-added account from sharing an old account's in-flight refresh.
|
|
44
|
+
- OpenAI usage authentication failures remain advisory rather than disabling
|
|
45
|
+
an account or overriding permanent credential quarantine. Background and
|
|
46
|
+
manual token refreshes continue to check quarantined accounts for recovery.
|
|
47
|
+
- OpenAI response relays honor downstream backpressure and cancel upstream
|
|
48
|
+
reads when the client disconnects. Failed partial streams close the
|
|
49
|
+
connection instead of appearing complete, and router-side stream failures
|
|
50
|
+
are no longer misclassified as client cancellations.
|
|
51
|
+
- Codex header timeouts return a safe HTTP 504 response, including after
|
|
52
|
+
account failover, while preserving the existing retry budget and shared
|
|
53
|
+
request correlation.
|
|
23
54
|
|
|
24
55
|
---
|
|
25
56
|
|
|
26
|
-
## [0.12.0
|
|
57
|
+
## [0.12.0] — 2026-09-11
|
|
27
58
|
|
|
28
59
|
### Added
|
|
29
60
|
|
|
@@ -48,11 +79,19 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
48
79
|
- Codex configuration rewrites now use TOML-aware parsing and validation rather
|
|
49
80
|
than line-oriented edits, including safe CLI start/stop toggles for the
|
|
50
81
|
managed block.
|
|
51
|
-
- The status dashboard
|
|
52
|
-
|
|
82
|
+
- The status dashboard has been redesigned with a more compact layout,
|
|
83
|
+
allowing even more accounts to be displayed at once. Compact provider
|
|
84
|
+
groups keep account headers visible in short terminals, and the dashboard
|
|
85
|
+
exposes the fleet-wide weekly-full count.
|
|
53
86
|
|
|
54
87
|
### Fixed
|
|
55
88
|
|
|
89
|
+
- OpenAI Responses function calls and their outputs remain top-level input
|
|
90
|
+
items across the Anthropic Messages bridge. JSON and SSE responses now
|
|
91
|
+
preserve call IDs, streamed or atomic arguments, refusal text, and
|
|
92
|
+
`tool_use` stop reasons. Invalid metadata, malformed arguments, and tool
|
|
93
|
+
streams that end before completion fail closed instead of fabricating a
|
|
94
|
+
successful assistant turn.
|
|
56
95
|
- `/v1/models` reports real context windows and includes bare `gpt-*` slugs used
|
|
57
96
|
by the Codex CLI.
|
|
58
97
|
- Codex routing accepts both dashed session-header spellings, logs route
|
|
@@ -670,8 +709,7 @@ cache-aware session routing and a round of security hardening.
|
|
|
670
709
|
- `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
|
|
671
710
|
affected APIs are not used here.
|
|
672
711
|
|
|
673
|
-
[0.12.0
|
|
674
|
-
[0.12.0-rc.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0-rc.0
|
|
712
|
+
[0.12.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0
|
|
675
713
|
[0.11.0]: https://github.com/Timo972/cc-router/releases/tag/v0.11.0
|
|
676
714
|
[0.9.0]: https://github.com/Timo972/cc-router/releases/tag/v0.9.0
|
|
677
715
|
[0.8.3]: https://github.com/Timo972/cc-router/releases/tag/v0.8.3
|
|
@@ -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) {
|
|
@@ -220,6 +220,8 @@ export class OpenAITokenPool {
|
|
|
220
220
|
return this.accounts;
|
|
221
221
|
}
|
|
222
222
|
hardBlock(account, context) {
|
|
223
|
+
if (account.authState === "quarantined")
|
|
224
|
+
return { reason: "unavailable" };
|
|
223
225
|
if (!account.enabled || !account.healthy)
|
|
224
226
|
return { reason: "unavailable" };
|
|
225
227
|
const nowMs = this.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 } : {}),
|
|
@@ -12,6 +12,7 @@ import { stats, applyCodexUsage } from "./stats.js";
|
|
|
12
12
|
import { extractCodexSessionKey } from "./openai-routing.js";
|
|
13
13
|
import { sendAnthropicNoEligibleResponse, detectAnthropicClientSource } from "./anthropic-routing.js";
|
|
14
14
|
import { mirrorUpstreamHeaders, runOpenAIIngress, } from "./openai-ingress.js";
|
|
15
|
+
import { waitForWritable } from "./transport-timing.js";
|
|
15
16
|
const MESSAGES_ENVELOPE = {
|
|
16
17
|
wrap: (type, message) => ({ type: "error", error: { type, message } }),
|
|
17
18
|
sendNoEligible: (error, res, nowMs) => sendAnthropicNoEligibleResponse(error, res, nowMs),
|
|
@@ -62,7 +63,7 @@ function extractUpstreamErrorMessage(bodyText, status) {
|
|
|
62
63
|
* the Codex backend signals in-stream failures as a `response.failed`/`error`
|
|
63
64
|
* SSE event on an HTTP 200, and those must not be reported as success.
|
|
64
65
|
*/
|
|
65
|
-
async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report) {
|
|
66
|
+
async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report, now) {
|
|
66
67
|
const onUsage = (usage) => applyCodexUsage(entry, usage);
|
|
67
68
|
// A non-OK response is never a Responses payload, whatever its content-type
|
|
68
69
|
// — parsing it as an event stream or a completed response would translate a
|
|
@@ -97,7 +98,7 @@ async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, repo
|
|
|
97
98
|
// Headers are already flushed by the time a mid-stream failure surfaces,
|
|
98
99
|
// so the client keeps the partial stream; reporting 502 here keeps the
|
|
99
100
|
// activity log and error totals honest about what happened.
|
|
100
|
-
const failure = await sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report);
|
|
101
|
+
const failure = await sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report, now);
|
|
101
102
|
return { statusCode: failure === undefined ? upstream.status : 502 };
|
|
102
103
|
}
|
|
103
104
|
const collected = await collectOpenAIStreamAsAnthropicMessage(upstream, report);
|
|
@@ -303,7 +304,7 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
|
|
|
303
304
|
};
|
|
304
305
|
}
|
|
305
306
|
/** Returns the upstream failure message when the stream ended in one. */
|
|
306
|
-
async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
307
|
+
async function sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report, now) {
|
|
307
308
|
res.status(upstream.status);
|
|
308
309
|
res.setHeader("content-type", "text/event-stream");
|
|
309
310
|
res.setHeader("cache-control", "no-cache");
|
|
@@ -327,6 +328,7 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
327
328
|
let totals;
|
|
328
329
|
let failure;
|
|
329
330
|
let completed = false;
|
|
331
|
+
let cleanEof = false;
|
|
330
332
|
const pendingToolCalls = new Set();
|
|
331
333
|
const writeProtocolError = () => {
|
|
332
334
|
res.write(encodeSseEvent({
|
|
@@ -362,13 +364,23 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
362
364
|
pendingToolCalls.delete(typed.output_index ?? 0);
|
|
363
365
|
}
|
|
364
366
|
};
|
|
365
|
-
const relayEvents = (events) => {
|
|
367
|
+
const relayEvents = async (events) => {
|
|
366
368
|
for (const event of events) {
|
|
367
369
|
inspect(event);
|
|
368
370
|
for (const mapped of normalizer.convert(event)) {
|
|
369
|
-
res.write(encodeSseEvent(mapped))
|
|
371
|
+
if (!res.write(encodeSseEvent(mapped))) {
|
|
372
|
+
// Do not consume another translated event or upstream chunk while
|
|
373
|
+
// Node is buffering for a slow client. A close/error while waiting
|
|
374
|
+
// ends the relay and explicitly releases the upstream reader.
|
|
375
|
+
const drained = await waitForWritable(res);
|
|
376
|
+
if (!drained) {
|
|
377
|
+
await reader.cancel().catch(() => { });
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
370
381
|
}
|
|
371
382
|
}
|
|
383
|
+
return true;
|
|
372
384
|
};
|
|
373
385
|
try {
|
|
374
386
|
while (true) {
|
|
@@ -380,17 +392,23 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
380
392
|
const { value, done } = await reader.read();
|
|
381
393
|
if (done)
|
|
382
394
|
break;
|
|
395
|
+
if (entry.firstByteDurationMs === undefined)
|
|
396
|
+
entry.firstByteDurationMs = now() - entry.ts;
|
|
383
397
|
// Tolerant: one malformed frame must not abort the relay (which would
|
|
384
398
|
// silently truncate the client's stream) nor discard the valid events
|
|
385
399
|
// decoded from the same chunk.
|
|
386
400
|
const parsed = parseSseLines(remainder + decoder.decode(value, { stream: true }), { tolerant: true });
|
|
387
401
|
remainder = parsed.remainder;
|
|
388
|
-
relayEvents(parsed.events)
|
|
402
|
+
if (!await relayEvents(parsed.events))
|
|
403
|
+
return undefined;
|
|
389
404
|
}
|
|
390
405
|
const tail = decoder.decode();
|
|
391
406
|
if (tail || remainder) {
|
|
392
|
-
relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events)
|
|
407
|
+
if (!await relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events)) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
393
410
|
}
|
|
411
|
+
cleanEof = true;
|
|
394
412
|
if (!completed && pendingToolCalls.size > 0 && failure === undefined) {
|
|
395
413
|
failure = "OpenAI function call ended before completion";
|
|
396
414
|
report.upstreamReportedFailure = true;
|
|
@@ -403,9 +421,12 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
|
|
|
403
421
|
failure = error.message;
|
|
404
422
|
report.upstreamReportedFailure = true;
|
|
405
423
|
writeProtocolError();
|
|
424
|
+
cleanEof = true;
|
|
425
|
+
await reader.cancel().catch(() => { });
|
|
406
426
|
}
|
|
407
427
|
finally {
|
|
408
|
-
res.
|
|
428
|
+
if (cleanEof && !res.destroyed && !res.writableEnded)
|
|
429
|
+
res.end();
|
|
409
430
|
onUsage?.(totals);
|
|
410
431
|
}
|
|
411
432
|
// Mirrors collectOpenAIStreamAsAnthropicMessage: tolerant parsing skips a
|
|
@@ -466,6 +487,7 @@ export function mountMessagesCrossProviderRoute(app, opts) {
|
|
|
466
487
|
now,
|
|
467
488
|
envelope: MESSAGES_ENVELOPE,
|
|
468
489
|
onUpstreamAuthFailure: opts.onUpstreamAuthFailure,
|
|
490
|
+
timeoutMs: opts.timeoutMs,
|
|
469
491
|
...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
|
|
470
492
|
...(opts.sameAccountRetryDelayMs !== undefined
|
|
471
493
|
? { sameAccountRetryDelayMs: opts.sameAccountRetryDelayMs }
|
|
@@ -473,7 +495,7 @@ export function mountMessagesCrossProviderRoute(app, opts) {
|
|
|
473
495
|
...(opts.retryRefreshTimeoutMs !== undefined
|
|
474
496
|
? { retryRefreshTimeoutMs: opts.retryRefreshTimeoutMs }
|
|
475
497
|
: {}),
|
|
476
|
-
relay: (upstream, res, entry, report) => sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report),
|
|
498
|
+
relay: (upstream, res, entry, report) => sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report, now),
|
|
477
499
|
});
|
|
478
500
|
});
|
|
479
501
|
}
|