ldrouter 1.17.3 → 1.17.5
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 +22 -0
- package/README.md +2 -0
- package/dist/server/db/repositories/qoder-accounts.js +14 -4
- package/dist/server/gateway/runner.js +128 -2
- package/dist/server/providers/codex-autostart.js +21 -0
- package/dist/server/providers/codex-refresh.js +3 -1
- package/dist/server/providers/qoder/credentials.js +5 -1
- package/dist/server/providers/qoder/credits.js +29 -0
- package/dist/server/routes/admin/codex.js +6 -0
- package/dist/server/routes/admin/qoder.js +13 -16
- package/dist/server/routing/combo.js +7 -0
- package/dist/server/upstream/client.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.17.5] - 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **An account whose session was revoked kept being selected instead of leaving the Codex pool.** Auth0 answers `refresh_token_invalidated` ("Your session has ended. Please log in again.") for an account whose login was revoked; the account cannot serve anything again until it is re-imported. The credential layer surfaced that as a bare `oauth_refresh_failed`, which the router classified `unknown` — and `unknown` is not a routing decision, so `shouldFallback` answered false, the retry never advanced to a sibling account, and the request answered `502`. Live production data confirmed the shape: two accounts holding invalidated refresh tokens sat at the head of the pool, so every request landed on one of them, and 226 of 241 failed requests carried `attempts_count=1` while healthy accounts sat idle behind them. Credential failure is now its own failure class, checked before the error-type switch, and it always advances to the next candidate — with or without a combo plan, since a direct `codex1/...` model has no fallback triggers to consult.
|
|
12
|
+
- **The account with the dead credential is now disabled, not merely degraded.** Marking down *and* disabling (`enabled=0`) is what actually stops the churn, mirroring quota exhaustion: every selection path — `expandCodexAccountCandidates`, `getCodexAccountForProvider` — filters on `enabled`. Deliberately kept out of `isUpstreamHealthFailure`, because one re-imported account must not open the provider circuit breaker for its healthy siblings.
|
|
13
|
+
- **`codexCredentialError` discarded the raw error code.** Its wrapping message is deliberately generic, so without the cause the routing layer could not distinguish a dead account from any other `authentication_error`. The raw error is now carried as `cause`.
|
|
14
|
+
|
|
15
|
+
## [1.17.4] - 2026-09-18
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **An account that ran out of quota kept being retried instead of leaving the pool.** A quota refusal was classified `unknown`, which is not a routing decision: `isUpstreamHealthFailure` excludes it and `shouldFallback` answers false for it, so the pool selected the same spent account on every request and answered "usage limited". Live production data confirmed the shape — 49 quota refusals (`Codex upstream HTTP 429: The usage limit has been reached`, `Qoder account is out of quota`) recorded over 14 days, every one of them `failure_reason=unknown`, with the same `codex_account_id` / `qoder_account_id` retried each time. Quota is now its own failure class, checked before the error-type switch (a 429 reaches the classifier as `upstream_rate_limit`, which previously returned `http_status` before the quota test could run).
|
|
20
|
+
- **The exhausted account is now disabled, not just marked degraded.** Disabling (`enabled=0`) is the write that actually stops the churn: every selection path — `expandCodexAccountCandidates`, `expandQoderAccountCandidates`, `getCodexAccountForProvider`, `findEligibleQoderAccount` — filters on it, whereas `health_state` alone was overwritten to `healthy` by the next successful request. A Qoder billing block now disables the account too, instead of only degrading it.
|
|
21
|
+
- **A direct model never advanced to the next account.** The retry was gated on `comboPlan ? ... : false`, so the reported case — a direct `qoder/...` model, logged 33 times in production — never retried at all. A quota failure now always advances to the next candidate, combo or not; that is safe precisely because the exhausted account was just disabled, so the retry cannot land on it again.
|
|
22
|
+
- **The upstream 429 body was discarded, hiding the reason.** Quota exhaustion arrives as a plain 429 on OpenAI-compatible providers, with the wording ("you exceeded your current quota") only in the body — which `upstreamHttpError` dropped, leaving an account unclassifiable. The redacted body excerpt is now carried on a 429 too. A 429 without quota wording is still treated as a transient throttle: it retries the same account and does not disable it.
|
|
23
|
+
- **Re-enabling an account in the admin UI did not put it back in the pool.** The enable toggle flipped `enabled` but left `health_state=down`, which every selection filter excludes, so the account stayed unroutable while the panel showed it as enabled. Both the Codex and Qoder routes now clear the health verdict when re-enabling.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Usage and Credits are refreshed as the pool is used.** Both the Codex usage snapshot and the Qoder credits snapshot are re-read in the background after a successful request, throttled per account so a burst of traffic costs at most one extra upstream call, and never awaited — the response path is unaffected.
|
|
28
|
+
|
|
7
29
|
## [1.17.3] - 2026-09-17
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -65,6 +65,8 @@ ZIP upload and automatic Codex CLI config-file generation/mutation are not inclu
|
|
|
65
65
|
|
|
66
66
|
The account panel shows the 5-hour and weekly quota windows with reset countdowns, per-account and bulk usage refresh, weekly reset credits, an opt-in 5-hour window auto-start, and a **Test** control that probes the upstream account without exposing tokens. Routing order is set by dragging rows; the saved order is the fallback order the router uses. **Delete** is permanent and erases the stored encrypted credentials — past request logs are kept but lose the account reference.
|
|
67
67
|
|
|
68
|
+
**A quota-exhausted account leaves the pool on its own.** When an account refuses for quota reasons — Codex `429 The usage limit has been reached`, or Qoder `out of credits` — it is marked `down` *and disabled*, so the very next request picks a different account in the pool rather than retrying the spent one. This applies to direct `codex/...` and `qoder/...` models too, not only combos. Re-enable the account in the panel once its window resets. Both Codex usage and Qoder credits are also refreshed in the background after a successful request (throttled, and never on the response path), so the panel figures track real usage without pressing **Refresh**.
|
|
69
|
+
|
|
68
70
|
### Qoder setup
|
|
69
71
|
|
|
70
72
|
Qoder providers use a personal access token (PAT) instead of an API key. Create one at `https://qoder.com/account/integrations`; it starts with `pt-`.
|
|
@@ -155,11 +155,16 @@ export function persistQoderJobToken(id, update) {
|
|
|
155
155
|
health_state='healthy', last_error=NULL, updated_at=? WHERE id=?`)
|
|
156
156
|
.run(jobToken.ciphertext, jobToken.nonce, jobToken.version, update.expiresAt, update.catalogJson ?? null, update.catalogFetchedAt ?? null, nextUpdatedAt(id), id);
|
|
157
157
|
}
|
|
158
|
-
|
|
158
|
+
/** `enabled` mirrors the Codex setter: pass `false` to take an exhausted account out of the pool. */
|
|
159
|
+
export function setQoderAccountHealth(id, healthState, lastError = null, enabled) {
|
|
159
160
|
const failures = healthState === 'healthy' ? 0 : null;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
const fields = enabled === undefined
|
|
162
|
+
? 'health_state=?, last_error=?, consecutive_failures=COALESCE(?, consecutive_failures), updated_at=?'
|
|
163
|
+
: 'health_state=?, last_error=?, consecutive_failures=COALESCE(?, consecutive_failures), enabled=?, updated_at=?';
|
|
164
|
+
const values = enabled === undefined
|
|
165
|
+
? [healthState, lastError, failures, nextUpdatedAt(id), id]
|
|
166
|
+
: [healthState, lastError, failures, enabled ? 1 : 0, nextUpdatedAt(id), id];
|
|
167
|
+
getRawDb().prepare(`UPDATE qoder_accounts SET ${fields} WHERE id=?`).run(...values);
|
|
163
168
|
}
|
|
164
169
|
export function readQoderCatalog(id) {
|
|
165
170
|
const row = getRawDb()
|
|
@@ -172,6 +177,11 @@ export function saveQoderCatalog(id, catalogJson, fetchedAt) {
|
|
|
172
177
|
.prepare('UPDATE qoder_accounts SET catalog_json=?, catalog_fetched_at=?, updated_at=? WHERE id=?')
|
|
173
178
|
.run(catalogJson, fetchedAt ?? new Date().toISOString(), nextUpdatedAt(id), id);
|
|
174
179
|
}
|
|
180
|
+
/** When the Credits snapshot was last written; null when it never has been. */
|
|
181
|
+
export function readQoderCreditsUpdatedAt(id) {
|
|
182
|
+
const row = getRawDb().prepare('SELECT credits_updated_at AS at FROM qoder_accounts WHERE id=?').get(id);
|
|
183
|
+
return row?.at ?? null;
|
|
184
|
+
}
|
|
175
185
|
/** Credits are observability, not routing state: a failed fetch is recorded without touching health. */
|
|
176
186
|
export function saveQoderCredits(id, creditsJson, error, fetchedAt) {
|
|
177
187
|
getRawDb()
|
|
@@ -8,7 +8,9 @@ import { loadCombo, selectCandidates, orderCandidates, shouldFallback, expandCod
|
|
|
8
8
|
import { listCodexAccountsForProvider, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
|
|
9
9
|
import { listQoderAccountsForProvider, setQoderAccountHealth } from '../db/repositories/qoder-accounts.js';
|
|
10
10
|
import { qoderAttemptFailure, qoderCredentialsFor } from '../providers/qoder/credentials.js';
|
|
11
|
+
import { refreshQoderCreditsIfStale } from '../providers/qoder/credits.js';
|
|
11
12
|
import { callQoderNonStreaming, callQoderStreaming } from '../providers/qoder/client.js';
|
|
13
|
+
import { refreshCodexUsageIfStale } from '../providers/codex-autostart.js';
|
|
12
14
|
import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
|
|
13
15
|
import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
|
|
14
16
|
import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
|
|
@@ -279,6 +281,13 @@ export class GatewayRunner {
|
|
|
279
281
|
setCodexAccountHealth(candidate.codexAccountId, 'healthy');
|
|
280
282
|
if (candidate.qoderAccountId)
|
|
281
283
|
setQoderAccountHealth(candidate.qoderAccountId, 'healthy');
|
|
284
|
+
// Refresh the quota snapshot in the background so the dashboard reflects real usage.
|
|
285
|
+
// Never awaited: it is a second upstream call, and the response is already complete.
|
|
286
|
+
// Throttled inside each helper, so a burst of requests costs at most one refresh.
|
|
287
|
+
if (candidate.codexAccountId)
|
|
288
|
+
void refreshCodexUsageIfStale(candidate.codexAccountId).catch(() => { });
|
|
289
|
+
if (candidate.qoderAccountId)
|
|
290
|
+
void refreshQoderCreditsIfStale(candidate.qoderAccountId).catch(() => { });
|
|
282
291
|
getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
|
|
283
292
|
attempts.push(attempt);
|
|
284
293
|
sentToClient = out.streamStarted ?? false;
|
|
@@ -286,15 +295,20 @@ export class GatewayRunner {
|
|
|
286
295
|
}
|
|
287
296
|
catch (e) {
|
|
288
297
|
const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message, { cause: e });
|
|
298
|
+
const quotaFailure = isQuotaFailure(err);
|
|
299
|
+
const credentialFailure = isCredentialFailure(err);
|
|
289
300
|
debugUpstream(ctx.requestId, 'ATTEMPT ERROR', [
|
|
290
301
|
`attempt=${i + 1}`,
|
|
291
302
|
`provider=${provider.name}`,
|
|
292
303
|
`model=${candidate.publicModelId}`,
|
|
293
304
|
`type=${err.type}`,
|
|
294
305
|
`status=${err.status}`,
|
|
295
|
-
`willFallback=${
|
|
306
|
+
`willFallback=${shouldRetryAttempt(comboPlan, err)}`,
|
|
296
307
|
]);
|
|
297
|
-
|
|
308
|
+
// Quota failures always advance (see shouldRetryAttempt): the next candidate for a pool
|
|
309
|
+
// provider is the next account, and the exhausted one was just disabled so the retry cannot
|
|
310
|
+
// land on it again.
|
|
311
|
+
const shouldRetry = shouldRetryAttempt(comboPlan, err);
|
|
298
312
|
attempt.statusCode = err.status;
|
|
299
313
|
attempt.success = false;
|
|
300
314
|
attempt.latencyMs = Date.now() - attemptStart;
|
|
@@ -312,6 +326,15 @@ export class GatewayRunner {
|
|
|
312
326
|
}
|
|
313
327
|
attempts.push(attempt);
|
|
314
328
|
lastError = err;
|
|
329
|
+
// An exhausted account leaves the pool outright: without this the combo retried the same
|
|
330
|
+
// dead account on every request for hours (verified live) and answered "usage limited".
|
|
331
|
+
if (quotaFailure)
|
|
332
|
+
markQuotaExhausted(candidate, err.message);
|
|
333
|
+
// A dead refresh token also leaves the pool outright: the account cannot serve anything
|
|
334
|
+
// again until it is re-imported, so a retry may only be spent on a sibling account. Without
|
|
335
|
+
// this the retry landed on the same dead account and the request answered 502.
|
|
336
|
+
if (credentialFailure)
|
|
337
|
+
markCredentialsDead(candidate, err.message);
|
|
315
338
|
if (isUpstreamHealthFailure(err)) {
|
|
316
339
|
recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
|
|
317
340
|
if (candidate.codexAccountId)
|
|
@@ -971,7 +994,110 @@ export class GatewayRunner {
|
|
|
971
994
|
export function isUpstreamHealthFailure(err) {
|
|
972
995
|
return ['connection_error', 'connect_timeout', 'first_token_timeout', 'http_status', 'upstream_rate_limit'].includes(classifyFailure(err));
|
|
973
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* An exhausted quota is a durable, per-account verdict — the account cannot serve anything again
|
|
999
|
+
* until its window resets, so it must leave the pool instead of being retried.
|
|
1000
|
+
*
|
|
1001
|
+
* Deliberately NOT part of `isUpstreamHealthFailure`: that path opens the provider's circuit
|
|
1002
|
+
* breaker, which here would take down every sibling account of a healthy pool because one member
|
|
1003
|
+
* ran dry. The exhaustion is applied account-by-account by `markQuotaExhausted` instead.
|
|
1004
|
+
*
|
|
1005
|
+
* Detection needs both signals because the two upstreams refuse differently, and neither reports a
|
|
1006
|
+
* status the router can trust alone:
|
|
1007
|
+
* - Codex answers 429 but the credential layer re-wraps it (status 502, cause 429), and a plain
|
|
1008
|
+
* rate limit is also a 429 — the "usage limit"/"quota" wording is what distinguishes "come back
|
|
1009
|
+
* in an hour" from "this account is spent".
|
|
1010
|
+
* - Qoder refuses with a billing envelope carrying status 403/code 112, surfaced as the
|
|
1011
|
+
* `qoder_billing_block` code.
|
|
1012
|
+
* Wording is only consulted when the status is quota-shaped, so a 400 that merely mentions the word
|
|
1013
|
+
* "quota" cannot disable a working account.
|
|
1014
|
+
*/
|
|
1015
|
+
export function isQuotaFailure(err) {
|
|
1016
|
+
if (err.code === 'qoder_billing_block')
|
|
1017
|
+
return true;
|
|
1018
|
+
const cause = err.cause;
|
|
1019
|
+
const upstreamStatus = typeof cause?.status === 'number' ? cause.status : err.status;
|
|
1020
|
+
if (upstreamStatus !== 429 && upstreamStatus !== 403)
|
|
1021
|
+
return false;
|
|
1022
|
+
return /usage[_ -]?limit|quota|out of credits|insufficient[_ -]?(credit|balance)|exceeded your current quota|billing/i.test(err.message);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Take an exhausted account out of the pool: mark it down and disable it.
|
|
1026
|
+
*
|
|
1027
|
+
* `enabled=0` (not just `health_state='down'`) is what the operator asked for and what actually
|
|
1028
|
+
* stops the churn: every selection path — `expandCodexAccountCandidates`,
|
|
1029
|
+
* `expandQoderAccountCandidates`, `getCodexAccountForProvider`, `findEligibleQoderAccount` — filters
|
|
1030
|
+
* on `enabled`, so disabling is the one write that makes the next request pick a different account.
|
|
1031
|
+
* The account stays visible in the admin UI, where re-enabling it after the window resets is a
|
|
1032
|
+
* one-click action.
|
|
1033
|
+
*/
|
|
1034
|
+
export function markQuotaExhausted(candidate, message) {
|
|
1035
|
+
const reason = redactString(message);
|
|
1036
|
+
if (candidate.codexAccountId)
|
|
1037
|
+
setCodexAccountHealth(candidate.codexAccountId, 'down', reason, false);
|
|
1038
|
+
if (candidate.qoderAccountId)
|
|
1039
|
+
setQoderAccountHealth(candidate.qoderAccountId, 'down', reason, false);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* A credential failure is a durable, per-account verdict — the same class as quota exhaustion.
|
|
1043
|
+
*
|
|
1044
|
+
* `withCodexCredentials` throws these as bare codes when the account's refresh token is dead
|
|
1045
|
+
* (Auth0 answers `refresh_token_invalidated`, "Your session has ended. Please log in again.").
|
|
1046
|
+
* The account cannot serve anything again until it is re-imported, so retrying it is pure waste
|
|
1047
|
+
* and leaving it in the pool answers 502 to every client.
|
|
1048
|
+
*
|
|
1049
|
+
* Live evidence (production, 2026-09-19): two accounts holding invalidated refresh tokens sat at
|
|
1050
|
+
* the head of the pool, so every request landed on one of them — `oauth_refresh_failed`,
|
|
1051
|
+
* classified `unknown`, `attempts_count=1`, 502 straight to the client (226 of 241 failed
|
|
1052
|
+
* requests had exactly one attempt while healthy accounts sat idle behind them).
|
|
1053
|
+
*
|
|
1054
|
+
* Deliberately NOT part of `isUpstreamHealthFailure`: one re-imported account must not open the
|
|
1055
|
+
* provider circuit breaker for its healthy siblings. The verdict is applied per account by
|
|
1056
|
+
* `markCredentialsDead` instead.
|
|
1057
|
+
*
|
|
1058
|
+
* Both spellings are checked because the code travels either raw (from `withCodexCredentials`
|
|
1059
|
+
* inside the gateway) or wrapped by `codexCredentialError`, which now keeps it as `cause`.
|
|
1060
|
+
*/
|
|
1061
|
+
export function isCredentialFailure(err) {
|
|
1062
|
+
const codes = /^(oauth_refresh_failed|invalid_refresh_response|credential_unavailable|account_not_found)$/;
|
|
1063
|
+
const cause = err.cause;
|
|
1064
|
+
return codes.test(err.message) || (typeof cause?.message === 'string' && codes.test(cause.message));
|
|
1065
|
+
}
|
|
1066
|
+
/** Takes an account with a dead refresh token out of the pool. `enabled=0` is what stops the churn. */
|
|
1067
|
+
export function markCredentialsDead(candidate, message) {
|
|
1068
|
+
const reason = redactString(message);
|
|
1069
|
+
if (candidate.codexAccountId)
|
|
1070
|
+
setCodexAccountHealth(candidate.codexAccountId, 'down', reason, false);
|
|
1071
|
+
if (candidate.qoderAccountId)
|
|
1072
|
+
setQoderAccountHealth(candidate.qoderAccountId, 'down', reason, false);
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Whether a failed attempt should advance to the next candidate.
|
|
1076
|
+
*
|
|
1077
|
+
* A quota failure always advances, with or without a combo plan. For a pool provider the next
|
|
1078
|
+
* candidate is the next *account*, so requiring a combo plan left a direct `codex/…` or `qoder/…`
|
|
1079
|
+
* request pinned to the exhausted account — the reported bug ("does not switch to a new account,
|
|
1080
|
+
* just answers usage limited"). Safe because the exhausted account was just disabled: the retry
|
|
1081
|
+
* cannot land on it again. Everything else keeps the combo's configured triggers, where no combo
|
|
1082
|
+
* plan means no fallback.
|
|
1083
|
+
*/
|
|
1084
|
+
export function shouldRetryAttempt(comboPlan, err) {
|
|
1085
|
+
if (isCredentialFailure(err))
|
|
1086
|
+
return true;
|
|
1087
|
+
if (isQuotaFailure(err))
|
|
1088
|
+
return true;
|
|
1089
|
+
return comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false;
|
|
1090
|
+
}
|
|
974
1091
|
export function classifyFailure(err) {
|
|
1092
|
+
// Checked before the type switch for the same reason as quota: a dead account is not a routing
|
|
1093
|
+
// decision the combo's triggers were meant to gate, and `unknown` is not a routing decision at all.
|
|
1094
|
+
if (isCredentialFailure(err))
|
|
1095
|
+
return 'credential';
|
|
1096
|
+
// Checked first, before the type switch: a quota refusal arrives as `upstream_rate_limit` (a
|
|
1097
|
+
// 429) or `upstream_error` (Codex rewraps it as a 502 with a 429 cause; Qoder uses code 112) and
|
|
1098
|
+
// must not fall through to `http_status`/`unknown`, neither of which is a routing decision.
|
|
1099
|
+
if (isQuotaFailure(err))
|
|
1100
|
+
return 'quota';
|
|
975
1101
|
switch (err.type) {
|
|
976
1102
|
case 'timeout_error':
|
|
977
1103
|
return err.message.includes('first token') ? 'first_token_timeout' : 'connect_timeout';
|
|
@@ -16,6 +16,27 @@ function providerFor(providerId) {
|
|
|
16
16
|
function accountFor(accountId) {
|
|
17
17
|
return getRawDb().prepare('SELECT id,chatgpt_account_id FROM codex_accounts WHERE id=?').get(accountId) ?? null;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Keep the quota snapshot honest while the account is in use.
|
|
21
|
+
*
|
|
22
|
+
* The usage API is a second upstream call, so this is throttled rather than run per request: a
|
|
23
|
+
* snapshot younger than the interval is left alone. Before this, usage only refreshed when an admin
|
|
24
|
+
* pressed the button or on the 10-minute autostart tick for opted-in accounts, so a dashboard could
|
|
25
|
+
* show hours-old consumption for an account actively serving traffic.
|
|
26
|
+
*/
|
|
27
|
+
export const CODEX_USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
28
|
+
export async function refreshCodexUsageIfStale(accountId, now = new Date()) {
|
|
29
|
+
const row = getRawDb().prepare('SELECT provider_id AS providerId,chatgpt_account_id AS chatgptAccountId,codex_usage_updated_at AS at FROM codex_accounts WHERE id=?').get(accountId);
|
|
30
|
+
if (!row)
|
|
31
|
+
return;
|
|
32
|
+
const provider = providerFor(row.providerId);
|
|
33
|
+
if (!provider)
|
|
34
|
+
return;
|
|
35
|
+
const last = row.at ? Date.parse(row.at) : NaN;
|
|
36
|
+
if (Number.isFinite(last) && now.getTime() - last < CODEX_USAGE_REFRESH_INTERVAL_MS)
|
|
37
|
+
return;
|
|
38
|
+
await refreshStoredCodexUsage(accountId, provider, { id: accountId, chatgpt_account_id: row.chatgptAccountId });
|
|
39
|
+
}
|
|
19
40
|
/** Reads fresh usage; stores the snapshot or a sanitized reason on failure. */
|
|
20
41
|
export async function refreshStoredCodexUsage(accountId, provider, account) {
|
|
21
42
|
try {
|
|
@@ -11,7 +11,9 @@ export function codexCredentialError(error) {
|
|
|
11
11
|
if (code === 'account_not_found')
|
|
12
12
|
return new GatewayError('invalid_request_error', 'Codex account not found', { status: 404 });
|
|
13
13
|
if (code === 'oauth_refresh_failed' || code === 'invalid_refresh_response' || code === 'credential_unavailable') {
|
|
14
|
-
|
|
14
|
+
// `cause` carries the raw credential code: the wrapping message is deliberately generic, so
|
|
15
|
+
// without it the routing layer cannot tell a dead account from any other authentication_error.
|
|
16
|
+
return new GatewayError('authentication_error', 'Codex credentials could not be refreshed — re-import the account', { status: 401, cause: error });
|
|
15
17
|
}
|
|
16
18
|
return error;
|
|
17
19
|
}
|
|
@@ -106,7 +106,11 @@ export async function qoderAttemptFailure(accountRecordId, error) {
|
|
|
106
106
|
// otherwise be classified as a rejected credential: that mislabels the cause, force-re-exchanges
|
|
107
107
|
// a perfectly good PAT on every request, and hides the real answer (the account is out of quota).
|
|
108
108
|
if (billing) {
|
|
109
|
-
|
|
109
|
+
// Disabled, not merely degraded: a depleted account cannot serve paid models until its Credits
|
|
110
|
+
// refill, and leaving it eligible made the pool retry it on every request while the client saw
|
|
111
|
+
// "usage limited". The operator re-enables it (or adds Credits) from the admin UI; Credits
|
|
112
|
+
// refresh keeps the snapshot honest in the meantime.
|
|
113
|
+
setQoderAccountHealth(accountRecordId, 'down', 'out of Credits — re-enable after topping up', false);
|
|
110
114
|
throw new GatewayError('upstream_error', 'Qoder account is out of quota', { status: 502, code: 'qoder_billing_block' });
|
|
111
115
|
}
|
|
112
116
|
if (status === 401 || status === 403) {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// consumption. `is_free` on a catalog entry is the separate signal that a model is covered
|
|
4
4
|
// by a promotion (e.g. Qwen3.8-Max) and therefore does not draw on Credits at all.
|
|
5
5
|
import { QODER_CREDITS_URL } from './constants.js';
|
|
6
|
+
import { qoderCredentialsFor } from './credentials.js';
|
|
7
|
+
import { readQoderCreditsUpdatedAt, saveQoderCredits } from '../../db/repositories/qoder-accounts.js';
|
|
6
8
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
7
9
|
const numberOr = (value, fallback) => {
|
|
8
10
|
const parsed = Number(value);
|
|
@@ -70,3 +72,30 @@ export async function fetchQoderCredits(creds, freeModels, deps = {}) {
|
|
|
70
72
|
clearTimeout(timer);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Fetch and persist one account's Credits snapshot. Free models come from the account's cached
|
|
77
|
+
* catalog because `is_free` is a catalog property, not a quota one — it is why an account at zero
|
|
78
|
+
* Credits can still serve a promotional model.
|
|
79
|
+
*
|
|
80
|
+
* Lives here rather than in the admin route so the gateway can reuse it: Credits are the only
|
|
81
|
+
* upstream truth about consumption (Qoder's chat stream reports no usage at all), so the snapshot
|
|
82
|
+
* has to be kept fresh as accounts are actually used, not only when an admin opens the page.
|
|
83
|
+
*/
|
|
84
|
+
export async function refreshStoredQoderCredits(accountRecordId) {
|
|
85
|
+
const { config } = await qoderCredentialsFor(accountRecordId);
|
|
86
|
+
const freeModels = config.catalog ? [...config.catalog.entries.values()].filter((entry) => entry.isFree).map((entry) => entry.key) : [];
|
|
87
|
+
const credits = await fetchQoderCredits({ jobToken: config.jobToken }, freeModels, { timeoutMs: FETCH_TIMEOUT_MS });
|
|
88
|
+
saveQoderCredits(accountRecordId, credits.unavailable ? null : JSON.stringify(credits), credits.unavailable ?? null, credits.fetchedAt);
|
|
89
|
+
return credits;
|
|
90
|
+
}
|
|
91
|
+
/** Same throttle as Codex usage: the Credits endpoint is a second upstream call per account. */
|
|
92
|
+
export const QODER_CREDITS_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
93
|
+
export async function refreshQoderCreditsIfStale(accountRecordId, now = new Date()) {
|
|
94
|
+
const row = readQoderCreditsUpdatedAt(accountRecordId);
|
|
95
|
+
const last = row ? Date.parse(row) : NaN;
|
|
96
|
+
if (Number.isFinite(last) && now.getTime() - last < QODER_CREDITS_REFRESH_INTERVAL_MS)
|
|
97
|
+
return;
|
|
98
|
+
// Never throws, matching `refreshStoredCodexUsage`: this runs in the background after a response
|
|
99
|
+
// has already been delivered, so a dead credential or unreachable quota API must not surface.
|
|
100
|
+
await refreshStoredQoderCredits(accountRecordId).catch(() => { });
|
|
101
|
+
}
|
|
@@ -274,6 +274,12 @@ export async function registerCodexRoutes(app) {
|
|
|
274
274
|
values.push(typeof value === 'boolean' ? (value ? 1 : 0) : value);
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
|
+
// Re-enabling must also clear a quota verdict: every selection path excludes `health_state='down'`,
|
|
278
|
+
// so flipping `enabled` alone would leave the account unreachable while the UI showed it as on.
|
|
279
|
+
if (body.enabled === true) {
|
|
280
|
+
fields.push('health_state=?');
|
|
281
|
+
values.push('unknown');
|
|
282
|
+
}
|
|
277
283
|
fields.push('updated_at=?');
|
|
278
284
|
values.push(new Date().toISOString(), id);
|
|
279
285
|
raw.prepare(`UPDATE codex_accounts SET ${fields.join(',')} WHERE id=?`).run(...values);
|
|
@@ -4,10 +4,10 @@ import { requireAdminAuth, requireAdminCsrf } from '../../auth/middleware.js';
|
|
|
4
4
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
5
5
|
import { getRawDb } from '../../db/index.js';
|
|
6
6
|
import { uuid } from '../../auth/ids.js';
|
|
7
|
-
import { listQoderAccountSummaries, getQoderAccountDetailById, setQoderAccountHealth, upsertQoderAccount, findQoderAccountForImport, saveQoderCatalog,
|
|
7
|
+
import { listQoderAccountSummaries, getQoderAccountDetailById, setQoderAccountHealth, upsertQoderAccount, findQoderAccountForImport, saveQoderCatalog, toQoderAccountSummary, } from '../../db/repositories/qoder-accounts.js';
|
|
8
8
|
import { exchangeQoderPat, fetchQoderCatalog, serializeCatalog } from '../../providers/qoder/catalog.js';
|
|
9
9
|
import { probeQoderInference } from '../../providers/qoder/client.js';
|
|
10
|
-
import {
|
|
10
|
+
import { refreshStoredQoderCredits } from '../../providers/qoder/credits.js';
|
|
11
11
|
import { qoderCredentialsFor, withQoderCredentials } from '../../providers/qoder/credentials.js';
|
|
12
12
|
import { parseQoderImportText, toQoderPreview, qoderTokenFingerprint } from '../../providers/qoder/qoder-import.js';
|
|
13
13
|
import { redactString } from '../../security/redact.js';
|
|
@@ -87,17 +87,6 @@ function summaryOrThrow(id) {
|
|
|
87
87
|
return toQoderAccountSummary(row);
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
|
-
* Fetch and persist the Credits snapshot. Free models come from the account's cached catalog
|
|
91
|
-
* because `is_free` is a catalog property, not a quota one — it is why an account at zero
|
|
92
|
-
* Credits can still serve a promotional model.
|
|
93
|
-
*/
|
|
94
|
-
async function refreshQoderCredits(id, config) {
|
|
95
|
-
const resolved = config ?? (await qoderCredentialsFor(id)).config;
|
|
96
|
-
const freeModels = resolved.catalog ? [...resolved.catalog.entries.values()].filter((entry) => entry.isFree).map((entry) => entry.key) : [];
|
|
97
|
-
const credits = await fetchQoderCredits({ jobToken: resolved.jobToken }, freeModels, { timeoutMs: 10_000 });
|
|
98
|
-
saveQoderCredits(id, credits.unavailable ? null : JSON.stringify(credits), credits.unavailable ?? null, credits.fetchedAt);
|
|
99
|
-
return credits;
|
|
100
|
-
}
|
|
101
90
|
/**
|
|
102
91
|
* A PAT that works but a catalog that 502s is recoverable — the operator retries later — so the
|
|
103
92
|
* exchange result is stored even when the catalog fetch fails. When the exchange itself fails
|
|
@@ -147,7 +136,7 @@ export async function registerQoderRoutes(app) {
|
|
|
147
136
|
if (catalogError)
|
|
148
137
|
setQoderAccountHealth(result.id, 'unknown', catalogError);
|
|
149
138
|
// Best effort: an account that cannot report Credits is still a usable account.
|
|
150
|
-
await
|
|
139
|
+
await refreshStoredQoderCredits(result.id).catch(() => { });
|
|
151
140
|
recordAudit({ action: 'qoder.accounts.add', success: true, targetType: 'qoder_account', targetId: result.id, targetName: record.label ?? undefined, ip: req.ip, metadata: { status: result.status, catalogError: catalogError ? 'yes' : 'no' } });
|
|
152
141
|
return { account: summaryOrThrow(result.id), status: result.status, catalogError };
|
|
153
142
|
});
|
|
@@ -247,6 +236,12 @@ export async function registerQoderRoutes(app) {
|
|
|
247
236
|
values.push(typeof value === 'boolean' ? (value ? 1 : 0) : value);
|
|
248
237
|
}
|
|
249
238
|
}
|
|
239
|
+
// Re-enabling must also clear a quota verdict: every selection path excludes `health_state='down'`,
|
|
240
|
+
// so flipping `enabled` alone would leave the account unreachable while the UI showed it as on.
|
|
241
|
+
if (body.enabled === true) {
|
|
242
|
+
fields.push('health_state=?');
|
|
243
|
+
values.push('unknown');
|
|
244
|
+
}
|
|
250
245
|
fields.push('updated_at=?');
|
|
251
246
|
values.push(new Date().toISOString(), id);
|
|
252
247
|
raw.prepare(`UPDATE qoder_accounts SET ${fields.join(',')} WHERE id=?`).run(...values);
|
|
@@ -281,7 +276,9 @@ export async function registerQoderRoutes(app) {
|
|
|
281
276
|
if (!getQoderAccountDetailById(id))
|
|
282
277
|
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
283
278
|
const { config } = await qoderCredentialsFor(id, { force: true });
|
|
284
|
-
|
|
279
|
+
// The exchange above was forced and persisted, so the refresh reuses that live job token
|
|
280
|
+
// rather than churning another one.
|
|
281
|
+
await refreshStoredQoderCredits(id).catch(() => { });
|
|
285
282
|
const keys = config.catalog ? [...config.catalog.entries.keys()] : [];
|
|
286
283
|
recordAudit({ action: 'qoder.accounts.catalog', success: Boolean(config.catalog), targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { modelCount: keys.length } });
|
|
287
284
|
return { modelCount: keys.length, modelKeys: keys, catalogFetchedAt: config.catalog?.fetchedAt ?? null };
|
|
@@ -296,7 +293,7 @@ export async function registerQoderRoutes(app) {
|
|
|
296
293
|
throw new GatewayError('invalid_request_error', 'Qoder account not found', { status: 404 });
|
|
297
294
|
// Deliberately not forced: a fresh exchange invalidates the job token in flight, so reading
|
|
298
295
|
// Credits must reuse the live one rather than churn it.
|
|
299
|
-
const credits = await
|
|
296
|
+
const credits = await refreshStoredQoderCredits(id);
|
|
300
297
|
recordAudit({ action: 'qoder.accounts.credits', success: !credits.unavailable, targetType: 'qoder_account', targetId: id, ip: req.ip, metadata: { freeModels: credits.freeModels.length } });
|
|
301
298
|
return { account: summaryOrThrow(id) };
|
|
302
299
|
});
|
|
@@ -127,6 +127,13 @@ export function shouldFallback(combo, reason) {
|
|
|
127
127
|
return combo.trigger.connectTimeout;
|
|
128
128
|
case 'first_token_timeout':
|
|
129
129
|
return combo.trigger.firstTokenTimeout;
|
|
130
|
+
case 'quota':
|
|
131
|
+
// An exhausted account is the clearest case for trying the next candidate: retrying the same
|
|
132
|
+
// one cannot succeed until its window resets. Unconditional because a dead account is not a
|
|
133
|
+
// provider problem the admin's 429/5xx toggles were meant to gate — the account is already
|
|
134
|
+
// disabled by the runner, and stopping here would answer "usage limited" instead of routing
|
|
135
|
+
// to a healthy account.
|
|
136
|
+
return true;
|
|
130
137
|
case 'http_status':
|
|
131
138
|
if (reason.status === 408)
|
|
132
139
|
return combo.trigger.on408;
|
|
@@ -75,8 +75,11 @@ export function providerToUpstreamConfig(p, codexAccountId) {
|
|
|
75
75
|
export function upstreamHttpError(status, bodyExcerpt, cause) {
|
|
76
76
|
if (status >= 500)
|
|
77
77
|
return new GatewayError('upstream_error', `Upstream HTTP ${status}`, { status: 502, cause, code: `upstream_http_${status}` });
|
|
78
|
+
// Keep the body on a 429: it is the only place a provider says *why*, and the wording decides
|
|
79
|
+
// whether this is a transient throttle (retry) or an exhausted account (disable it and move on).
|
|
80
|
+
// `isQuotaFailure` matches on that wording. Callers pass an already-redacted excerpt.
|
|
78
81
|
if (status === 429)
|
|
79
|
-
return new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${status})`, { status: 429, cause, code: 'upstream_http_429' });
|
|
82
|
+
return new GatewayError('upstream_rate_limit', `Upstream rate limited (HTTP ${status})${bodyExcerpt ? `: ${bodyExcerpt}` : ''}`, { status: 429, cause, code: 'upstream_http_429' });
|
|
80
83
|
if (status === 401 || status === 403)
|
|
81
84
|
return new GatewayError('upstream_auth_error', `Upstream authentication failed (HTTP ${status})`, { status: 502, cause, code: `upstream_http_${status}` });
|
|
82
85
|
return new GatewayError('upstream_error', `Upstream HTTP ${status}: ${bodyExcerpt}`, { status: 502, cause, code: `upstream_http_${status}` });
|