@juspay/neurolink 12.7.2 → 12.7.4

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.
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { Agent } from "undici";
16
16
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
17
17
  import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
18
- import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
18
+ import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
19
19
  import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, mergeQuotaSnapshot, modelFamilyToken, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
20
20
  import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
21
21
  import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
@@ -23,6 +23,7 @@ import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoord
23
23
  import { MAX_COOLDOWN_MS_BY_REASON } from "../../proxy/routingEvidence.js";
24
24
  import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
25
25
  import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
26
+ import { CodexFallbackResponseError, consumeCodexFallbackResponse, convertClaudeRequestToCodex, } from "../../proxy/codexFallback.js";
26
27
  import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
27
28
  import { tracers } from "../../telemetry/tracers.js";
28
29
  import { withSpan } from "../../telemetry/withSpan.js";
@@ -56,6 +57,7 @@ import { sanitizeForLog } from "../../utils/logSanitize.js";
56
57
  import { logger } from "../../utils/logger.js";
57
58
  import { raceWithAbort, withTimeout } from "../../utils/async/withTimeout.js";
58
59
  import { ProviderHealthChecker } from "../../utils/providerHealth.js";
60
+ import { handleCodexResponsesRequest } from "./codexProxyRoutes.js";
59
61
  // ---------------------------------------------------------------------------
60
62
  // Helpers
61
63
  // ---------------------------------------------------------------------------
@@ -70,6 +72,30 @@ const BLOCKED_UPSTREAM_HEADERS = new Set([
70
72
  ]);
71
73
  const PROXY_INTERNAL_ACCOUNT_LABEL = "proxy/internal";
72
74
  const PROXY_INTERNAL_ACCOUNT_TYPE = "internal";
75
+ function resolveRequestLogAccountIdentity(accountLabel, accountType) {
76
+ if (!accountLabel) {
77
+ return {};
78
+ }
79
+ if (accountType === "codex-oauth") {
80
+ return {
81
+ accountKey: accountLabel.startsWith("codex:")
82
+ ? accountLabel
83
+ : `codex:${accountLabel}`,
84
+ provider: "openai",
85
+ };
86
+ }
87
+ if (accountType === "oauth" ||
88
+ accountType === "api_key" ||
89
+ accountType === "passthrough") {
90
+ return {
91
+ accountKey: accountType === "passthrough"
92
+ ? accountLabel
93
+ : normalizeAnthropicAccountKey(accountLabel),
94
+ provider: "anthropic",
95
+ };
96
+ }
97
+ return {};
98
+ }
73
99
  // ---------------------------------------------------------------------------
74
100
  // Module-level state
75
101
  // ---------------------------------------------------------------------------
@@ -632,7 +658,8 @@ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
632
658
  }
633
659
  /**
634
660
  * Seed each account's runtime quota from the persisted snapshots in
635
- * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
661
+ * ~/.neurolink/account-quotas.json (keyed by provider-qualified account key).
662
+ * Runtime state is
636
663
  * in-memory only, so without this the quota-aware ordering is blind after a
637
664
  * proxy restart: all accounts tie, selection falls back to token-store
638
665
  * enumeration order, and the first account served becomes self-reinforcing
@@ -650,8 +677,13 @@ async function seedRuntimeQuotasFromDisk(accounts) {
650
677
  const now = Date.now();
651
678
  for (const account of accounts) {
652
679
  const state = getOrCreateRuntimeState(account.key);
653
- if (!state.quota && persistedQuotas[account.label]) {
654
- state.quota = persistedQuotas[account.label];
680
+ if (!state.quota) {
681
+ // Before provider identity was introduced, Anthropic snapshots were
682
+ // written under the bare display label. Keep that reading as an
683
+ // upgrade bridge, but never write new data back under the ambiguous
684
+ // key: a Codex login can legitimately use the same email.
685
+ state.quota =
686
+ persistedQuotas[account.key] ?? persistedQuotas[account.label];
655
687
  }
656
688
  const persistedCooldown = persistedCooldowns[account.key];
657
689
  if (persistedCooldown?.coolingUntil > now &&
@@ -706,7 +738,7 @@ async function applyAccountUsageResult(account, fetchResult, observedAt, prior)
706
738
  // Non-fatal: the next successful response will reconcile again.
707
739
  });
708
740
  }
709
- await saveAccountQuota(account.label, quota).catch(() => {
741
+ await saveAccountQuota(account.key, quota).catch(() => {
710
742
  // Non-fatal: quota persistence is best-effort.
711
743
  });
712
744
  return quota;
@@ -757,7 +789,11 @@ async function refreshAccountLimits(options = {}) {
757
789
  key: account.key,
758
790
  type: account.type,
759
791
  status,
760
- quota: quota ?? state?.quota ?? persisted[account.label] ?? null,
792
+ quota: quota ??
793
+ state?.quota ??
794
+ persisted[account.key] ??
795
+ persisted[account.label] ??
796
+ null,
761
797
  };
762
798
  if (error !== undefined) {
763
799
  result.error = error;
@@ -813,7 +849,7 @@ async function refreshAccountLimits(options = {}) {
813
849
  results[index] = buildResult(account, "error", null, fetchResult.error);
814
850
  continue;
815
851
  }
816
- const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.label] ?? null);
852
+ const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.key] ?? persisted[account.label] ?? null);
817
853
  if (!quota) {
818
854
  results[index] = buildResult(account, "error", null, "usage payload had no recognizable limit windows");
819
855
  continue;
@@ -2535,20 +2571,14 @@ async function applyShareAccountGates(args) {
2535
2571
  grant,
2536
2572
  retryAfterSeconds: earliestShareRecoverySeconds(views, now),
2537
2573
  });
2538
- // Assigned, not merged into an existing object: a refusal is often the first
2539
- // thing to touch this context, and only copying when headers already existed
2540
- // meant the borrower learned nothing about why it was refused.
2541
- args.ctx.responseHeaders = {
2542
- ...(args.ctx.responseHeaders ?? {}),
2543
- ...refusal.headers,
2544
- };
2545
2574
  logger.always(`[proxy] share ${share.peerLabel} withheld: ${reason} (${decision.excluded.length} accounts)`);
2546
2575
  return {
2547
2576
  accounts: [],
2548
2577
  refusal: {
2549
- response: args.buildLoggedClaudeError(refusal.status, refusal.body.error.message, refusal.body.error.type),
2550
2578
  status: refusal.status,
2551
2579
  message: refusal.body.error.message,
2580
+ errorType: refusal.body.error.type,
2581
+ responseHeaders: refusal.headers,
2552
2582
  },
2553
2583
  };
2554
2584
  }
@@ -2836,7 +2866,7 @@ async function auditCompleteShareHeartbeat(grant, reportedCoins) {
2836
2866
  return { paused: false, detail: "no provisioned account recorded" };
2837
2867
  }
2838
2868
  const state = accountRuntimeState.get(`anthropic:${accountLabel}`);
2839
- const stats = getAccountStats(accountLabel);
2869
+ const stats = getAccountStats(accountLabel, "oauth");
2840
2870
  const { verdict, shouldPause } = await recordAuditObservation({
2841
2871
  grantId: grant.id,
2842
2872
  accountLabel,
@@ -3009,7 +3039,7 @@ function earliestShareRecoverySeconds(views, now) {
3009
3039
  return Math.max(1, Math.round((Math.min(...resets) - now) / 1000));
3010
3040
  }
3011
3041
  async function loadClaudeProxyAccounts(args) {
3012
- const { ctx, body, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, setRoutingDecision, } = args;
3042
+ const { ctx, body, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), setRoutingDecision, } = args;
3013
3043
  const fs = await import("fs");
3014
3044
  const os = await import("os");
3015
3045
  const accounts = [];
@@ -3176,10 +3206,12 @@ async function loadClaudeProxyAccounts(args) {
3176
3206
  message: "OAuth authentication is not allowed for this organization.",
3177
3207
  errorCode: "oauth_not_allowed_for_organization",
3178
3208
  });
3179
- tracer?.setError("permission_error", entitlementMessage);
3180
- tracer?.end(403, Date.now() - requestStartTime);
3181
3209
  return {
3182
- response: buildLoggedClaudeError(403, entitlementMessage, "permission_error"),
3210
+ failure: {
3211
+ status: 403,
3212
+ message: entitlementMessage,
3213
+ errorType: "permission_error",
3214
+ },
3183
3215
  };
3184
3216
  }
3185
3217
  const noCredentialsMessage = accountAllowlist
@@ -3187,10 +3219,12 @@ async function loadClaudeProxyAccounts(args) {
3187
3219
  : compoundKeys.length > 0
3188
3220
  ? "Configured Anthropic accounts are disabled or unavailable"
3189
3221
  : "No Anthropic credentials found";
3190
- tracer?.setError("authentication_error", noCredentialsMessage);
3191
- tracer?.end(401, Date.now() - requestStartTime);
3192
3222
  return {
3193
- response: buildLoggedClaudeError(401, noCredentialsMessage),
3223
+ failure: {
3224
+ status: 401,
3225
+ message: noCredentialsMessage,
3226
+ errorType: "authentication_error",
3227
+ },
3194
3228
  };
3195
3229
  }
3196
3230
  for (const account of accounts) {
@@ -3228,28 +3262,30 @@ async function loadClaudeProxyAccounts(args) {
3228
3262
  `Ask the lender to widen the share, or use a model it allows.`
3229
3263
  : `Borrowed account(s) are no longer covered by a lease: ${detail}. ` +
3230
3264
  `Run 'neurolink proxy peer sync' to check in with the lender, or ask them to resume the share.`;
3231
- tracer?.setError("permission_error", leaseMessage);
3232
- tracer?.end(403, Date.now() - requestStartTime);
3233
3265
  return {
3234
- response: buildLoggedClaudeError(403, leaseMessage, "permission_error"),
3266
+ failure: {
3267
+ status: 403,
3268
+ message: leaseMessage,
3269
+ errorType: "permission_error",
3270
+ },
3235
3271
  };
3236
3272
  }
3237
3273
  const shareFiltered = await applyShareAccountGates({
3238
3274
  accounts: leasedAccounts,
3239
- ctx,
3240
- buildLoggedClaudeError,
3241
3275
  });
3242
3276
  if (shareFiltered.refusal) {
3243
- tracer?.setError("rate_limit_error", shareFiltered.refusal.message);
3244
- tracer?.end(shareFiltered.refusal.status, Date.now() - requestStartTime);
3245
- return { response: shareFiltered.refusal.response };
3277
+ return { failure: shareFiltered.refusal };
3246
3278
  }
3247
3279
  const enabledAccounts = shareFiltered.accounts;
3248
3280
  if (enabledAccounts.length === 0) {
3249
3281
  const reauthMsg = formatReauthMessage(accounts.map((account) => account.label));
3250
- tracer?.setError("authentication_error", reauthMsg);
3251
- tracer?.end(401, Date.now() - requestStartTime);
3252
- return { response: buildLoggedClaudeError(401, reauthMsg) };
3282
+ return {
3283
+ failure: {
3284
+ status: 401,
3285
+ message: reauthMsg,
3286
+ errorType: "authentication_error",
3287
+ },
3288
+ };
3253
3289
  }
3254
3290
  const { orderedAccounts, metricsByKey } = selectClaudeProxyAccountOrder({
3255
3291
  enabledAccounts,
@@ -3430,6 +3466,122 @@ async function executeClaudeFallbackWithRetry(args) {
3430
3466
  }
3431
3467
  throw lastError;
3432
3468
  }
3469
+ /**
3470
+ * Run the configured `codex` fallback through the native pooled Codex route.
3471
+ *
3472
+ * The inner response is fully buffered and validated before this function
3473
+ * creates a single Claude frame. That preserves the proxy's no-replay-after-
3474
+ * output guarantee when Codex returns an incomplete stream.
3475
+ */
3476
+ async function executeClaudeCodexFallback(args) {
3477
+ const { ctx, body, model, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
3478
+ const codexCtx = {
3479
+ ...ctx,
3480
+ requestId: `${ctx.requestId}:codex-fallback`,
3481
+ method: "POST",
3482
+ path: "/backend-api/codex/responses",
3483
+ headers: {
3484
+ "content-type": "application/json",
3485
+ accept: "text/event-stream",
3486
+ },
3487
+ query: {},
3488
+ params: {},
3489
+ body: convertClaudeRequestToCodex(body, model),
3490
+ metadata: { ...ctx.metadata, "neurolink.codexFallback": true },
3491
+ // Keep the child attribution isolated until its stream has passed
3492
+ // validation. A failed Codex attempt must not look like a served request.
3493
+ responseHeaders: {},
3494
+ };
3495
+ const codexResponse = await handleCodexResponsesRequest(codexCtx);
3496
+ const codexHeaders = { ...(codexCtx.responseHeaders ?? {}) };
3497
+ let parsed;
3498
+ try {
3499
+ parsed = await consumeCodexFallbackResponse(codexResponse);
3500
+ }
3501
+ catch (error) {
3502
+ if (error instanceof CodexFallbackResponseError) {
3503
+ logger.always(`[proxy] Codex fallback returned ${error.status}: ${sanitizeForLog(error.responseBody, 500)}`);
3504
+ }
3505
+ throw error;
3506
+ }
3507
+ if (Object.keys(codexHeaders).length > 0) {
3508
+ ctx.responseHeaders ??= {};
3509
+ Object.assign(ctx.responseHeaders, redactHeadersForBorrower(codexHeaders));
3510
+ }
3511
+ const accountLabel = codexHeaders["x-neurolink-account"] ?? "";
3512
+ const accountType = codexHeaders["x-neurolink-account-type"] ?? "codex-oauth";
3513
+ const internal = {
3514
+ content: parsed.text,
3515
+ // Keep the original Anthropic model in the client wire response. The
3516
+ // attribution headers above expose that Codex served the fallback.
3517
+ model: body.model,
3518
+ finishReason: parsed.finishReason,
3519
+ ...(parsed.usage ? { usage: parsed.usage } : {}),
3520
+ toolCalls: parsed.toolCalls,
3521
+ };
3522
+ if (body.stream) {
3523
+ const serializer = new ClaudeStreamSerializer(body.model, parsed.usage?.input ?? 0);
3524
+ const frames = [];
3525
+ for (const frame of serializer.start()) {
3526
+ frames.push(frame);
3527
+ }
3528
+ if (parsed.text) {
3529
+ for (const frame of serializer.pushDelta(parsed.text)) {
3530
+ frames.push(frame);
3531
+ }
3532
+ }
3533
+ for (const toolCall of parsed.toolCalls) {
3534
+ for (const frame of serializer.pushToolUse(generateToolUseId(), toolCall.toolName, toolCall.args)) {
3535
+ frames.push(frame);
3536
+ }
3537
+ }
3538
+ for (const frame of serializer.finish(parsed.usage?.output, parsed.finishReason)) {
3539
+ frames.push(frame);
3540
+ }
3541
+ tracer?.end(200, Date.now() - requestStartTime);
3542
+ logFinalRequest(200, accountLabel, accountType, undefined, undefined, {
3543
+ inputTokens: parsed.usage?.input,
3544
+ outputTokens: parsed.usage?.output,
3545
+ cacheCreationTokens: parsed.usage?.cacheCreationTokens,
3546
+ cacheReadTokens: parsed.usage?.cacheReadTokens,
3547
+ });
3548
+ const bufferedBody = frames.join("");
3549
+ logProxyBody({
3550
+ phase: "client_response",
3551
+ headers: { "content-type": "text/event-stream" },
3552
+ body: bufferedBody,
3553
+ bodySize: Buffer.byteLength(bufferedBody, "utf8"),
3554
+ contentType: "text/event-stream",
3555
+ responseStatus: 200,
3556
+ durationMs: Date.now() - requestStartTime,
3557
+ });
3558
+ async function* sseGenerator() {
3559
+ for (const frame of frames) {
3560
+ yield frame;
3561
+ }
3562
+ }
3563
+ return sseGenerator();
3564
+ }
3565
+ tracer?.end(200, Date.now() - requestStartTime);
3566
+ const clientResponse = serializeClaudeResponse(internal, body.model);
3567
+ logFinalRequest(200, accountLabel, accountType, undefined, undefined, {
3568
+ inputTokens: parsed.usage?.input,
3569
+ outputTokens: parsed.usage?.output,
3570
+ cacheCreationTokens: parsed.usage?.cacheCreationTokens,
3571
+ cacheReadTokens: parsed.usage?.cacheReadTokens,
3572
+ });
3573
+ const clientResponseText = JSON.stringify(clientResponse);
3574
+ logProxyBody({
3575
+ phase: "client_response",
3576
+ headers: { "content-type": "application/json" },
3577
+ body: clientResponseText,
3578
+ bodySize: Buffer.byteLength(clientResponseText, "utf8"),
3579
+ contentType: "application/json",
3580
+ responseStatus: 200,
3581
+ durationMs: Date.now() - requestStartTime,
3582
+ });
3583
+ return clientResponse;
3584
+ }
3433
3585
  /**
3434
3586
  * Try each borrowable peer in priority order once the local pool is spent.
3435
3587
  *
@@ -3486,10 +3638,20 @@ async function tryBorrowFromPeers(args) {
3486
3638
  }
3487
3639
  return null;
3488
3640
  }
3641
+ function getCodexFallbackInvalidRequestFailure(error) {
3642
+ if (!(error instanceof CodexFallbackResponseError) || error.status !== 400) {
3643
+ return null;
3644
+ }
3645
+ return {
3646
+ status: error.status,
3647
+ body: error.responseBody,
3648
+ contentType: "application/json",
3649
+ };
3650
+ }
3489
3651
  async function tryConfiguredClaudeFallbackChain(args) {
3490
- const { ctx, body, parsedFallbackRequest, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
3491
- const chain = modelRouter?.getFallbackChain() ?? [];
3492
- const fallbackPlan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, chain, body.model, parsedFallbackRequest);
3652
+ const { ctx, body, parsedFallbackRequest, fallbackPlan: providedFallbackPlan, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
3653
+ const fallbackPlan = providedFallbackPlan ??
3654
+ buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, modelRouter?.getFallbackChain() ?? [], body.model, parsedFallbackRequest);
3493
3655
  logProxyBody({
3494
3656
  phase: "routing_decision",
3495
3657
  contentType: "application/json",
@@ -3504,41 +3666,58 @@ async function tryConfiguredClaudeFallbackChain(args) {
3504
3666
  reason: "all_anthropic_accounts_exhausted",
3505
3667
  });
3506
3668
  let lastFallbackError;
3669
+ let invalidRequestFailure;
3507
3670
  for (const fallback of fallbackPlan.attempts.slice(1)) {
3508
3671
  if (!fallback.provider || !fallback.model) {
3509
3672
  continue;
3510
3673
  }
3511
- const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(fallback.provider, fallback.model);
3512
- if (!availability.available) {
3513
- const reason = availability.reason ?? "provider unavailable";
3514
- logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} health-check failed (${reason}), skipping`);
3515
- recordFallbackAttempt({
3516
- provider: fallback.provider,
3517
- model: fallback.model,
3518
- status: "failure",
3519
- errorMessage: `[unavailable] ${reason}`,
3520
- durationMs: 0,
3521
- });
3522
- lastFallbackError = `[${fallback.provider}/${fallback.model}] unavailable: ${reason}`;
3523
- continue;
3524
- }
3525
3674
  const fallbackStart = Date.now();
3526
3675
  try {
3527
3676
  logger.always(`[proxy] fallback → ${fallback.provider}/${fallback.model}`);
3528
- const options = buildProxyFallbackOptions(parsedFallbackRequest, {
3529
- provider: fallback.provider,
3530
- model: fallback.model,
3531
- });
3532
- const response = await executeClaudeFallbackWithRetry({
3533
- ctx,
3534
- body,
3535
- tracer,
3536
- requestStartTime,
3537
- logProxyBody,
3538
- logFinalRequest,
3539
- options: options,
3540
- providerLabel: fallback.provider,
3541
- });
3677
+ let response;
3678
+ if (fallback.provider === "codex") {
3679
+ // Codex is a local OAuth account pool, not a generic SDK provider.
3680
+ // Calling its native route preserves account rotation and cooldowns.
3681
+ response = await executeClaudeCodexFallback({
3682
+ ctx,
3683
+ body,
3684
+ model: fallback.model,
3685
+ tracer,
3686
+ requestStartTime,
3687
+ logProxyBody,
3688
+ logFinalRequest,
3689
+ });
3690
+ }
3691
+ else {
3692
+ const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(fallback.provider, fallback.model);
3693
+ if (!availability.available) {
3694
+ const reason = availability.reason ?? "provider unavailable";
3695
+ logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} health-check failed (${reason}), skipping`);
3696
+ recordFallbackAttempt({
3697
+ provider: fallback.provider,
3698
+ model: fallback.model,
3699
+ status: "failure",
3700
+ errorMessage: `[unavailable] ${reason}`,
3701
+ durationMs: 0,
3702
+ });
3703
+ lastFallbackError = `[${fallback.provider}/${fallback.model}] unavailable: ${reason}`;
3704
+ continue;
3705
+ }
3706
+ const options = buildProxyFallbackOptions(parsedFallbackRequest, {
3707
+ provider: fallback.provider,
3708
+ model: fallback.model,
3709
+ });
3710
+ response = await executeClaudeFallbackWithRetry({
3711
+ ctx,
3712
+ body,
3713
+ tracer,
3714
+ requestStartTime,
3715
+ logProxyBody,
3716
+ logFinalRequest,
3717
+ options: options,
3718
+ providerLabel: fallback.provider,
3719
+ });
3720
+ }
3542
3721
  recordFallbackAttempt({
3543
3722
  provider: fallback.provider,
3544
3723
  model: fallback.model,
@@ -3552,17 +3731,21 @@ async function tryConfiguredClaudeFallbackChain(args) {
3552
3731
  attemptCount: fallbackPlan.attempts.slice(1).length,
3553
3732
  reason: "fallback_success",
3554
3733
  });
3555
- // A different provider produced this response — say so, and report no
3556
- // quota. Emitting the last Anthropic snapshot here would attribute one
3557
- // provider's capacity to another's output.
3558
- publishLimitHeaders(ctx, {
3559
- quota: null,
3560
- source: "none",
3561
- servedBy: fallback.provider,
3562
- });
3734
+ if (fallback.provider !== "codex") {
3735
+ // A different provider produced this response say so, and report no
3736
+ // quota. Emitting the last Anthropic snapshot here would attribute one
3737
+ // provider's capacity to another's output.
3738
+ publishLimitHeaders(ctx, {
3739
+ quota: null,
3740
+ source: "none",
3741
+ servedBy: fallback.provider,
3742
+ });
3743
+ }
3563
3744
  return { response };
3564
3745
  }
3565
3746
  catch (fallbackErr) {
3747
+ invalidRequestFailure ??=
3748
+ getCodexFallbackInvalidRequestFailure(fallbackErr) ?? undefined;
3566
3749
  const errMsg = redactProviderErrorMessage(fallbackErr instanceof Error
3567
3750
  ? fallbackErr.message
3568
3751
  : String(fallbackErr));
@@ -3598,7 +3781,11 @@ async function tryConfiguredClaudeFallbackChain(args) {
3598
3781
  lastFallbackError = `[${fallback.provider}/${fallback.model}] ${redactProviderErrorMessage(describeTransportError(fallbackErr))}`;
3599
3782
  }
3600
3783
  }
3601
- return { response: null, lastErrorMessage: lastFallbackError };
3784
+ return {
3785
+ response: null,
3786
+ lastErrorMessage: lastFallbackError,
3787
+ ...(invalidRequestFailure ? { invalidRequestFailure } : {}),
3788
+ };
3602
3789
  }
3603
3790
  async function tryAutoClaudeFallback(args) {
3604
3791
  const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest } = args;
@@ -3769,28 +3956,32 @@ function buildClaudeAnthropicFailureResponse(args) {
3769
3956
  tracer?.end(503, Date.now() - requestStartTime);
3770
3957
  return buildLoggedClaudeError(503, authCooldownMessage, "token_refresh_unavailable");
3771
3958
  }
3772
- if (invalidRequestFailure) {
3773
- tracer?.setError("invalid_request_error", summarizeErrorMessage(invalidRequestFailure.body));
3959
+ if (invalidRequestFailure && !sawRateLimit) {
3960
+ const parsedUpstream = parseClaudeErrorBody(invalidRequestFailure.body);
3961
+ const preserveUpstreamBody = parsedUpstream.message !== undefined;
3962
+ const message = summarizeErrorMessage(parsedUpstream.message ?? invalidRequestFailure.body);
3963
+ const errorBodyText = preserveUpstreamBody
3964
+ ? invalidRequestFailure.body
3965
+ : JSON.stringify(buildClaudeError(invalidRequestFailure.status, message, "invalid_request_error"));
3966
+ const contentType = preserveUpstreamBody
3967
+ ? (invalidRequestFailure.contentType ?? "application/json")
3968
+ : "application/json";
3969
+ tracer?.setError("invalid_request_error", message);
3774
3970
  tracer?.end(invalidRequestFailure.status, Date.now() - requestStartTime);
3775
- try {
3776
- const parsedError = JSON.parse(invalidRequestFailure.body);
3777
- logFinalRequest(invalidRequestFailure.status, "", "final", "invalid_request_error", summarizeErrorMessage(invalidRequestFailure.body));
3778
- logProxyBody({
3779
- phase: "client_response",
3780
- headers: {
3781
- "content-type": invalidRequestFailure.contentType ?? "application/json",
3782
- },
3783
- body: invalidRequestFailure.body,
3784
- bodySize: Buffer.byteLength(invalidRequestFailure.body, "utf8"),
3785
- contentType: invalidRequestFailure.contentType ?? "application/json",
3786
- responseStatus: invalidRequestFailure.status,
3787
- durationMs: Date.now() - requestStartTime,
3788
- });
3789
- return parsedError;
3790
- }
3791
- catch {
3792
- return buildLoggedClaudeError(invalidRequestFailure.status, summarizeErrorMessage(invalidRequestFailure.body), "invalid_request_error");
3793
- }
3971
+ logFinalRequest(invalidRequestFailure.status, "", "final", "invalid_request_error", message);
3972
+ logProxyBody({
3973
+ phase: "client_response",
3974
+ headers: { "content-type": contentType },
3975
+ body: errorBodyText,
3976
+ bodySize: Buffer.byteLength(errorBodyText, "utf8"),
3977
+ contentType,
3978
+ responseStatus: invalidRequestFailure.status,
3979
+ durationMs: Date.now() - requestStartTime,
3980
+ });
3981
+ return new Response(errorBodyText, {
3982
+ status: invalidRequestFailure.status,
3983
+ headers: { "content-type": contentType },
3984
+ });
3794
3985
  }
3795
3986
  if ((sawNetworkError || sawTransientFailure) && !sawRateLimit) {
3796
3987
  const fallbackSuffix = fallbackFailureMessage
@@ -3903,7 +4094,7 @@ async function handleAnthropicSuccessfulResponse(args) {
3903
4094
  // Non-fatal: the next successful response will reconcile again.
3904
4095
  });
3905
4096
  }
3906
- saveAccountQuota(account.label, quota).catch(() => {
4097
+ saveAccountQuota(account.key, quota).catch(() => {
3907
4098
  // Non-fatal: quota persistence is best-effort
3908
4099
  });
3909
4100
  }
@@ -4569,7 +4760,7 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
4569
4760
  // Non-fatal: the next successful response will reconcile again.
4570
4761
  });
4571
4762
  }
4572
- saveAccountQuota(account.label, retryQuota).catch((error) => {
4763
+ saveAccountQuota(account.key, retryQuota).catch((error) => {
4573
4764
  logger.debug("[proxy] Failed to persist account quota after auth retry", {
4574
4765
  account: account.label,
4575
4766
  error: error instanceof Error ? error.message : String(error),
@@ -4860,7 +5051,7 @@ async function handleAnthropicAuthRetry(args) {
4860
5051
  accountState.coolingReason = retryPlan.reason;
4861
5052
  }
4862
5053
  if (retryQuota429) {
4863
- saveAccountQuota(account.label, retryQuota429).catch(() => {
5054
+ saveAccountQuota(account.key, retryQuota429).catch(() => {
4864
5055
  // Non-fatal: routing already has the in-memory snapshot.
4865
5056
  });
4866
5057
  }
@@ -5085,7 +5276,7 @@ function finalizeAnthropicTerminalTransportError(args) {
5085
5276
  return clientError;
5086
5277
  }
5087
5278
  async function handleAnthropicNonOkResponse(args) {
5088
- const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, } = args;
5279
+ const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, allowConfiguredModelFallback = false, } = args;
5089
5280
  let currentLastError = lastError;
5090
5281
  let currentAuthFailureMessage = authFailureMessage;
5091
5282
  let currentSawTransientFailure = sawTransientFailure;
@@ -5257,6 +5448,26 @@ async function handleAnthropicNonOkResponse(args) {
5257
5448
  };
5258
5449
  }
5259
5450
  if (response.status === 404) {
5451
+ if (allowConfiguredModelFallback &&
5452
+ isAnthropicModelNotFound(response.status, errBody)) {
5453
+ // An upstream model retirement is provider-wide, not an account failure.
5454
+ // Do not cool or disable an account; leave the configured translation
5455
+ // fallback eligible so legacy Anthropic aliases can use the Codex target.
5456
+ logger.always(`[proxy] ← 404 account=${account.label} model unavailable; trying configured fallback`);
5457
+ logAttempt(404, "not_found_error", summarizeErrorMessage(errBody));
5458
+ tracer?.setError("not_found_error", summarizeErrorMessage(errBody));
5459
+ tracer?.recordRetry(account.label, "model_not_found");
5460
+ currentLastError = summarizeErrorMessage(errBody);
5461
+ return {
5462
+ continueLoop: false,
5463
+ lastError: currentLastError,
5464
+ authFailureMessage: currentAuthFailureMessage,
5465
+ sawTransientFailure: currentSawTransientFailure,
5466
+ invalidRequestFailure: currentInvalidRequestFailure,
5467
+ entitlementFailure: currentEntitlementFailure,
5468
+ upstreamSpan: undefined,
5469
+ };
5470
+ }
5260
5471
  logger.always(`[proxy] ← 404 account=${account.label}`);
5261
5472
  logAttempt(404, "not_found_error", summarizeErrorMessage(errBody));
5262
5473
  tracer?.setError("not_found_error", summarizeErrorMessage(errBody));
@@ -5404,9 +5615,13 @@ function createClaudeRequestRuntimeContext(args) {
5404
5615
  : status >= 400
5405
5616
  ? PROXY_INTERNAL_ACCOUNT_TYPE
5406
5617
  : undefined;
5618
+ const finalAccountIdentity = resolveRequestLogAccountIdentity(finalAccountLabel, finalAccountType);
5407
5619
  if (status >= 400) {
5408
5620
  recordFinalError(status, finalAccountLabel, finalAccountType, {
5409
5621
  requestId: ctx.requestId,
5622
+ ...(finalAccountIdentity.accountKey
5623
+ ? { accountKey: finalAccountIdentity.accountKey }
5624
+ : {}),
5410
5625
  errorType,
5411
5626
  terminalOutcome: errorType === "client_cancelled"
5412
5627
  ? "client_cancelled"
@@ -5430,7 +5645,13 @@ function createClaudeRequestRuntimeContext(args) {
5430
5645
  stream: !!body.stream,
5431
5646
  toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
5432
5647
  account: finalAccountLabel ?? "",
5648
+ ...(finalAccountIdentity.accountKey
5649
+ ? { accountKey: finalAccountIdentity.accountKey }
5650
+ : {}),
5433
5651
  accountType: finalAccountType ?? "",
5652
+ ...(finalAccountIdentity.provider
5653
+ ? { provider: finalAccountIdentity.provider }
5654
+ : {}),
5434
5655
  ...buildClientAttribution(ctx.headers),
5435
5656
  responseStatus: status,
5436
5657
  responseTimeMs: Date.now() - requestStartTime,
@@ -5493,6 +5714,7 @@ function createClaudeRequestRuntimeContext(args) {
5493
5714
  function createAnthropicAttemptLogger(args) {
5494
5715
  const { ctx, body, toolCount, requestStart, tracer, account, attemptNumber } = args;
5495
5716
  const attemptStartedAt = Date.now();
5717
+ const accountIdentity = resolveRequestLogAccountIdentity(account.label, account.type);
5496
5718
  return (status, errorType, errorMessage, extra) => {
5497
5719
  const attemptCompletedAt = Date.now();
5498
5720
  const traceCtx = tracer?.getTraceContext();
@@ -5506,7 +5728,11 @@ function createAnthropicAttemptLogger(args) {
5506
5728
  stream: !!body.stream,
5507
5729
  toolCount,
5508
5730
  account: account.label,
5731
+ accountKey: account.key,
5509
5732
  accountType: account.type,
5733
+ ...(accountIdentity.provider
5734
+ ? { provider: accountIdentity.provider }
5735
+ : {}),
5510
5736
  responseStatus: status,
5511
5737
  responseTimeMs: attemptCompletedAt - requestStart,
5512
5738
  attemptDurationMs: extra?.attemptDurationMs ?? attemptCompletedAt - attemptStartedAt,
@@ -5862,33 +6088,85 @@ async function fetchAnthropicAccountResponse(args) {
5862
6088
  function shouldAttemptClaudeFallback(loopState) {
5863
6089
  return loopState.invalidRequestFailure === null;
5864
6090
  }
6091
+ function buildDeferredClaudeAccountFailureResponse(args) {
6092
+ const { ctx, tracer, requestStartTime, failure, buildLoggedClaudeError } = args;
6093
+ if (failure.responseHeaders) {
6094
+ ctx.responseHeaders = {
6095
+ ...(ctx.responseHeaders ?? {}),
6096
+ ...failure.responseHeaders,
6097
+ };
6098
+ }
6099
+ tracer?.setError(failure.errorType, failure.message);
6100
+ tracer?.end(failure.status, Date.now() - requestStartTime);
6101
+ return buildLoggedClaudeError(failure.status, failure.message, failure.errorType);
6102
+ }
5865
6103
  async function handleAnthropicRoutedClaudeRequest(args) {
5866
6104
  const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, logProxyBody, logFinalRequest, setRoutingDecision, } = args;
5867
6105
  const parsedRequest = parseClaudeRequest(body);
6106
+ const configuredFallbackPlan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, modelRouter?.getFallbackChain() ?? [], body.model, parsedRequest);
6107
+ const hasConfiguredFallback = configuredFallbackPlan.attempts
6108
+ .slice(1)
6109
+ .some((attempt) => Boolean(attempt.provider && attempt.model));
5868
6110
  const loadedAccounts = await loadClaudeProxyAccounts({
5869
6111
  ctx,
5870
6112
  body,
5871
- tracer,
5872
- requestStartTime,
5873
6113
  accountStrategy,
5874
6114
  primaryAccountKey,
5875
6115
  accountAllowlist,
5876
6116
  quotaRoutingEnabled,
5877
6117
  sessionSoftLimit,
5878
6118
  sessionResetToleranceMs,
5879
- buildLoggedClaudeError,
5880
6119
  setRoutingDecision,
5881
6120
  });
5882
- if ("response" in loadedAccounts) {
6121
+ if ("failure" in loadedAccounts) {
5883
6122
  // No usable local account. A node that has none of its own — or whose only
5884
6123
  // accounts are disabled — is still entitled to borrow: that is the whole
5885
- // point of being lent capacity. Peers are tried before the credentials
5886
- // error is returned, and the error stands if none of them serves.
6124
+ // point of being lent capacity. Peers and explicitly configured fallbacks
6125
+ // are tried before returning the credential error.
5887
6126
  const peerOnlyResult = await tryBorrowFromPeers({ body, logFinalRequest });
5888
6127
  if (peerOnlyResult) {
5889
6128
  return peerOnlyResult;
5890
6129
  }
5891
- return loadedAccounts.response;
6130
+ const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
6131
+ ctx,
6132
+ body,
6133
+ parsedFallbackRequest: parsedRequest,
6134
+ fallbackPlan: configuredFallbackPlan,
6135
+ modelRouter,
6136
+ tracer,
6137
+ requestStartTime,
6138
+ logProxyBody,
6139
+ logFinalRequest,
6140
+ });
6141
+ if (configuredFallbackResult.response) {
6142
+ return configuredFallbackResult.response;
6143
+ }
6144
+ if (configuredFallbackResult.invalidRequestFailure) {
6145
+ return buildClaudeAnthropicFailureResponse({
6146
+ tracer,
6147
+ requestStartTime,
6148
+ authFailureMessage: null,
6149
+ authCooldownMessage: null,
6150
+ invalidRequestFailure: configuredFallbackResult.invalidRequestFailure,
6151
+ entitlementFailure: null,
6152
+ scopedExhaustion: null,
6153
+ sawNetworkError: false,
6154
+ sawTransientFailure: false,
6155
+ sawRateLimit: false,
6156
+ lastError: undefined,
6157
+ orderedAccounts: [],
6158
+ buildLoggedClaudeError,
6159
+ logProxyBody,
6160
+ logFinalRequest,
6161
+ });
6162
+ }
6163
+ return buildDeferredClaudeAccountFailureResponse({
6164
+ ctx,
6165
+ tracer,
6166
+ requestStartTime,
6167
+ failure: loadedAccounts.failure,
6168
+ buildLoggedClaudeError,
6169
+ });
5892
6170
  }
5893
6171
  const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, } = loadedAccounts;
5894
6172
  // Snapshot the operator policy once. Reading the module value later would let
@@ -6107,7 +6385,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6107
6385
  // Refresh the account's quota snapshot for proactive selection.
6108
6386
  if (fetchResult.quota) {
6109
6387
  accountState.quota = mergeQuotaSnapshot(accountState.quota, fetchResult.quota);
6110
- saveAccountQuota(account.label, fetchResult.quota).catch(() => {
6388
+ saveAccountQuota(account.key, fetchResult.quota).catch(() => {
6111
6389
  // Non-fatal: routing already has the in-memory snapshot.
6112
6390
  });
6113
6391
  }
@@ -6244,6 +6522,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6244
6522
  sawTransientFailure: loopState.sawTransientFailure,
6245
6523
  invalidRequestFailure: loopState.invalidRequestFailure,
6246
6524
  entitlementFailure: loopState.entitlementFailure,
6525
+ allowConfiguredModelFallback: hasConfiguredFallback,
6247
6526
  });
6248
6527
  loopState.lastError = nonOkResult.lastError;
6249
6528
  loopState.authFailureMessage = nonOkResult.authFailureMessage;
@@ -6348,6 +6627,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6348
6627
  ctx,
6349
6628
  body,
6350
6629
  parsedFallbackRequest: parsedRequest,
6630
+ fallbackPlan: configuredFallbackPlan,
6351
6631
  modelRouter,
6352
6632
  tracer,
6353
6633
  requestStartTime,
@@ -6357,10 +6637,20 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6357
6637
  if (configuredFallbackResult.response) {
6358
6638
  return configuredFallbackResult.response;
6359
6639
  }
6640
+ if (configuredFallbackResult.invalidRequestFailure &&
6641
+ !loopState.sawRateLimit) {
6642
+ // A converted Codex request can be rejected independently of the original
6643
+ // Anthropic request. Preserve a real pool 429 as the actionable terminal
6644
+ // response when both occurred.
6645
+ loopState.invalidRequestFailure =
6646
+ configuredFallbackResult.invalidRequestFailure;
6647
+ }
6360
6648
  fallbackFailureMessage = configuredFallbackResult.lastErrorMessage;
6361
6649
  // A translation-layer-selected provider is only permitted by an explicit
6362
6650
  // routing setting. Empty fallback chains otherwise stay within OAuth.
6363
- if (!loopState.sawRateLimit && modelRouter?.isAutoFallbackEnabled?.()) {
6651
+ if (loopState.invalidRequestFailure === null &&
6652
+ !loopState.sawRateLimit &&
6653
+ modelRouter?.isAutoFallbackEnabled?.()) {
6364
6654
  const autoFallbackResult = await tryAutoClaudeFallback({
6365
6655
  ctx,
6366
6656
  body,
@@ -7538,6 +7828,18 @@ export function isInvalidRequestError(status, errBody) {
7538
7828
  return (parsed.errorType === "invalid_request_error" ||
7539
7829
  errBody.includes("invalid_request_error"));
7540
7830
  }
7831
+ /**
7832
+ * A 404 for a retired model can be served by an explicitly configured fallback;
7833
+ * other 404s remain terminal so a bad endpoint or resource is never disguised.
7834
+ */
7835
+ function isAnthropicModelNotFound(status, errBody) {
7836
+ if (status !== 404) {
7837
+ return false;
7838
+ }
7839
+ const parsed = parseClaudeErrorBody(errBody);
7840
+ return (parsed.errorType === "not_found_error" &&
7841
+ (parsed.message ?? "").toLowerCase().includes("model"));
7842
+ }
7541
7843
  /**
7542
7844
  * A subscription-specific beta rejection. Anthropic returns
7543
7845
  * `400 invalid_request_error` with a message like
@@ -7764,7 +8066,10 @@ export const __testHooks = {
7764
8066
  redactProviderErrorMessage,
7765
8067
  isUpstreamOverload,
7766
8068
  getOverloadRotationDelayMs,
8069
+ redactHeadersForBorrower,
7767
8070
  shouldAttemptClaudeFallback,
8071
+ isAnthropicModelNotFound,
8072
+ getCodexFallbackInvalidRequestFailure,
7768
8073
  executeClaudeFallbackWithRetry,
7769
8074
  buildClaudeAnthropicFailureResponse,
7770
8075
  isAccountEntitlementError,