@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.
- package/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +422 -414
- package/dist/cli/commands/auth.js +252 -125
- package/dist/cli/commands/proxy.js +115 -75
- package/dist/cli/factories/authCommandFactory.js +8 -11
- package/dist/proxy/codexAccountUsage.d.ts +5 -0
- package/dist/proxy/codexAccountUsage.js +30 -0
- package/dist/proxy/codexFallback.d.ts +26 -0
- package/dist/proxy/codexFallback.js +371 -0
- package/dist/proxy/proxyActivity.js +12 -1
- package/dist/proxy/usageStats.d.ts +2 -2
- package/dist/proxy/usageStats.js +66 -10
- package/dist/server/routes/claudeProxyRoutes.d.ts +25 -2
- package/dist/server/routes/claudeProxyRoutes.js +413 -108
- package/dist/server/routes/codexProxyRoutes.d.ts +10 -2
- package/dist/server/routes/codexProxyRoutes.js +192 -59
- package/dist/types/claudeProxy.d.ts +16 -0
- package/dist/types/claudeProxy.js +4 -0
- package/dist/types/cli.d.ts +37 -5
- package/dist/types/codex.d.ts +62 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +37 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
654
|
-
|
|
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.
|
|
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 ??
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
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
|
|
3492
|
-
|
|
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
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
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
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 ("
|
|
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
|
|
5886
|
-
//
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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,
|