ldrouter 1.17.4 → 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 CHANGED
@@ -4,6 +4,14 @@ 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
+
7
15
  ## [1.17.4] - 2026-09-18
8
16
 
9
17
  ### Fixed
@@ -296,6 +296,7 @@ export class GatewayRunner {
296
296
  catch (e) {
297
297
  const err = e instanceof GatewayError ? e : new GatewayError('upstream_error', e.message, { cause: e });
298
298
  const quotaFailure = isQuotaFailure(err);
299
+ const credentialFailure = isCredentialFailure(err);
299
300
  debugUpstream(ctx.requestId, 'ATTEMPT ERROR', [
300
301
  `attempt=${i + 1}`,
301
302
  `provider=${provider.name}`,
@@ -329,6 +330,11 @@ export class GatewayRunner {
329
330
  // dead account on every request for hours (verified live) and answered "usage limited".
330
331
  if (quotaFailure)
331
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);
332
338
  if (isUpstreamHealthFailure(err)) {
333
339
  recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
334
340
  if (candidate.codexAccountId)
@@ -1032,6 +1038,39 @@ export function markQuotaExhausted(candidate, message) {
1032
1038
  if (candidate.qoderAccountId)
1033
1039
  setQoderAccountHealth(candidate.qoderAccountId, 'down', reason, false);
1034
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
+ }
1035
1074
  /**
1036
1075
  * Whether a failed attempt should advance to the next candidate.
1037
1076
  *
@@ -1043,11 +1082,17 @@ export function markQuotaExhausted(candidate, message) {
1043
1082
  * plan means no fallback.
1044
1083
  */
1045
1084
  export function shouldRetryAttempt(comboPlan, err) {
1085
+ if (isCredentialFailure(err))
1086
+ return true;
1046
1087
  if (isQuotaFailure(err))
1047
1088
  return true;
1048
1089
  return comboPlan ? shouldFallback(comboPlan, { type: classifyFailure(err), status: err.status }) : false;
1049
1090
  }
1050
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';
1051
1096
  // Checked first, before the type switch: a quota refusal arrives as `upstream_rate_limit` (a
1052
1097
  // 429) or `upstream_error` (Codex rewraps it as a 502 with a 429 cause; Qoder uses code 112) and
1053
1098
  // must not fall through to `http_status`/`unknown`, neither of which is a routing decision.
@@ -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
- return new GatewayError('authentication_error', 'Codex credentials could not be refreshed — re-import the account', { status: 401 });
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldrouter",
3
- "version": "1.17.4",
3
+ "version": "1.17.5",
4
4
  "description": "LateDev Router — lightweight self-hosted LLM gateway with admin UI",
5
5
  "type": "module",
6
6
  "license": "MIT",