@juspay/neurolink 12.40.3 → 12.41.0
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 +3 -3
- package/dist/browser/neurolink.min.js +447 -443
- package/dist/cli/commands/proxy.js +5 -1
- package/dist/proxy/accountLedger.js +3 -0
- package/dist/proxy/codexOutboundCache.d.ts +52 -0
- package/dist/proxy/codexOutboundCache.js +147 -0
- package/dist/proxy/codexOutboundFallback.d.ts +27 -2
- package/dist/proxy/codexOutboundFallback.js +147 -11
- package/dist/proxy/codexResponsesFormat.js +7 -1
- package/dist/proxy/codexToAnthropicFallback.js +6 -0
- package/dist/proxy/proxyAnalysis.js +4 -0
- package/dist/proxy/proxyConfig.js +120 -0
- package/dist/proxy/proxyTokenUsage.d.ts +15 -0
- package/dist/proxy/proxyTokenUsage.js +33 -0
- package/dist/proxy/proxyTracer.d.ts +16 -1
- package/dist/proxy/proxyTracer.js +26 -5
- package/dist/proxy/requestLogger.js +9 -0
- package/dist/proxy/runtimeConfig.js +20 -0
- package/dist/proxy/sseInterceptor.js +9 -0
- package/dist/proxy/vertexAnthropicFallback.js +9 -0
- package/dist/server/routes/claudeProxyRoutes.js +65 -1
- package/dist/server/routes/codexProxyRoutes.d.ts +39 -3
- package/dist/server/routes/codexProxyRoutes.js +792 -8
- package/dist/server/routes/index.d.ts +8 -0
- package/dist/server/routes/index.js +9 -1
- package/dist/types/analytics.d.ts +25 -0
- package/dist/types/codex.d.ts +45 -1
- package/dist/types/proxy.d.ts +40 -2
- package/dist/types/proxyClient.d.ts +2 -0
- package/dist/types/server.d.ts +15 -0
- package/dist/types/subscription.d.ts +22 -0
- package/dist/utils/anthropicCacheBreakpoints.d.ts +2 -2
- package/dist/utils/anthropicCacheBreakpoints.js +14 -3
- package/dist/utils/pricing.js +101 -24
- package/docs-site/static/search-index.json +17 -6
- package/package.json +1 -1
|
@@ -24,18 +24,25 @@ import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.j
|
|
|
24
24
|
import { createCodexUsageTap } from "../../proxy/codexUsage.js";
|
|
25
25
|
import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
|
|
26
26
|
import { buildClientAttribution } from "../../proxy/clientAttribution.js";
|
|
27
|
-
import {
|
|
27
|
+
import { getProxyUpstreamFailure } from "../../proxy/proxyFailureDetails.js";
|
|
28
|
+
import { registerProxyResponseObserver, isProxyRequestFinalized, registerInternalProxyRequest, getProxyBridgeResult, releaseProxyRequestAccounting, } from "../../proxy/proxyActivity.js";
|
|
29
|
+
import { parseCodexNativeRequest, translateCodexRequestToClaude, classifyCodexOutboundFailure, buildSystemBlocksFromDeveloperMessages, } from "../../proxy/codexOutboundFallback.js";
|
|
30
|
+
import { createAnthropicFallbackStream } from "../../proxy/codexToAnthropicFallback.js";
|
|
31
|
+
import { executeVertexAnthropicFallback } from "../../proxy/vertexAnthropicFallback.js";
|
|
32
|
+
import { assertClaudeSystemPrefixShape, assertClaudeMessagesAlternate, codexAnthropicAffinityKey, } from "../../proxy/codexOutboundCache.js";
|
|
33
|
+
import { applyClaudeRequestCacheBreakpoints } from "../../utils/anthropicCacheBreakpoints.js";
|
|
28
34
|
import { prepareProxyRequestContext, ProxyContextPreflightError, } from "../../proxy/proxyContextPreflight.js";
|
|
29
35
|
import { reserveProxyTokenBudget, settleProxyTokenBudget, getProxyTokenBudgetError, getProxyTokenBudgetSessionKey, } from "../../proxy/proxyTokenBudget.js";
|
|
30
36
|
import { emitProxyOtelEvent } from "../../proxy/otelLogSink.js";
|
|
31
37
|
import { getRuntimeContextWindow, clearRuntimeOutputCeiling, registerRuntimeContextWindow, registerRuntimeOutputCeiling, } from "../../constants/contextWindows.js";
|
|
32
38
|
import { logRequest, logRequestAttempt, logBodyCapture, isProxyBodyCaptureEnabled, } from "../../proxy/requestLogger.js";
|
|
33
39
|
import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js";
|
|
34
|
-
import { resolveProxyLogTraceContext } from "../../proxy/proxyTraceContext.js";
|
|
40
|
+
import { resolveProxyLogTraceContext, getProxyRequestTraceContext, } from "../../proxy/proxyTraceContext.js";
|
|
35
41
|
import { isBorrowedRequest } from "../../proxy/shareContext.js";
|
|
36
|
-
import { ProxyTracer } from "../../proxy/proxyTracer.js";
|
|
42
|
+
import { ProxyTracer, recordFallbackAttempt } from "../../proxy/proxyTracer.js";
|
|
37
43
|
import { parseRetryAfterMs } from "../../proxy/routingPolicy.js";
|
|
38
44
|
import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
|
|
45
|
+
import { raceWithAbort } from "../../utils/async/withTimeout.js";
|
|
39
46
|
import { sanitizeForLog } from "../../utils/logSanitize.js";
|
|
40
47
|
import { logger } from "../../utils/logger.js";
|
|
41
48
|
const CODEX_UPSTREAM_TIMEOUT_MS = 15 * 60 * 1000; // 15 min, matches Claude path
|
|
@@ -377,8 +384,581 @@ function publishCodexHeaders(ctx, account, attempt, quota) {
|
|
|
377
384
|
ctx.responseHeaders["x-neurolink-weekly-left-pct"] = remaining;
|
|
378
385
|
}
|
|
379
386
|
}
|
|
387
|
+
// =============================================================================
|
|
388
|
+
// CODEX-OUTBOUND FALLBACK DISPATCH (stage-c-trigger.md). When the native
|
|
389
|
+
// Codex pool itself fails (no accounts, all cooling, a non-retryable
|
|
390
|
+
// transport error, or the account-retry loop exhausts without a terminal
|
|
391
|
+
// response), and runtime config has opted in, translate the inbound native
|
|
392
|
+
// Codex request to Anthropic wire format and serve it from the Anthropic
|
|
393
|
+
// OAuth pool (in-process loopback to this same proxy's `/v1/messages`
|
|
394
|
+
// handler) or Vertex's Claude passthrough, then translate the response back
|
|
395
|
+
// to Codex wire format so the native Codex CLI client never sees the
|
|
396
|
+
// difference.
|
|
397
|
+
//
|
|
398
|
+
// Owns dispatch/HTTP only. Classification lives in codexOutboundFallback.ts's
|
|
399
|
+
// `classifyCodexOutboundFailure` (that file's own header disclaims dispatch
|
|
400
|
+
// ownership); translation and the response codec live in
|
|
401
|
+
// codexOutboundFallback.ts and codexToAnthropicFallback.ts respectively.
|
|
402
|
+
// =============================================================================
|
|
403
|
+
/** Set once a real dispatch attempt begins (after every gate has passed,
|
|
404
|
+
* before any network call). A single flag — not a per-target counter —
|
|
405
|
+
* because MAX_ENGINE_CROSSINGS caps the whole request to one crossing
|
|
406
|
+
* regardless of which of the four call sites in `dispatch()` reaches this
|
|
407
|
+
* function, and insertion point 3 sits inside a loop that can call it more
|
|
408
|
+
* than once before insertion point 4 is ever reached. */
|
|
409
|
+
const CODEX_OUTBOUND_FALLBACK_ATTEMPTED_KEY = "neurolink.codexOutboundFallbackAttempted";
|
|
410
|
+
/** Bounds the Anthropic loopback until its response headers arrive. It is
|
|
411
|
+
* cleared once they do, so a long turn can stream past it. */
|
|
412
|
+
const CODEX_OUTBOUND_FALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
413
|
+
let codexOutboundFallbackTimeoutOverrideMs = null;
|
|
414
|
+
/** `CodexResponseUsage` -> the shared `UsageContext` shape `tracer.setUsage`
|
|
415
|
+
* takes. `cacheReadTokensObserved`/`cacheCreationTokensObserved` are false
|
|
416
|
+
* only when the field is genuinely absent (never conflated with an
|
|
417
|
+
* observed zero). `inputIncludesCachedTokens` is always true because
|
|
418
|
+
* `synthesizeCodexUsage` folds cache reads and cache writes into the Codex
|
|
419
|
+
* `input_tokens`, the same OpenAI-style total the native Codex path logs. */
|
|
420
|
+
function toUsageContext(usage) {
|
|
421
|
+
if (!usage) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const cacheRead = usage.input_tokens_details?.cached_tokens;
|
|
425
|
+
const cacheCreate = usage.input_tokens_details?.cache_write_tokens;
|
|
426
|
+
const cacheCreate1h = usage.input_tokens_details?.cache_write_1h_tokens;
|
|
427
|
+
return {
|
|
428
|
+
inputTokens: usage.input_tokens,
|
|
429
|
+
outputTokens: usage.output_tokens,
|
|
430
|
+
cacheReadTokens: cacheRead ?? 0,
|
|
431
|
+
cacheCreationTokens: cacheCreate ?? 0,
|
|
432
|
+
...(cacheCreate1h === undefined
|
|
433
|
+
? {}
|
|
434
|
+
: { cacheCreation1hTokens: cacheCreate1h }),
|
|
435
|
+
cacheReadTokensObserved: cacheRead !== undefined,
|
|
436
|
+
cacheCreationTokensObserved: cacheCreate !== undefined,
|
|
437
|
+
inputIncludesCachedTokens: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** Preserve absent cache fields in the persisted log while the tracer uses
|
|
441
|
+
* zero defaults for arithmetic. */
|
|
442
|
+
function outboundFallbackUsageLogFields(usage) {
|
|
443
|
+
return {
|
|
444
|
+
inputTokens: usage.inputTokens,
|
|
445
|
+
outputTokens: usage.outputTokens,
|
|
446
|
+
cacheReadTokensObserved: usage.cacheReadTokensObserved === true,
|
|
447
|
+
cacheCreationTokensObserved: usage.cacheCreationTokensObserved === true,
|
|
448
|
+
...(usage.cacheReadTokensObserved === true
|
|
449
|
+
? { cacheReadTokens: usage.cacheReadTokens }
|
|
450
|
+
: {}),
|
|
451
|
+
...(usage.cacheCreationTokensObserved === true
|
|
452
|
+
? {
|
|
453
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
454
|
+
...(usage.cacheCreation1hTokens !== undefined
|
|
455
|
+
? { cacheCreation1hTokens: usage.cacheCreation1hTokens }
|
|
456
|
+
: {}),
|
|
457
|
+
}
|
|
458
|
+
: {}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Drive a `CodexResponseStream`'s `frames` generator into a `Response` body,
|
|
463
|
+
* calling `onSettled` exactly once with the outcome: the terminal envelope on
|
|
464
|
+
* a normal finish, the thrown error on the (never expected, still handled)
|
|
465
|
+
* post-commit failure, or `cancelled` when the consumer cancels the
|
|
466
|
+
* `ReadableStream` first (client disconnect, or an upstream timeout/abort).
|
|
467
|
+
*
|
|
468
|
+
* `createAnthropicFallbackStream`'s generator never throws once past its own
|
|
469
|
+
* internal preflight (codexToAnthropicFallback.ts:220-517) — every
|
|
470
|
+
* post-commit failure is instead serialized as a `response.failed` frame and
|
|
471
|
+
* the generator completes normally — so the `catch` branch here is a
|
|
472
|
+
* defensive backstop, not the documented failure path.
|
|
473
|
+
*/
|
|
474
|
+
function wrapCodexOutboundResponseStream(codecStream, headers, onSettled) {
|
|
475
|
+
let settled = false;
|
|
476
|
+
const settleOnce = (result) => {
|
|
477
|
+
if (settled) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
settled = true;
|
|
481
|
+
onSettled(result);
|
|
482
|
+
};
|
|
483
|
+
const encoder = new TextEncoder();
|
|
484
|
+
// One frame per pull(), so a slow client holds the upstream back instead of
|
|
485
|
+
// buffering the whole turn. `cancelled` is set before the codec is
|
|
486
|
+
// cancelled: a read that settles after a disconnect must not be recorded
|
|
487
|
+
// as a stream error.
|
|
488
|
+
let cancelled = false;
|
|
489
|
+
const body = new ReadableStream({
|
|
490
|
+
async pull(controller) {
|
|
491
|
+
try {
|
|
492
|
+
const next = await codecStream.frames.next();
|
|
493
|
+
if (cancelled) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (next.done === true) {
|
|
497
|
+
controller.close();
|
|
498
|
+
settleOnce({ kind: "completed", envelope: next.value });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
controller.enqueue(encoder.encode(next.value));
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (cancelled) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
controller.error(error);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Already closed or errored.
|
|
512
|
+
}
|
|
513
|
+
settleOnce({ kind: "error", error });
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
cancel(reason) {
|
|
517
|
+
cancelled = true;
|
|
518
|
+
return codecStream.cancel(reason).finally(() => {
|
|
519
|
+
settleOnce({ kind: "cancelled" });
|
|
520
|
+
});
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
return new Response(body, { status: 200, headers });
|
|
524
|
+
}
|
|
525
|
+
/** Set the response headers for a served-outbound-fallback turn. Mirrors
|
|
526
|
+
* `publishCodexHeaders`'s use of `ctx.responseHeaders`, direction-prefixed
|
|
527
|
+
* so it is never confused with a native Codex-pool response. */
|
|
528
|
+
function buildCodexOutboundFallbackResponseHeaders(ctx, provider) {
|
|
529
|
+
if (!ctx.responseHeaders) {
|
|
530
|
+
ctx.responseHeaders = {};
|
|
531
|
+
}
|
|
532
|
+
ctx.responseHeaders["x-neurolink-served-by"] =
|
|
533
|
+
`codex-outbound-fallback:${provider}`;
|
|
534
|
+
return {
|
|
535
|
+
"content-type": "text/event-stream",
|
|
536
|
+
"cache-control": "no-cache",
|
|
537
|
+
connection: "keep-alive",
|
|
538
|
+
...ctx.responseHeaders,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
/** Forward headers for the loopback `/v1/messages` call — the same allow-list
|
|
542
|
+
* `openaiProxyRoutes.ts`'s `buildBridgeHeaders` uses — plus the unforgeable
|
|
543
|
+
* internal-origin marker `claudeProxyRoutes.ts` cross-checks against a
|
|
544
|
+
* server-set accounting scope that no inbound request header can forge. */
|
|
545
|
+
function buildCodexOutboundLoopbackHeaders(ctx, token, affinityKey) {
|
|
546
|
+
const forwardHeaders = {
|
|
547
|
+
"content-type": "application/json",
|
|
548
|
+
accept: "text/event-stream",
|
|
549
|
+
"x-neurolink-internal-request": token,
|
|
550
|
+
"x-neurolink-internal-origin": "codex-outbound-fallback",
|
|
551
|
+
// Ruling 3: derive the Codex-side session-affinity key and forward it as
|
|
552
|
+
// the same header claudeProxyRoutes.ts already reads for a native Claude
|
|
553
|
+
// client's session id, so the existing (config-gated, off-by-default on
|
|
554
|
+
// the live proxy) session-affinity machinery can bind consecutive turns
|
|
555
|
+
// of the same Codex conversation to the same Anthropic account.
|
|
556
|
+
...(affinityKey ? { "x-claude-code-session-id": affinityKey } : {}),
|
|
557
|
+
};
|
|
558
|
+
const traceContext = getProxyRequestTraceContext(ctx.requestId);
|
|
559
|
+
if (traceContext) {
|
|
560
|
+
forwardHeaders.traceparent = `00-${traceContext.traceId}-${traceContext.spanId}-${traceContext.traceFlags.toString(16).padStart(2, "0")}`;
|
|
561
|
+
}
|
|
562
|
+
for (const [k, v] of Object.entries(ctx.headers)) {
|
|
563
|
+
if (typeof v !== "string") {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const lower = k.toLowerCase();
|
|
567
|
+
if (["user-agent", "tracestate", "baggage"].includes(lower) ||
|
|
568
|
+
(lower === "traceparent" && !forwardHeaders.traceparent)) {
|
|
569
|
+
forwardHeaders[lower] = v;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return forwardHeaders;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* One target attempt: dispatch a translated `ClaudeRequest` to either the
|
|
576
|
+
* in-process Anthropic-pool loopback or Vertex's Claude passthrough, and
|
|
577
|
+
* hand back an Anthropic-shaped `Response` (never a Codex-shape one). Both
|
|
578
|
+
* legs are unified through the same `createAnthropicFallbackStream` call one
|
|
579
|
+
* level up, so this function's only job per target is "get the Response
|
|
580
|
+
* plus a way to attribute it afterward" — Vertex's own `onTerminal` usage
|
|
581
|
+
* path is deliberately unused, since usage is read from the shared terminal
|
|
582
|
+
* envelope instead (a scope-bounding ruling, not an oversight).
|
|
583
|
+
*
|
|
584
|
+
* `finalizeAttribution`/`releaseInternal` are split from the dispatch itself
|
|
585
|
+
* because, for the anthropic target, the loopback's own account/model
|
|
586
|
+
* attribution is only known once its lifecycle completes — which, like the
|
|
587
|
+
* OpenAI->Anthropic bridge's `writeLifecycle`, is no earlier than this
|
|
588
|
+
* caller's own read of the response body. The caller invokes
|
|
589
|
+
* `finalizeAttribution` then `releaseInternal`, in that order, exactly once.
|
|
590
|
+
*/
|
|
591
|
+
async function dispatchCodexOutboundTarget(args) {
|
|
592
|
+
const { ctx, target, claudeRequest, loopbackPort, internalDispatch, affinityKey, } = args;
|
|
593
|
+
if (target.provider === "vertex") {
|
|
594
|
+
try {
|
|
595
|
+
const vertexBody = {
|
|
596
|
+
...claudeRequest,
|
|
597
|
+
stream: true,
|
|
598
|
+
};
|
|
599
|
+
const response = await executeVertexAnthropicFallback({
|
|
600
|
+
body: vertexBody,
|
|
601
|
+
model: target.model,
|
|
602
|
+
...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}),
|
|
603
|
+
});
|
|
604
|
+
return {
|
|
605
|
+
ok: true,
|
|
606
|
+
response,
|
|
607
|
+
// Vertex has no OAuth-pool "account" concept, but it does have a
|
|
608
|
+
// model, so it uses the same `vertex/${model}` / "vertex" labeling
|
|
609
|
+
// convention as the existing Claude->Vertex fallback leg
|
|
610
|
+
// (claudeProxyRoutes.ts's setServedAccount(`vertex/${vertexModel}`,
|
|
611
|
+
// "vertex")), keeping per-model cost attribution intact instead of
|
|
612
|
+
// collapsing every Codex-outbound-to-Vertex model into one sentinel.
|
|
613
|
+
finalizeAttribution: () => ({
|
|
614
|
+
account: `vertex/${target.model}`,
|
|
615
|
+
accountKey: `vertex/${target.model}`,
|
|
616
|
+
accountType: "vertex",
|
|
617
|
+
provider: "vertex",
|
|
618
|
+
model: target.model,
|
|
619
|
+
}),
|
|
620
|
+
releaseInternal: () => {
|
|
621
|
+
/* Vertex holds no internal-request capability to release. */
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
return { ok: false, error };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// provider === "anthropic": in-process loopback to this same proxy's own
|
|
630
|
+
// /v1/messages handler. SECURITY: the target is always 127.0.0.1 on the
|
|
631
|
+
// listener's own port, never derived from a client-controlled Host header.
|
|
632
|
+
if (!loopbackPort) {
|
|
633
|
+
return {
|
|
634
|
+
ok: false,
|
|
635
|
+
error: new Error("codex-outbound-fallback: no loopback port configured for the anthropic target"),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
// Registration throws at the internal-dispatch capacity limit. That is a
|
|
639
|
+
// failed target like any other: the caller moves on to the next target or
|
|
640
|
+
// returns the native Codex error, instead of the throw escaping as a 502.
|
|
641
|
+
let internal;
|
|
642
|
+
try {
|
|
643
|
+
internal = registerInternalProxyRequest(ctx.requestId);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
return { ok: false, error };
|
|
647
|
+
}
|
|
648
|
+
const cancellation = new AbortController();
|
|
649
|
+
const headersTimeout = setTimeout(() => cancellation.abort(new DOMException("Codex outbound fallback timed out", "TimeoutError")), codexOutboundFallbackTimeoutOverrideMs ??
|
|
650
|
+
CODEX_OUTBOUND_FALLBACK_TIMEOUT_MS);
|
|
651
|
+
const signal = AbortSignal.any([
|
|
652
|
+
cancellation.signal,
|
|
653
|
+
...(ctx.abortSignal ? [ctx.abortSignal] : []),
|
|
654
|
+
]);
|
|
655
|
+
const internalUrl = `http://127.0.0.1:${loopbackPort}/v1/messages`;
|
|
656
|
+
let disposed = false;
|
|
657
|
+
const finalizeAttribution = () => {
|
|
658
|
+
if (!disposed) {
|
|
659
|
+
disposed = true;
|
|
660
|
+
internal.dispose();
|
|
661
|
+
}
|
|
662
|
+
const child = getProxyBridgeResult(ctx.requestId);
|
|
663
|
+
return {
|
|
664
|
+
account: child?.account ?? "anthropic-outbound",
|
|
665
|
+
...(child?.accountKey ? { accountKey: child.accountKey } : {}),
|
|
666
|
+
accountType: child?.accountType ?? "anthropic-oauth",
|
|
667
|
+
provider: child?.provider ?? "anthropic",
|
|
668
|
+
model: child?.model ?? target.model,
|
|
669
|
+
};
|
|
670
|
+
};
|
|
671
|
+
const releaseInternal = () => {
|
|
672
|
+
if (!disposed) {
|
|
673
|
+
disposed = true;
|
|
674
|
+
internal.dispose();
|
|
675
|
+
}
|
|
676
|
+
releaseProxyRequestAccounting(ctx.requestId);
|
|
677
|
+
};
|
|
678
|
+
try {
|
|
679
|
+
const forwardHeaders = buildCodexOutboundLoopbackHeaders(ctx, internal.token, affinityKey);
|
|
680
|
+
const requestOptions = {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: forwardHeaders,
|
|
683
|
+
body: JSON.stringify({ ...claudeRequest, stream: true }),
|
|
684
|
+
signal,
|
|
685
|
+
};
|
|
686
|
+
const response = await raceWithAbort(Promise.resolve(internalDispatch
|
|
687
|
+
? internalDispatch(new Request(internalUrl, requestOptions))
|
|
688
|
+
: fetch(internalUrl, requestOptions)), signal);
|
|
689
|
+
if (!response.ok) {
|
|
690
|
+
const detail = await response.text().catch(() => "");
|
|
691
|
+
clearTimeout(headersTimeout);
|
|
692
|
+
releaseInternal();
|
|
693
|
+
return {
|
|
694
|
+
ok: false,
|
|
695
|
+
error: new Error(`codex-outbound-fallback: anthropic loopback responded ${response.status}: ${detail.slice(0, 300)}`),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
clearTimeout(headersTimeout);
|
|
699
|
+
return {
|
|
700
|
+
ok: true,
|
|
701
|
+
response,
|
|
702
|
+
finalizeAttribution,
|
|
703
|
+
releaseInternal,
|
|
704
|
+
childRequestId: internal.requestId,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
clearTimeout(headersTimeout);
|
|
709
|
+
releaseInternal();
|
|
710
|
+
return { ok: false, error };
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Attempt the Codex-outbound fallback for one native-Codex-route failure.
|
|
715
|
+
* Called from each of `executeCodexResponsesRequest`'s four insertion points
|
|
716
|
+
* with a `CodexOutboundFailureInput` describing what that call site observed.
|
|
717
|
+
*
|
|
718
|
+
* Gating order: already-a-fallback-request -> already-attempted-this-request
|
|
719
|
+
* -> classifier eligibility -> runtime-config enabled+targets -> the inbound
|
|
720
|
+
* body parses as a native Codex request. Any gate failing returns
|
|
721
|
+
* `not_attempted`, and the caller's existing code at that insertion point
|
|
722
|
+
* runs completely unchanged — this is what makes the flag-off path byte-
|
|
723
|
+
* identical to before this feature existed.
|
|
724
|
+
*/
|
|
725
|
+
async function attemptCodexOutboundFallback(args) {
|
|
726
|
+
const { ctx, body, model, isFallbackRequest, runtimeConfigProvider, loopbackPort, internalDispatch, tracer, recordFinalOutcome, failureInput, } = args;
|
|
727
|
+
// A request that itself arrived as an inner fallback leg (the existing
|
|
728
|
+
// Claude->Codex direction's own Codex call) must never be offered a
|
|
729
|
+
// further outbound fallback — that would be a second engine crossing in
|
|
730
|
+
// the same turn, exactly what MAX_ENGINE_CROSSINGS bounds against.
|
|
731
|
+
if (isFallbackRequest) {
|
|
732
|
+
return { kind: "not_attempted" };
|
|
733
|
+
}
|
|
734
|
+
if (ctx.metadata?.[CODEX_OUTBOUND_FALLBACK_ATTEMPTED_KEY] === true) {
|
|
735
|
+
return { kind: "not_attempted" };
|
|
736
|
+
}
|
|
737
|
+
if (!runtimeConfigProvider) {
|
|
738
|
+
return { kind: "not_attempted" };
|
|
739
|
+
}
|
|
740
|
+
const decision = classifyCodexOutboundFailure(failureInput);
|
|
741
|
+
if (!decision.eligible) {
|
|
742
|
+
return { kind: "not_attempted" };
|
|
743
|
+
}
|
|
744
|
+
let snapshot;
|
|
745
|
+
try {
|
|
746
|
+
snapshot = runtimeConfigProvider();
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return { kind: "not_attempted" };
|
|
750
|
+
}
|
|
751
|
+
if (!snapshot.codexOutboundFallbackEnabled) {
|
|
752
|
+
return { kind: "not_attempted" };
|
|
753
|
+
}
|
|
754
|
+
const targets = snapshot.codexOutboundFallbackTargets;
|
|
755
|
+
if (!targets || targets.length === 0) {
|
|
756
|
+
return { kind: "not_attempted" };
|
|
757
|
+
}
|
|
758
|
+
const parsed = parseCodexNativeRequest(body);
|
|
759
|
+
if (!parsed.ok) {
|
|
760
|
+
return { kind: "not_attempted" };
|
|
761
|
+
}
|
|
762
|
+
// Every gate has passed and at least one target will be tried below: this
|
|
763
|
+
// is the one dispatch attempt MAX_ENGINE_CROSSINGS allows for this whole
|
|
764
|
+
// request, so no later insertion-point call may try again even if every
|
|
765
|
+
// target below ultimately fails.
|
|
766
|
+
ctx.metadata[CODEX_OUTBOUND_FALLBACK_ATTEMPTED_KEY] = true;
|
|
767
|
+
for (const target of targets) {
|
|
768
|
+
const mapping = snapshot.codexOutboundFallbackModelMappings.find((m) => m.from === parsed.value.model && m.provider === target.provider);
|
|
769
|
+
const resolvedModel = mapping ? mapping.to : target.model;
|
|
770
|
+
const translated = translateCodexRequestToClaude(parsed.value, {
|
|
771
|
+
provider: target.provider,
|
|
772
|
+
model: resolvedModel,
|
|
773
|
+
});
|
|
774
|
+
if (translated.ok === false) {
|
|
775
|
+
if (translated.error.code === "REQUEST_TOO_LARGE") {
|
|
776
|
+
// Target-independent: every other configured target would fail the
|
|
777
|
+
// exact same way, so this short-circuits the whole loop.
|
|
778
|
+
return {
|
|
779
|
+
kind: "request_too_large",
|
|
780
|
+
message: translated.error.message,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
recordFallbackAttempt({
|
|
784
|
+
provider: target.provider,
|
|
785
|
+
model: resolvedModel,
|
|
786
|
+
status: "failure",
|
|
787
|
+
errorMessage: translated.error.message,
|
|
788
|
+
durationMs: 0,
|
|
789
|
+
direction: "codex-outbound",
|
|
790
|
+
});
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
// Cache preservation (spec cache section; Ruling #3): guard the
|
|
794
|
+
// translated request's shape, then mark cache breakpoints with a 1h TTL
|
|
795
|
+
// before dispatch. The fallback exists to rescue the turn, so a
|
|
796
|
+
// cache-shape problem must never fail it — a thrown guard is logged
|
|
797
|
+
// (never with payload content) and the unmodified translated request is
|
|
798
|
+
// dispatched without the added breakpoints instead.
|
|
799
|
+
let claudeRequest = translated.value;
|
|
800
|
+
try {
|
|
801
|
+
assertClaudeSystemPrefixShape(translated.value.system, buildSystemBlocksFromDeveloperMessages(parsed.value.input).length);
|
|
802
|
+
assertClaudeMessagesAlternate(translated.value.messages);
|
|
803
|
+
claudeRequest = applyClaudeRequestCacheBreakpoints(translated.value, {
|
|
804
|
+
ttl: "1h",
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
logger.warn(`[codex-outbound-fallback] cache-preservation guard rejected the translated request shape (${target.provider}/${resolvedModel}); dispatching without added cache breakpoints: ${error instanceof Error ? error.name : "unknown error"}`);
|
|
809
|
+
}
|
|
810
|
+
// Ruling #3: derive the Codex-side session-affinity key so consecutive
|
|
811
|
+
// turns of the same Codex conversation can route to the same Anthropic
|
|
812
|
+
// account. Only the anthropic target has a session-affinity seam to feed;
|
|
813
|
+
// Vertex has none.
|
|
814
|
+
const affinityKey = target.provider === "anthropic"
|
|
815
|
+
? codexAnthropicAffinityKey(parsed.value)
|
|
816
|
+
: undefined;
|
|
817
|
+
const attemptStartedAt = Date.now();
|
|
818
|
+
const dispatched = await dispatchCodexOutboundTarget({
|
|
819
|
+
ctx,
|
|
820
|
+
target: { provider: target.provider, model: resolvedModel },
|
|
821
|
+
claudeRequest,
|
|
822
|
+
loopbackPort,
|
|
823
|
+
internalDispatch,
|
|
824
|
+
affinityKey,
|
|
825
|
+
});
|
|
826
|
+
if (dispatched.ok === false) {
|
|
827
|
+
recordFallbackAttempt({
|
|
828
|
+
provider: target.provider,
|
|
829
|
+
model: resolvedModel,
|
|
830
|
+
status: "failure",
|
|
831
|
+
errorMessage: dispatched.error instanceof Error
|
|
832
|
+
? dispatched.error.message
|
|
833
|
+
: String(dispatched.error),
|
|
834
|
+
durationMs: Date.now() - attemptStartedAt,
|
|
835
|
+
direction: "codex-outbound",
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
let codecStream;
|
|
840
|
+
try {
|
|
841
|
+
codecStream = await createAnthropicFallbackStream(dispatched.response, resolvedModel, translated.toolKindByName);
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
// Pre-commit failure (createAnthropicFallbackStream's own preflight,
|
|
845
|
+
// codexToAnthropicFallback.ts:220-517): safe to try the next target.
|
|
846
|
+
recordFallbackAttempt({
|
|
847
|
+
provider: target.provider,
|
|
848
|
+
model: resolvedModel,
|
|
849
|
+
status: "failure",
|
|
850
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
851
|
+
durationMs: Date.now() - attemptStartedAt,
|
|
852
|
+
direction: "codex-outbound",
|
|
853
|
+
});
|
|
854
|
+
dispatched.releaseInternal();
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
// COMMIT POINT: createAnthropicFallbackStream resolved, so this leg
|
|
858
|
+
// never throws again past this point — every later failure is
|
|
859
|
+
// serialized into the stream itself. The leg-health record fires now,
|
|
860
|
+
// and this function unconditionally returns success from here on,
|
|
861
|
+
// never falling back to try a different target.
|
|
862
|
+
//
|
|
863
|
+
// Spec: "Call tracer.setRequestOrigin('codex-fallback') at the point the
|
|
864
|
+
// new outbound path enters the Anthropic dispatch." Set only here, once a
|
|
865
|
+
// target has actually been dispatched and will serve the turn, so a
|
|
866
|
+
// request that exhausts every target and ends as a native Codex error
|
|
867
|
+
// keeps its root span's origin at "native" rather than being mutated on
|
|
868
|
+
// an attempt that was later abandoned.
|
|
869
|
+
tracer?.setRequestOrigin("codex-fallback");
|
|
870
|
+
recordFallbackAttempt({
|
|
871
|
+
provider: target.provider,
|
|
872
|
+
model: resolvedModel,
|
|
873
|
+
status: "success",
|
|
874
|
+
durationMs: Date.now() - attemptStartedAt,
|
|
875
|
+
direction: "codex-outbound",
|
|
876
|
+
});
|
|
877
|
+
const responseHeaders = buildCodexOutboundFallbackResponseHeaders(ctx, target.provider);
|
|
878
|
+
const response = wrapCodexOutboundResponseStream(codecStream, responseHeaders, (result) => {
|
|
879
|
+
const served = dispatched.finalizeAttribution();
|
|
880
|
+
const { account, accountType } = served;
|
|
881
|
+
// Read the child's requestId BEFORE releasing: this mirrors
|
|
882
|
+
// openaiProxyRoutes.ts's own internal loopback exactly. The anthropic
|
|
883
|
+
// target's /v1/messages call logs its own final entry, carrying real
|
|
884
|
+
// usage, under `dispatched.childRequestId` as ITS `requestId`. Naming
|
|
885
|
+
// it here as this (outer) entry's `usageOwnerRequestId` is what makes
|
|
886
|
+
// `proxyAnalysis.ts`'s billing pass skip the outer entry and count the
|
|
887
|
+
// upstream call exactly once, via the inner entry — set unconditionally
|
|
888
|
+
// (every outcome), since a cancelled/errored outer turn does not
|
|
889
|
+
// un-happen the inner call the loopback already made.
|
|
890
|
+
// `requestedModel` stays the Codex model writeFinalLog sets; these
|
|
891
|
+
// override the Codex-pool defaults so a Vertex-served turn is priced
|
|
892
|
+
// as the Claude model on Vertex, not as an OpenAI model.
|
|
893
|
+
const accountingExtra = {
|
|
894
|
+
...served,
|
|
895
|
+
...(dispatched.childRequestId
|
|
896
|
+
? {
|
|
897
|
+
accountingScope: "client",
|
|
898
|
+
usageOwnerRequestId: dispatched.childRequestId,
|
|
899
|
+
}
|
|
900
|
+
: {}),
|
|
901
|
+
};
|
|
902
|
+
dispatched.releaseInternal();
|
|
903
|
+
try {
|
|
904
|
+
if (result.kind === "completed") {
|
|
905
|
+
tracer?.setModelSubstitution(model, resolvedModel, target.provider);
|
|
906
|
+
tracer?.setServedAccount(account, accountType);
|
|
907
|
+
const usage = toUsageContext(result.envelope.usage);
|
|
908
|
+
// A loopback child records this call's tokens and cost itself, so
|
|
909
|
+
// this outer entry carries neither, as the OpenAI bridge's does.
|
|
910
|
+
const delegated = dispatched.childRequestId !== undefined;
|
|
911
|
+
if (usage) {
|
|
912
|
+
tracer?.setUsage(usage, delegated ? { recordMetrics: false } : undefined);
|
|
913
|
+
}
|
|
914
|
+
// A failed stream is a failed turn (502), as on the native path.
|
|
915
|
+
const failed = result.envelope.status === "failed";
|
|
916
|
+
void recordFinalOutcome(undefined, failed ? 502 : 200, {
|
|
917
|
+
...accountingExtra,
|
|
918
|
+
terminalOutcome: failed ? "stream_error" : "completed",
|
|
919
|
+
...(failed
|
|
920
|
+
? {
|
|
921
|
+
errorType: "outbound_fallback_stream_failed",
|
|
922
|
+
errorMessage: result.envelope.error?.message ??
|
|
923
|
+
"codex-outbound fallback stream ended in failure",
|
|
924
|
+
}
|
|
925
|
+
: {}),
|
|
926
|
+
...(usage && !delegated
|
|
927
|
+
? outboundFallbackUsageLogFields(usage)
|
|
928
|
+
: {}),
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
else if (result.kind === "cancelled") {
|
|
932
|
+
tracer?.setServedAccount(account, accountType);
|
|
933
|
+
void recordFinalOutcome(undefined, 499, {
|
|
934
|
+
...accountingExtra,
|
|
935
|
+
errorType: "client_cancelled",
|
|
936
|
+
errorMessage: "Client cancelled Codex request",
|
|
937
|
+
terminalOutcome: "client_cancelled",
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
tracer?.setServedAccount(account, accountType);
|
|
942
|
+
void recordFinalOutcome(undefined, 502, {
|
|
943
|
+
...accountingExtra,
|
|
944
|
+
errorType: "outbound_fallback_stream_error",
|
|
945
|
+
errorMessage: result.error instanceof Error
|
|
946
|
+
? result.error.message
|
|
947
|
+
: String(result.error),
|
|
948
|
+
terminalOutcome: "stream_error",
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
// Accounting must never throw across the response boundary.
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
return { kind: "success", response };
|
|
957
|
+
}
|
|
958
|
+
return { kind: "not_attempted" };
|
|
959
|
+
}
|
|
380
960
|
/** Core pooled handler for POST /backend-api/codex/responses. */
|
|
381
|
-
export async function handleCodexResponsesRequest(ctx) {
|
|
961
|
+
export async function handleCodexResponsesRequest(ctx, runtimeConfigProvider, loopbackPort, internalDispatch) {
|
|
382
962
|
if (ctx.metadata?.[CODEX_FALLBACK_METADATA_KEY] !== true) {
|
|
383
963
|
void logBodyCapture({
|
|
384
964
|
timestamp: new Date().toISOString(),
|
|
@@ -392,7 +972,7 @@ export async function handleCodexResponsesRequest(ctx) {
|
|
|
392
972
|
...resolveProxyLogTraceContext({ requestId: ctx.requestId }),
|
|
393
973
|
});
|
|
394
974
|
}
|
|
395
|
-
const response = await executeCodexResponsesRequest(ctx);
|
|
975
|
+
const response = await executeCodexResponsesRequest(ctx, runtimeConfigProvider, loopbackPort, internalDispatch);
|
|
396
976
|
return ctx.metadata?.[CODEX_FALLBACK_METADATA_KEY] === true
|
|
397
977
|
? response
|
|
398
978
|
: captureCodexResponse(ctx, response, "client_response");
|
|
@@ -449,7 +1029,15 @@ function captureCodexResponse(ctx, response, phase, account, attempt) {
|
|
|
449
1029
|
* provider did not report are carried as unobserved rather than zero, so a
|
|
450
1030
|
* silent cache breakdown is never recorded as a cache miss.
|
|
451
1031
|
*/
|
|
452
|
-
async function executeCodexResponsesRequest(ctx
|
|
1032
|
+
async function executeCodexResponsesRequest(ctx,
|
|
1033
|
+
// Read by the outbound-fallback insertion points below (stage-c-trigger.md)
|
|
1034
|
+
// to gate/target a Codex-outbound fallback attempt when the native Codex
|
|
1035
|
+
// pool itself fails.
|
|
1036
|
+
runtimeConfigProvider,
|
|
1037
|
+
// Threaded through from `createCodexProxyRoutes` to the anthropic-target
|
|
1038
|
+
// loopback leg of the outbound-fallback dispatcher (mirrors the
|
|
1039
|
+
// OpenAI->Anthropic bridge's `loopbackPort`/`internalDispatch`).
|
|
1040
|
+
loopbackPort, internalDispatch) {
|
|
453
1041
|
const requestStartTime = Date.now();
|
|
454
1042
|
let body = (ctx.body ?? {});
|
|
455
1043
|
let bodyStr;
|
|
@@ -665,6 +1253,35 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
665
1253
|
return cancelRequest();
|
|
666
1254
|
}
|
|
667
1255
|
if (accounts.length === 0) {
|
|
1256
|
+
const outbound = await attemptCodexOutboundFallback({
|
|
1257
|
+
ctx,
|
|
1258
|
+
body,
|
|
1259
|
+
model,
|
|
1260
|
+
isFallbackRequest,
|
|
1261
|
+
runtimeConfigProvider,
|
|
1262
|
+
loopbackPort,
|
|
1263
|
+
internalDispatch,
|
|
1264
|
+
tracer,
|
|
1265
|
+
recordFinalOutcome,
|
|
1266
|
+
failureInput: { failureClass: "no_accounts" },
|
|
1267
|
+
});
|
|
1268
|
+
if (outbound.kind === "success") {
|
|
1269
|
+
// No Codex account was ever selected on this path, so budgetLease is
|
|
1270
|
+
// always undefined here — settleBudget() is a defensive no-op. Kept
|
|
1271
|
+
// for consistency with the other three insertion points below.
|
|
1272
|
+
await settleBudget();
|
|
1273
|
+
budgetLease = undefined;
|
|
1274
|
+
budgetDispatched = false;
|
|
1275
|
+
return outbound.response;
|
|
1276
|
+
}
|
|
1277
|
+
if (outbound.kind === "request_too_large") {
|
|
1278
|
+
await recordFinalOutcome(undefined, 413, {
|
|
1279
|
+
errorType: "request_too_large",
|
|
1280
|
+
errorMessage: outbound.message,
|
|
1281
|
+
terminalOutcome: "handler_error",
|
|
1282
|
+
});
|
|
1283
|
+
return buildCodexErrorResponse(413, outbound.message, "request_too_large");
|
|
1284
|
+
}
|
|
668
1285
|
await recordFinalOutcome(undefined, 401, {
|
|
669
1286
|
errorType: "no_accounts",
|
|
670
1287
|
errorMessage: "No Codex accounts",
|
|
@@ -687,6 +1304,35 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
687
1304
|
const retryAfterSec = soonest
|
|
688
1305
|
? Math.max(1, Math.ceil((soonest - now) / 1000))
|
|
689
1306
|
: 60;
|
|
1307
|
+
const outbound = await attemptCodexOutboundFallback({
|
|
1308
|
+
ctx,
|
|
1309
|
+
body,
|
|
1310
|
+
model,
|
|
1311
|
+
isFallbackRequest,
|
|
1312
|
+
runtimeConfigProvider,
|
|
1313
|
+
loopbackPort,
|
|
1314
|
+
internalDispatch,
|
|
1315
|
+
tracer,
|
|
1316
|
+
recordFinalOutcome,
|
|
1317
|
+
failureInput: { failureClass: "pool_exhausted" },
|
|
1318
|
+
});
|
|
1319
|
+
if (outbound.kind === "success") {
|
|
1320
|
+
// No Codex account was ever selected on this path, so budgetLease is
|
|
1321
|
+
// always undefined here — settleBudget() is a defensive no-op. Kept
|
|
1322
|
+
// for consistency with the other three insertion points below.
|
|
1323
|
+
await settleBudget();
|
|
1324
|
+
budgetLease = undefined;
|
|
1325
|
+
budgetDispatched = false;
|
|
1326
|
+
return outbound.response;
|
|
1327
|
+
}
|
|
1328
|
+
if (outbound.kind === "request_too_large") {
|
|
1329
|
+
await recordFinalOutcome(undefined, 413, {
|
|
1330
|
+
errorType: "request_too_large",
|
|
1331
|
+
errorMessage: outbound.message,
|
|
1332
|
+
terminalOutcome: "handler_error",
|
|
1333
|
+
});
|
|
1334
|
+
return buildCodexErrorResponse(413, outbound.message, "request_too_large");
|
|
1335
|
+
}
|
|
690
1336
|
await recordFinalOutcome(undefined, 429, {
|
|
691
1337
|
errorType: "all_accounts_cooling",
|
|
692
1338
|
errorMessage: "All Codex accounts are rate-limited",
|
|
@@ -699,6 +1345,9 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
699
1345
|
let attempt = 0;
|
|
700
1346
|
let lastErrorMessage = "All Codex accounts failed";
|
|
701
1347
|
let lastErrorStatus = 502;
|
|
1348
|
+
// Set with every lastErrorStatus so the outbound-fallback classifier sees
|
|
1349
|
+
// the code of the same attempt, never a stale one from an earlier account.
|
|
1350
|
+
let lastErrorCode;
|
|
702
1351
|
let lastFailure = { errorType: "all_accounts_failed" };
|
|
703
1352
|
let lastAttemptedAccount;
|
|
704
1353
|
for (const account of eligible) {
|
|
@@ -828,6 +1477,40 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
828
1477
|
"ENOTFOUND",
|
|
829
1478
|
"EAI_AGAIN",
|
|
830
1479
|
].includes(errorCode ?? "")) {
|
|
1480
|
+
const outbound = await attemptCodexOutboundFallback({
|
|
1481
|
+
ctx,
|
|
1482
|
+
body,
|
|
1483
|
+
model,
|
|
1484
|
+
isFallbackRequest,
|
|
1485
|
+
runtimeConfigProvider,
|
|
1486
|
+
loopbackPort,
|
|
1487
|
+
internalDispatch,
|
|
1488
|
+
tracer,
|
|
1489
|
+
recordFinalOutcome,
|
|
1490
|
+
failureInput: {
|
|
1491
|
+
failureClass: "non_retryable_transport",
|
|
1492
|
+
transportErrorCode: errorCode,
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
if (outbound.kind === "success") {
|
|
1496
|
+
// This Codex account's budget lease (reserved for the attempt
|
|
1497
|
+
// that just failed transport-level) belongs to a request this
|
|
1498
|
+
// turn is no longer serving via Codex at all — hold it open no
|
|
1499
|
+
// longer than necessary rather than leaving it pending for the
|
|
1500
|
+
// unrelated fallback stream's full duration.
|
|
1501
|
+
await settleBudget();
|
|
1502
|
+
budgetLease = undefined;
|
|
1503
|
+
budgetDispatched = false;
|
|
1504
|
+
return outbound.response;
|
|
1505
|
+
}
|
|
1506
|
+
if (outbound.kind === "request_too_large") {
|
|
1507
|
+
await recordFinalOutcome(account, 413, {
|
|
1508
|
+
errorType: "request_too_large",
|
|
1509
|
+
errorMessage: outbound.message,
|
|
1510
|
+
terminalOutcome: "handler_error",
|
|
1511
|
+
});
|
|
1512
|
+
return buildCodexErrorResponse(413, outbound.message, "request_too_large");
|
|
1513
|
+
}
|
|
831
1514
|
await recordFinalOutcome(account, 502, {
|
|
832
1515
|
...lastFailure,
|
|
833
1516
|
errorMessage,
|
|
@@ -836,6 +1519,7 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
836
1519
|
}
|
|
837
1520
|
lastErrorMessage = "Codex upstream request failed";
|
|
838
1521
|
lastErrorStatus = 502;
|
|
1522
|
+
lastErrorCode = lastFailure.errorCode;
|
|
839
1523
|
break; // rotate to next account
|
|
840
1524
|
}
|
|
841
1525
|
upstream = captureCodexResponse(ctx, upstream, "upstream_response", account, attempt);
|
|
@@ -1043,6 +1727,7 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
1043
1727
|
errorCode: "refresh_invalid",
|
|
1044
1728
|
};
|
|
1045
1729
|
lastErrorStatus = 401;
|
|
1730
|
+
lastErrorCode = lastFailure.errorCode;
|
|
1046
1731
|
lastErrorMessage =
|
|
1047
1732
|
"Codex token refresh failed; re-login required";
|
|
1048
1733
|
break;
|
|
@@ -1056,6 +1741,7 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
1056
1741
|
errorCode: getCodexTransportErrorCode(error),
|
|
1057
1742
|
};
|
|
1058
1743
|
lastErrorStatus = 503;
|
|
1744
|
+
lastErrorCode = lastFailure.errorCode;
|
|
1059
1745
|
lastErrorMessage = "Codex token refresh temporarily unavailable";
|
|
1060
1746
|
break;
|
|
1061
1747
|
}
|
|
@@ -1093,6 +1779,7 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
1093
1779
|
errorCode: bodyQuota?.errorCode,
|
|
1094
1780
|
};
|
|
1095
1781
|
lastErrorStatus = 429;
|
|
1782
|
+
lastErrorCode = lastFailure.errorCode;
|
|
1096
1783
|
lastErrorMessage = "Codex account rate-limited";
|
|
1097
1784
|
break; // rotate
|
|
1098
1785
|
}
|
|
@@ -1115,10 +1802,46 @@ async function executeCodexResponsesRequest(ctx) {
|
|
|
1115
1802
|
});
|
|
1116
1803
|
lastFailure = { errorType };
|
|
1117
1804
|
lastErrorStatus = upstream.status >= 500 ? 502 : upstream.status;
|
|
1805
|
+
lastErrorCode = getProxyUpstreamFailure({
|
|
1806
|
+
responseBody: errText,
|
|
1807
|
+
})?.code;
|
|
1118
1808
|
lastErrorMessage = errorMessage;
|
|
1119
1809
|
break; // rotate
|
|
1120
1810
|
}
|
|
1121
1811
|
}
|
|
1812
|
+
const outbound = await attemptCodexOutboundFallback({
|
|
1813
|
+
ctx,
|
|
1814
|
+
body,
|
|
1815
|
+
model,
|
|
1816
|
+
isFallbackRequest,
|
|
1817
|
+
runtimeConfigProvider,
|
|
1818
|
+
loopbackPort,
|
|
1819
|
+
internalDispatch,
|
|
1820
|
+
tracer,
|
|
1821
|
+
recordFinalOutcome,
|
|
1822
|
+
failureInput: {
|
|
1823
|
+
failureClass: "loop_fallthrough",
|
|
1824
|
+
lastStatus: lastErrorStatus,
|
|
1825
|
+
lastErrorCode,
|
|
1826
|
+
},
|
|
1827
|
+
});
|
|
1828
|
+
if (outbound.kind === "success") {
|
|
1829
|
+
// Same reasoning as the transport-catch insertion point above: settle
|
|
1830
|
+
// the exhausted Codex account's budget lease immediately rather than
|
|
1831
|
+
// holding it open for the unrelated fallback stream's duration.
|
|
1832
|
+
await settleBudget();
|
|
1833
|
+
budgetLease = undefined;
|
|
1834
|
+
budgetDispatched = false;
|
|
1835
|
+
return outbound.response;
|
|
1836
|
+
}
|
|
1837
|
+
if (outbound.kind === "request_too_large") {
|
|
1838
|
+
await recordFinalOutcome(lastAttemptedAccount, 413, {
|
|
1839
|
+
errorType: "request_too_large",
|
|
1840
|
+
errorMessage: outbound.message,
|
|
1841
|
+
terminalOutcome: "handler_error",
|
|
1842
|
+
});
|
|
1843
|
+
return buildCodexErrorResponse(413, outbound.message, "request_too_large");
|
|
1844
|
+
}
|
|
1122
1845
|
await recordFinalOutcome(lastAttemptedAccount, lastErrorStatus, {
|
|
1123
1846
|
...lastFailure,
|
|
1124
1847
|
errorMessage: lastFailure.errorMessage ?? lastErrorMessage,
|
|
@@ -1303,13 +2026,70 @@ async function handleCodexModelsRequest(ctx) {
|
|
|
1303
2026
|
// person debugging it re-opens this same issue.
|
|
1304
2027
|
return buildCodexErrorResponse(lastErrorStatus, lastErrorMessage);
|
|
1305
2028
|
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Route-creation-time-only check: the Codex-outbound-fallback dispatcher's
|
|
2031
|
+
* `anthropic` target needs the in-process loopback (`loopbackPort` and/or
|
|
2032
|
+
* `internalDispatch`) to reach the Anthropic pool, and `createAllRoutes` /
|
|
2033
|
+
* `registerAllRoutes` (`src/lib/server/routes/index.ts`) has no option that
|
|
2034
|
+
* threads either through — only the CLI proxy command
|
|
2035
|
+
* (`cli/commands/proxy.ts`) passes them directly to this function today. A
|
|
2036
|
+
* consumer that enables the feature with an `anthropic` target through the
|
|
2037
|
+
* generic SDK route surface would otherwise fail every such request silently
|
|
2038
|
+
* behind `attemptCodexOutboundFallback`'s "no loopback port configured"
|
|
2039
|
+
* warning, one per request, with nothing at startup explaining why. This
|
|
2040
|
+
* logs ONE clear warning here instead, at the point the routes are built,
|
|
2041
|
+
* rather than adding a new public option (ruling: don't grow
|
|
2042
|
+
* `CreateRoutesOptions` for this — document the limitation and warn).
|
|
2043
|
+
* A `vertex`-only configuration never reads `loopbackPort`, so it is exempt.
|
|
2044
|
+
*/
|
|
2045
|
+
function warnIfAnthropicOutboundFallbackUnreachable(runtimeConfigProvider, loopbackPort, internalDispatch) {
|
|
2046
|
+
if (!runtimeConfigProvider ||
|
|
2047
|
+
loopbackPort !== undefined ||
|
|
2048
|
+
internalDispatch) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
let snapshot;
|
|
2052
|
+
try {
|
|
2053
|
+
snapshot = runtimeConfigProvider();
|
|
2054
|
+
}
|
|
2055
|
+
catch {
|
|
2056
|
+
// Same fail-open contract as the request-time reader below: a provider
|
|
2057
|
+
// that throws at creation time is not this check's problem to surface.
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
if (!snapshot.codexOutboundFallbackEnabled) {
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
const hasAnthropicTarget = (snapshot.codexOutboundFallbackTargets ?? []).some((target) => target.provider === "anthropic");
|
|
2064
|
+
if (!hasAnthropicTarget) {
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
logger.warn("[codex-outbound-fallback] enabled with an anthropic target, but createCodexProxyRoutes " +
|
|
2068
|
+
"was called without loopbackPort or internalDispatch — the anthropic leg cannot dispatch " +
|
|
2069
|
+
"and every such request will fail at attempt time. This SDK route surface " +
|
|
2070
|
+
"(createAllRoutes/registerAllRoutes) does not thread the in-process loopback through; " +
|
|
2071
|
+
"only the CLI proxy command wires it. See docs/features/codex-proxy-support.md.");
|
|
2072
|
+
}
|
|
1306
2073
|
/**
|
|
1307
2074
|
* Create Codex proxy routes.
|
|
1308
2075
|
*
|
|
1309
2076
|
* @param basePath - Base path prefix (default "").
|
|
2077
|
+
* @param runtimeConfigProvider - Optional runtime config snapshot reader.
|
|
2078
|
+
* Gates the Codex-outbound fallback dispatcher at all four insertion
|
|
2079
|
+
* points in `dispatch()`: omitting it (or a snapshot with the feature
|
|
2080
|
+
* disabled / no targets configured) reproduces today's behavior exactly.
|
|
2081
|
+
* @param loopbackPort - The in-process HTTP port the outbound-fallback leg's
|
|
2082
|
+
* Anthropic-pool loopback request targets (mirrors `createOpenAIProxyRoutes`'s
|
|
2083
|
+
* bridge). Required only for an `anthropic` target; the `vertex` target
|
|
2084
|
+
* dispatches via `executeVertexAnthropicFallback` and never reads it.
|
|
2085
|
+
* @param internalDispatch - Optional in-process fetch (`(request) =>
|
|
2086
|
+
* app.fetch(request)`), so the outbound-fallback loopback never re-enters
|
|
2087
|
+
* the public listener. Falls back to a real `fetch` against
|
|
2088
|
+
* `loopbackPort` when omitted.
|
|
1310
2089
|
* @returns RouteGroup with the Codex backend Responses endpoint.
|
|
1311
2090
|
*/
|
|
1312
|
-
export function createCodexProxyRoutes(basePath = "") {
|
|
2091
|
+
export function createCodexProxyRoutes(basePath = "", runtimeConfigProvider, loopbackPort, internalDispatch) {
|
|
2092
|
+
warnIfAnthropicOutboundFallbackUnreachable(runtimeConfigProvider, loopbackPort, internalDispatch);
|
|
1313
2093
|
return {
|
|
1314
2094
|
prefix: `${basePath}/backend-api/codex`,
|
|
1315
2095
|
routes: [
|
|
@@ -1317,7 +2097,7 @@ export function createCodexProxyRoutes(basePath = "") {
|
|
|
1317
2097
|
method: "POST",
|
|
1318
2098
|
path: `${basePath}/backend-api/codex/responses`,
|
|
1319
2099
|
description: "Codex ChatGPT-backend Responses API (account pool)",
|
|
1320
|
-
handler: (ctx) => handleCodexResponsesRequest(ctx),
|
|
2100
|
+
handler: (ctx) => handleCodexResponsesRequest(ctx, runtimeConfigProvider, loopbackPort, internalDispatch),
|
|
1321
2101
|
},
|
|
1322
2102
|
{
|
|
1323
2103
|
method: "GET",
|
|
@@ -1336,4 +2116,8 @@ export const __testHooks = {
|
|
|
1336
2116
|
refreshCodexTokenOnce,
|
|
1337
2117
|
refreshCodexTokenOnceWithDependencies,
|
|
1338
2118
|
codexRefreshInFlightSize: () => codexRefreshInFlight.size,
|
|
2119
|
+
setOutboundFallbackTimeoutMsForTests: (ms) => {
|
|
2120
|
+
codexOutboundFallbackTimeoutOverrideMs = ms;
|
|
2121
|
+
},
|
|
2122
|
+
wrapCodexOutboundResponseStream,
|
|
1339
2123
|
};
|