@juspay/neurolink 12.7.1 → 12.7.3

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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -2535,20 +2537,14 @@ async function applyShareAccountGates(args) {
2535
2537
  grant,
2536
2538
  retryAfterSeconds: earliestShareRecoverySeconds(views, now),
2537
2539
  });
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
2540
  logger.always(`[proxy] share ${share.peerLabel} withheld: ${reason} (${decision.excluded.length} accounts)`);
2546
2541
  return {
2547
2542
  accounts: [],
2548
2543
  refusal: {
2549
- response: args.buildLoggedClaudeError(refusal.status, refusal.body.error.message, refusal.body.error.type),
2550
2544
  status: refusal.status,
2551
2545
  message: refusal.body.error.message,
2546
+ errorType: refusal.body.error.type,
2547
+ responseHeaders: refusal.headers,
2552
2548
  },
2553
2549
  };
2554
2550
  }
@@ -3009,7 +3005,7 @@ function earliestShareRecoverySeconds(views, now) {
3009
3005
  return Math.max(1, Math.round((Math.min(...resets) - now) / 1000));
3010
3006
  }
3011
3007
  async function loadClaudeProxyAccounts(args) {
3012
- const { ctx, body, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, setRoutingDecision, } = args;
3008
+ const { ctx, body, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), setRoutingDecision, } = args;
3013
3009
  const fs = await import("fs");
3014
3010
  const os = await import("os");
3015
3011
  const accounts = [];
@@ -3176,10 +3172,12 @@ async function loadClaudeProxyAccounts(args) {
3176
3172
  message: "OAuth authentication is not allowed for this organization.",
3177
3173
  errorCode: "oauth_not_allowed_for_organization",
3178
3174
  });
3179
- tracer?.setError("permission_error", entitlementMessage);
3180
- tracer?.end(403, Date.now() - requestStartTime);
3181
3175
  return {
3182
- response: buildLoggedClaudeError(403, entitlementMessage, "permission_error"),
3176
+ failure: {
3177
+ status: 403,
3178
+ message: entitlementMessage,
3179
+ errorType: "permission_error",
3180
+ },
3183
3181
  };
3184
3182
  }
3185
3183
  const noCredentialsMessage = accountAllowlist
@@ -3187,10 +3185,12 @@ async function loadClaudeProxyAccounts(args) {
3187
3185
  : compoundKeys.length > 0
3188
3186
  ? "Configured Anthropic accounts are disabled or unavailable"
3189
3187
  : "No Anthropic credentials found";
3190
- tracer?.setError("authentication_error", noCredentialsMessage);
3191
- tracer?.end(401, Date.now() - requestStartTime);
3192
3188
  return {
3193
- response: buildLoggedClaudeError(401, noCredentialsMessage),
3189
+ failure: {
3190
+ status: 401,
3191
+ message: noCredentialsMessage,
3192
+ errorType: "authentication_error",
3193
+ },
3194
3194
  };
3195
3195
  }
3196
3196
  for (const account of accounts) {
@@ -3228,28 +3228,30 @@ async function loadClaudeProxyAccounts(args) {
3228
3228
  `Ask the lender to widen the share, or use a model it allows.`
3229
3229
  : `Borrowed account(s) are no longer covered by a lease: ${detail}. ` +
3230
3230
  `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
3231
  return {
3234
- response: buildLoggedClaudeError(403, leaseMessage, "permission_error"),
3232
+ failure: {
3233
+ status: 403,
3234
+ message: leaseMessage,
3235
+ errorType: "permission_error",
3236
+ },
3235
3237
  };
3236
3238
  }
3237
3239
  const shareFiltered = await applyShareAccountGates({
3238
3240
  accounts: leasedAccounts,
3239
- ctx,
3240
- buildLoggedClaudeError,
3241
3241
  });
3242
3242
  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 };
3243
+ return { failure: shareFiltered.refusal };
3246
3244
  }
3247
3245
  const enabledAccounts = shareFiltered.accounts;
3248
3246
  if (enabledAccounts.length === 0) {
3249
3247
  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) };
3248
+ return {
3249
+ failure: {
3250
+ status: 401,
3251
+ message: reauthMsg,
3252
+ errorType: "authentication_error",
3253
+ },
3254
+ };
3253
3255
  }
3254
3256
  const { orderedAccounts, metricsByKey } = selectClaudeProxyAccountOrder({
3255
3257
  enabledAccounts,
@@ -3430,6 +3432,121 @@ async function executeClaudeFallbackWithRetry(args) {
3430
3432
  }
3431
3433
  throw lastError;
3432
3434
  }
3435
+ /**
3436
+ * Run the configured `codex` fallback through the native pooled Codex route.
3437
+ *
3438
+ * The inner response is fully buffered and validated before this function
3439
+ * creates a single Claude frame. That preserves the proxy's no-replay-after-
3440
+ * output guarantee when Codex returns an incomplete stream.
3441
+ */
3442
+ async function executeClaudeCodexFallback(args) {
3443
+ const { ctx, body, model, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
3444
+ const codexCtx = {
3445
+ ...ctx,
3446
+ requestId: `${ctx.requestId}:codex-fallback`,
3447
+ method: "POST",
3448
+ path: "/backend-api/codex/responses",
3449
+ headers: {
3450
+ "content-type": "application/json",
3451
+ accept: "text/event-stream",
3452
+ },
3453
+ query: {},
3454
+ params: {},
3455
+ body: convertClaudeRequestToCodex(body, model),
3456
+ // Keep the child attribution isolated until its stream has passed
3457
+ // validation. A failed Codex attempt must not look like a served request.
3458
+ responseHeaders: {},
3459
+ };
3460
+ const codexResponse = await handleCodexResponsesRequest(codexCtx);
3461
+ const codexHeaders = { ...(codexCtx.responseHeaders ?? {}) };
3462
+ let parsed;
3463
+ try {
3464
+ parsed = await consumeCodexFallbackResponse(codexResponse);
3465
+ }
3466
+ catch (error) {
3467
+ if (error instanceof CodexFallbackResponseError) {
3468
+ logger.always(`[proxy] Codex fallback returned ${error.status}: ${sanitizeForLog(error.responseBody, 500)}`);
3469
+ }
3470
+ throw error;
3471
+ }
3472
+ if (Object.keys(codexHeaders).length > 0) {
3473
+ ctx.responseHeaders ??= {};
3474
+ Object.assign(ctx.responseHeaders, redactHeadersForBorrower(codexHeaders));
3475
+ }
3476
+ const accountLabel = codexHeaders["x-neurolink-account"] ?? "";
3477
+ const accountType = codexHeaders["x-neurolink-account-type"] ?? "codex-oauth";
3478
+ const internal = {
3479
+ content: parsed.text,
3480
+ // Keep the original Anthropic model in the client wire response. The
3481
+ // attribution headers above expose that Codex served the fallback.
3482
+ model: body.model,
3483
+ finishReason: parsed.finishReason,
3484
+ ...(parsed.usage ? { usage: parsed.usage } : {}),
3485
+ toolCalls: parsed.toolCalls,
3486
+ };
3487
+ if (body.stream) {
3488
+ const serializer = new ClaudeStreamSerializer(body.model, parsed.usage?.input ?? 0);
3489
+ const frames = [];
3490
+ for (const frame of serializer.start()) {
3491
+ frames.push(frame);
3492
+ }
3493
+ if (parsed.text) {
3494
+ for (const frame of serializer.pushDelta(parsed.text)) {
3495
+ frames.push(frame);
3496
+ }
3497
+ }
3498
+ for (const toolCall of parsed.toolCalls) {
3499
+ for (const frame of serializer.pushToolUse(generateToolUseId(), toolCall.toolName, toolCall.args)) {
3500
+ frames.push(frame);
3501
+ }
3502
+ }
3503
+ for (const frame of serializer.finish(parsed.usage?.output, parsed.finishReason)) {
3504
+ frames.push(frame);
3505
+ }
3506
+ tracer?.end(200, Date.now() - requestStartTime);
3507
+ logFinalRequest(200, accountLabel, accountType, undefined, undefined, {
3508
+ inputTokens: parsed.usage?.input,
3509
+ outputTokens: parsed.usage?.output,
3510
+ cacheCreationTokens: parsed.usage?.cacheCreationTokens,
3511
+ cacheReadTokens: parsed.usage?.cacheReadTokens,
3512
+ });
3513
+ const bufferedBody = frames.join("");
3514
+ logProxyBody({
3515
+ phase: "client_response",
3516
+ headers: { "content-type": "text/event-stream" },
3517
+ body: bufferedBody,
3518
+ bodySize: Buffer.byteLength(bufferedBody, "utf8"),
3519
+ contentType: "text/event-stream",
3520
+ responseStatus: 200,
3521
+ durationMs: Date.now() - requestStartTime,
3522
+ });
3523
+ async function* sseGenerator() {
3524
+ for (const frame of frames) {
3525
+ yield frame;
3526
+ }
3527
+ }
3528
+ return sseGenerator();
3529
+ }
3530
+ tracer?.end(200, Date.now() - requestStartTime);
3531
+ const clientResponse = serializeClaudeResponse(internal, body.model);
3532
+ logFinalRequest(200, accountLabel, accountType, undefined, undefined, {
3533
+ inputTokens: parsed.usage?.input,
3534
+ outputTokens: parsed.usage?.output,
3535
+ cacheCreationTokens: parsed.usage?.cacheCreationTokens,
3536
+ cacheReadTokens: parsed.usage?.cacheReadTokens,
3537
+ });
3538
+ const clientResponseText = JSON.stringify(clientResponse);
3539
+ logProxyBody({
3540
+ phase: "client_response",
3541
+ headers: { "content-type": "application/json" },
3542
+ body: clientResponseText,
3543
+ bodySize: Buffer.byteLength(clientResponseText, "utf8"),
3544
+ contentType: "application/json",
3545
+ responseStatus: 200,
3546
+ durationMs: Date.now() - requestStartTime,
3547
+ });
3548
+ return clientResponse;
3549
+ }
3433
3550
  /**
3434
3551
  * Try each borrowable peer in priority order once the local pool is spent.
3435
3552
  *
@@ -3486,10 +3603,20 @@ async function tryBorrowFromPeers(args) {
3486
3603
  }
3487
3604
  return null;
3488
3605
  }
3606
+ function getCodexFallbackInvalidRequestFailure(error) {
3607
+ if (!(error instanceof CodexFallbackResponseError) || error.status !== 400) {
3608
+ return null;
3609
+ }
3610
+ return {
3611
+ status: error.status,
3612
+ body: error.responseBody,
3613
+ contentType: "application/json",
3614
+ };
3615
+ }
3489
3616
  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);
3617
+ const { ctx, body, parsedFallbackRequest, fallbackPlan: providedFallbackPlan, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
3618
+ const fallbackPlan = providedFallbackPlan ??
3619
+ buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, modelRouter?.getFallbackChain() ?? [], body.model, parsedFallbackRequest);
3493
3620
  logProxyBody({
3494
3621
  phase: "routing_decision",
3495
3622
  contentType: "application/json",
@@ -3504,41 +3631,58 @@ async function tryConfiguredClaudeFallbackChain(args) {
3504
3631
  reason: "all_anthropic_accounts_exhausted",
3505
3632
  });
3506
3633
  let lastFallbackError;
3634
+ let invalidRequestFailure;
3507
3635
  for (const fallback of fallbackPlan.attempts.slice(1)) {
3508
3636
  if (!fallback.provider || !fallback.model) {
3509
3637
  continue;
3510
3638
  }
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
3639
  const fallbackStart = Date.now();
3526
3640
  try {
3527
3641
  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
- });
3642
+ let response;
3643
+ if (fallback.provider === "codex") {
3644
+ // Codex is a local OAuth account pool, not a generic SDK provider.
3645
+ // Calling its native route preserves account rotation and cooldowns.
3646
+ response = await executeClaudeCodexFallback({
3647
+ ctx,
3648
+ body,
3649
+ model: fallback.model,
3650
+ tracer,
3651
+ requestStartTime,
3652
+ logProxyBody,
3653
+ logFinalRequest,
3654
+ });
3655
+ }
3656
+ else {
3657
+ const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(fallback.provider, fallback.model);
3658
+ if (!availability.available) {
3659
+ const reason = availability.reason ?? "provider unavailable";
3660
+ logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} health-check failed (${reason}), skipping`);
3661
+ recordFallbackAttempt({
3662
+ provider: fallback.provider,
3663
+ model: fallback.model,
3664
+ status: "failure",
3665
+ errorMessage: `[unavailable] ${reason}`,
3666
+ durationMs: 0,
3667
+ });
3668
+ lastFallbackError = `[${fallback.provider}/${fallback.model}] unavailable: ${reason}`;
3669
+ continue;
3670
+ }
3671
+ const options = buildProxyFallbackOptions(parsedFallbackRequest, {
3672
+ provider: fallback.provider,
3673
+ model: fallback.model,
3674
+ });
3675
+ response = await executeClaudeFallbackWithRetry({
3676
+ ctx,
3677
+ body,
3678
+ tracer,
3679
+ requestStartTime,
3680
+ logProxyBody,
3681
+ logFinalRequest,
3682
+ options: options,
3683
+ providerLabel: fallback.provider,
3684
+ });
3685
+ }
3542
3686
  recordFallbackAttempt({
3543
3687
  provider: fallback.provider,
3544
3688
  model: fallback.model,
@@ -3552,17 +3696,21 @@ async function tryConfiguredClaudeFallbackChain(args) {
3552
3696
  attemptCount: fallbackPlan.attempts.slice(1).length,
3553
3697
  reason: "fallback_success",
3554
3698
  });
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
- });
3699
+ if (fallback.provider !== "codex") {
3700
+ // A different provider produced this response say so, and report no
3701
+ // quota. Emitting the last Anthropic snapshot here would attribute one
3702
+ // provider's capacity to another's output.
3703
+ publishLimitHeaders(ctx, {
3704
+ quota: null,
3705
+ source: "none",
3706
+ servedBy: fallback.provider,
3707
+ });
3708
+ }
3563
3709
  return { response };
3564
3710
  }
3565
3711
  catch (fallbackErr) {
3712
+ invalidRequestFailure ??=
3713
+ getCodexFallbackInvalidRequestFailure(fallbackErr) ?? undefined;
3566
3714
  const errMsg = redactProviderErrorMessage(fallbackErr instanceof Error
3567
3715
  ? fallbackErr.message
3568
3716
  : String(fallbackErr));
@@ -3598,7 +3746,11 @@ async function tryConfiguredClaudeFallbackChain(args) {
3598
3746
  lastFallbackError = `[${fallback.provider}/${fallback.model}] ${redactProviderErrorMessage(describeTransportError(fallbackErr))}`;
3599
3747
  }
3600
3748
  }
3601
- return { response: null, lastErrorMessage: lastFallbackError };
3749
+ return {
3750
+ response: null,
3751
+ lastErrorMessage: lastFallbackError,
3752
+ ...(invalidRequestFailure ? { invalidRequestFailure } : {}),
3753
+ };
3602
3754
  }
3603
3755
  async function tryAutoClaudeFallback(args) {
3604
3756
  const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest } = args;
@@ -3769,28 +3921,32 @@ function buildClaudeAnthropicFailureResponse(args) {
3769
3921
  tracer?.end(503, Date.now() - requestStartTime);
3770
3922
  return buildLoggedClaudeError(503, authCooldownMessage, "token_refresh_unavailable");
3771
3923
  }
3772
- if (invalidRequestFailure) {
3773
- tracer?.setError("invalid_request_error", summarizeErrorMessage(invalidRequestFailure.body));
3924
+ if (invalidRequestFailure && !sawRateLimit) {
3925
+ const parsedUpstream = parseClaudeErrorBody(invalidRequestFailure.body);
3926
+ const preserveUpstreamBody = parsedUpstream.message !== undefined;
3927
+ const message = summarizeErrorMessage(parsedUpstream.message ?? invalidRequestFailure.body);
3928
+ const errorBodyText = preserveUpstreamBody
3929
+ ? invalidRequestFailure.body
3930
+ : JSON.stringify(buildClaudeError(invalidRequestFailure.status, message, "invalid_request_error"));
3931
+ const contentType = preserveUpstreamBody
3932
+ ? (invalidRequestFailure.contentType ?? "application/json")
3933
+ : "application/json";
3934
+ tracer?.setError("invalid_request_error", message);
3774
3935
  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
- }
3936
+ logFinalRequest(invalidRequestFailure.status, "", "final", "invalid_request_error", message);
3937
+ logProxyBody({
3938
+ phase: "client_response",
3939
+ headers: { "content-type": contentType },
3940
+ body: errorBodyText,
3941
+ bodySize: Buffer.byteLength(errorBodyText, "utf8"),
3942
+ contentType,
3943
+ responseStatus: invalidRequestFailure.status,
3944
+ durationMs: Date.now() - requestStartTime,
3945
+ });
3946
+ return new Response(errorBodyText, {
3947
+ status: invalidRequestFailure.status,
3948
+ headers: { "content-type": contentType },
3949
+ });
3794
3950
  }
3795
3951
  if ((sawNetworkError || sawTransientFailure) && !sawRateLimit) {
3796
3952
  const fallbackSuffix = fallbackFailureMessage
@@ -5085,7 +5241,7 @@ function finalizeAnthropicTerminalTransportError(args) {
5085
5241
  return clientError;
5086
5242
  }
5087
5243
  async function handleAnthropicNonOkResponse(args) {
5088
- const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, } = args;
5244
+ const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, allowConfiguredModelFallback = false, } = args;
5089
5245
  let currentLastError = lastError;
5090
5246
  let currentAuthFailureMessage = authFailureMessage;
5091
5247
  let currentSawTransientFailure = sawTransientFailure;
@@ -5257,6 +5413,26 @@ async function handleAnthropicNonOkResponse(args) {
5257
5413
  };
5258
5414
  }
5259
5415
  if (response.status === 404) {
5416
+ if (allowConfiguredModelFallback &&
5417
+ isAnthropicModelNotFound(response.status, errBody)) {
5418
+ // An upstream model retirement is provider-wide, not an account failure.
5419
+ // Do not cool or disable an account; leave the configured translation
5420
+ // fallback eligible so legacy Anthropic aliases can use the Codex target.
5421
+ logger.always(`[proxy] ← 404 account=${account.label} model unavailable; trying configured fallback`);
5422
+ logAttempt(404, "not_found_error", summarizeErrorMessage(errBody));
5423
+ tracer?.setError("not_found_error", summarizeErrorMessage(errBody));
5424
+ tracer?.recordRetry(account.label, "model_not_found");
5425
+ currentLastError = summarizeErrorMessage(errBody);
5426
+ return {
5427
+ continueLoop: false,
5428
+ lastError: currentLastError,
5429
+ authFailureMessage: currentAuthFailureMessage,
5430
+ sawTransientFailure: currentSawTransientFailure,
5431
+ invalidRequestFailure: currentInvalidRequestFailure,
5432
+ entitlementFailure: currentEntitlementFailure,
5433
+ upstreamSpan: undefined,
5434
+ };
5435
+ }
5260
5436
  logger.always(`[proxy] ← 404 account=${account.label}`);
5261
5437
  logAttempt(404, "not_found_error", summarizeErrorMessage(errBody));
5262
5438
  tracer?.setError("not_found_error", summarizeErrorMessage(errBody));
@@ -5862,33 +6038,85 @@ async function fetchAnthropicAccountResponse(args) {
5862
6038
  function shouldAttemptClaudeFallback(loopState) {
5863
6039
  return loopState.invalidRequestFailure === null;
5864
6040
  }
6041
+ function buildDeferredClaudeAccountFailureResponse(args) {
6042
+ const { ctx, tracer, requestStartTime, failure, buildLoggedClaudeError } = args;
6043
+ if (failure.responseHeaders) {
6044
+ ctx.responseHeaders = {
6045
+ ...(ctx.responseHeaders ?? {}),
6046
+ ...failure.responseHeaders,
6047
+ };
6048
+ }
6049
+ tracer?.setError(failure.errorType, failure.message);
6050
+ tracer?.end(failure.status, Date.now() - requestStartTime);
6051
+ return buildLoggedClaudeError(failure.status, failure.message, failure.errorType);
6052
+ }
5865
6053
  async function handleAnthropicRoutedClaudeRequest(args) {
5866
6054
  const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, logProxyBody, logFinalRequest, setRoutingDecision, } = args;
5867
6055
  const parsedRequest = parseClaudeRequest(body);
6056
+ const configuredFallbackPlan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, modelRouter?.getFallbackChain() ?? [], body.model, parsedRequest);
6057
+ const hasConfiguredFallback = configuredFallbackPlan.attempts
6058
+ .slice(1)
6059
+ .some((attempt) => Boolean(attempt.provider && attempt.model));
5868
6060
  const loadedAccounts = await loadClaudeProxyAccounts({
5869
6061
  ctx,
5870
6062
  body,
5871
- tracer,
5872
- requestStartTime,
5873
6063
  accountStrategy,
5874
6064
  primaryAccountKey,
5875
6065
  accountAllowlist,
5876
6066
  quotaRoutingEnabled,
5877
6067
  sessionSoftLimit,
5878
6068
  sessionResetToleranceMs,
5879
- buildLoggedClaudeError,
5880
6069
  setRoutingDecision,
5881
6070
  });
5882
- if ("response" in loadedAccounts) {
6071
+ if ("failure" in loadedAccounts) {
5883
6072
  // No usable local account. A node that has none of its own — or whose only
5884
6073
  // 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.
6074
+ // point of being lent capacity. Peers and explicitly configured fallbacks
6075
+ // are tried before returning the credential error.
5887
6076
  const peerOnlyResult = await tryBorrowFromPeers({ body, logFinalRequest });
5888
6077
  if (peerOnlyResult) {
5889
6078
  return peerOnlyResult;
5890
6079
  }
5891
- return loadedAccounts.response;
6080
+ const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
6081
+ ctx,
6082
+ body,
6083
+ parsedFallbackRequest: parsedRequest,
6084
+ fallbackPlan: configuredFallbackPlan,
6085
+ modelRouter,
6086
+ tracer,
6087
+ requestStartTime,
6088
+ logProxyBody,
6089
+ logFinalRequest,
6090
+ });
6091
+ if (configuredFallbackResult.response) {
6092
+ return configuredFallbackResult.response;
6093
+ }
6094
+ if (configuredFallbackResult.invalidRequestFailure) {
6095
+ return buildClaudeAnthropicFailureResponse({
6096
+ tracer,
6097
+ requestStartTime,
6098
+ authFailureMessage: null,
6099
+ authCooldownMessage: null,
6100
+ invalidRequestFailure: configuredFallbackResult.invalidRequestFailure,
6101
+ entitlementFailure: null,
6102
+ scopedExhaustion: null,
6103
+ sawNetworkError: false,
6104
+ sawTransientFailure: false,
6105
+ sawRateLimit: false,
6106
+ lastError: undefined,
6107
+ orderedAccounts: [],
6108
+ buildLoggedClaudeError,
6109
+ logProxyBody,
6110
+ logFinalRequest,
6111
+ });
6112
+ }
6113
+ return buildDeferredClaudeAccountFailureResponse({
6114
+ ctx,
6115
+ tracer,
6116
+ requestStartTime,
6117
+ failure: loadedAccounts.failure,
6118
+ buildLoggedClaudeError,
6119
+ });
5892
6120
  }
5893
6121
  const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, } = loadedAccounts;
5894
6122
  // Snapshot the operator policy once. Reading the module value later would let
@@ -6244,6 +6472,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6244
6472
  sawTransientFailure: loopState.sawTransientFailure,
6245
6473
  invalidRequestFailure: loopState.invalidRequestFailure,
6246
6474
  entitlementFailure: loopState.entitlementFailure,
6475
+ allowConfiguredModelFallback: hasConfiguredFallback,
6247
6476
  });
6248
6477
  loopState.lastError = nonOkResult.lastError;
6249
6478
  loopState.authFailureMessage = nonOkResult.authFailureMessage;
@@ -6348,6 +6577,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6348
6577
  ctx,
6349
6578
  body,
6350
6579
  parsedFallbackRequest: parsedRequest,
6580
+ fallbackPlan: configuredFallbackPlan,
6351
6581
  modelRouter,
6352
6582
  tracer,
6353
6583
  requestStartTime,
@@ -6357,10 +6587,20 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6357
6587
  if (configuredFallbackResult.response) {
6358
6588
  return configuredFallbackResult.response;
6359
6589
  }
6590
+ if (configuredFallbackResult.invalidRequestFailure &&
6591
+ !loopState.sawRateLimit) {
6592
+ // A converted Codex request can be rejected independently of the original
6593
+ // Anthropic request. Preserve a real pool 429 as the actionable terminal
6594
+ // response when both occurred.
6595
+ loopState.invalidRequestFailure =
6596
+ configuredFallbackResult.invalidRequestFailure;
6597
+ }
6360
6598
  fallbackFailureMessage = configuredFallbackResult.lastErrorMessage;
6361
6599
  // A translation-layer-selected provider is only permitted by an explicit
6362
6600
  // routing setting. Empty fallback chains otherwise stay within OAuth.
6363
- if (!loopState.sawRateLimit && modelRouter?.isAutoFallbackEnabled?.()) {
6601
+ if (loopState.invalidRequestFailure === null &&
6602
+ !loopState.sawRateLimit &&
6603
+ modelRouter?.isAutoFallbackEnabled?.()) {
6364
6604
  const autoFallbackResult = await tryAutoClaudeFallback({
6365
6605
  ctx,
6366
6606
  body,
@@ -7538,6 +7778,18 @@ export function isInvalidRequestError(status, errBody) {
7538
7778
  return (parsed.errorType === "invalid_request_error" ||
7539
7779
  errBody.includes("invalid_request_error"));
7540
7780
  }
7781
+ /**
7782
+ * A 404 for a retired model can be served by an explicitly configured fallback;
7783
+ * other 404s remain terminal so a bad endpoint or resource is never disguised.
7784
+ */
7785
+ function isAnthropicModelNotFound(status, errBody) {
7786
+ if (status !== 404) {
7787
+ return false;
7788
+ }
7789
+ const parsed = parseClaudeErrorBody(errBody);
7790
+ return (parsed.errorType === "not_found_error" &&
7791
+ (parsed.message ?? "").toLowerCase().includes("model"));
7792
+ }
7541
7793
  /**
7542
7794
  * A subscription-specific beta rejection. Anthropic returns
7543
7795
  * `400 invalid_request_error` with a message like
@@ -7764,7 +8016,10 @@ export const __testHooks = {
7764
8016
  redactProviderErrorMessage,
7765
8017
  isUpstreamOverload,
7766
8018
  getOverloadRotationDelayMs,
8019
+ redactHeadersForBorrower,
7767
8020
  shouldAttemptClaudeFallback,
8021
+ isAnthropicModelNotFound,
8022
+ getCodexFallbackInvalidRequestFailure,
7768
8023
  executeClaudeFallbackWithRetry,
7769
8024
  buildClaudeAnthropicFailureResponse,
7770
8025
  isAccountEntitlementError,
@@ -17,7 +17,7 @@
17
17
  * never collides with anthropic entries) and does pre-commit rotation only, not
18
18
  * the full transient-budget / admission machinery.
19
19
  */
20
- import type { AccountCoolingReason, AccountQuota, CodexRuntimeAccount, RouteGroup } from "../../types/index.js";
20
+ import type { AccountCoolingReason, AccountQuota, CodexRuntimeAccount, RouteGroup, ServerContext } from "../../types/index.js";
21
21
  /** Refresh an account's token at most once at a time. */
22
22
  declare function refreshCodexTokenOnce(key: string, refreshToken: string): Promise<{
23
23
  accessToken: string;
@@ -46,6 +46,8 @@ declare function planCodexCooldown(quota: AccountQuota | null, retryAfterMs: num
46
46
  coolingUntil: number;
47
47
  reason: AccountCoolingReason;
48
48
  };
49
+ /** Core pooled handler for POST /backend-api/codex/responses. */
50
+ export declare function handleCodexResponsesRequest(ctx: ServerContext): Promise<Response>;
49
51
  /**
50
52
  * Create Codex proxy routes.
51
53
  *