@juspay/neurolink 12.40.2 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +447 -443
  3. package/dist/cli/commands/proxy.js +5 -1
  4. package/dist/proxy/accountLedger.js +3 -0
  5. package/dist/proxy/codexOutboundCache.d.ts +52 -0
  6. package/dist/proxy/codexOutboundCache.js +147 -0
  7. package/dist/proxy/codexOutboundFallback.d.ts +27 -2
  8. package/dist/proxy/codexOutboundFallback.js +147 -11
  9. package/dist/proxy/codexResponsesFormat.js +7 -1
  10. package/dist/proxy/codexToAnthropicFallback.js +6 -0
  11. package/dist/proxy/proxyAnalysis.js +4 -0
  12. package/dist/proxy/proxyConfig.js +120 -0
  13. package/dist/proxy/proxyTokenUsage.d.ts +15 -0
  14. package/dist/proxy/proxyTokenUsage.js +33 -0
  15. package/dist/proxy/proxyTracer.d.ts +16 -1
  16. package/dist/proxy/proxyTracer.js +26 -5
  17. package/dist/proxy/requestLogger.js +9 -0
  18. package/dist/proxy/runtimeConfig.js +20 -0
  19. package/dist/proxy/sseInterceptor.js +9 -0
  20. package/dist/proxy/vertexAnthropicFallback.js +9 -0
  21. package/dist/server/routes/claudeProxyRoutes.js +65 -1
  22. package/dist/server/routes/codexProxyRoutes.d.ts +39 -3
  23. package/dist/server/routes/codexProxyRoutes.js +792 -8
  24. package/dist/server/routes/index.d.ts +8 -0
  25. package/dist/server/routes/index.js +9 -1
  26. package/dist/types/analytics.d.ts +25 -0
  27. package/dist/types/codex.d.ts +45 -1
  28. package/dist/types/proxy.d.ts +40 -2
  29. package/dist/types/proxyClient.d.ts +2 -0
  30. package/dist/types/server.d.ts +15 -0
  31. package/dist/types/subscription.d.ts +22 -0
  32. package/dist/utils/anthropicCacheBreakpoints.d.ts +2 -2
  33. package/dist/utils/anthropicCacheBreakpoints.js +14 -3
  34. package/dist/utils/pricing.js +101 -24
  35. package/docs-site/static/search-index.json +17 -6
  36. package/package.json +4 -4
@@ -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 { registerProxyResponseObserver, isProxyRequestFinalized, } from "../../proxy/proxyActivity.js";
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
  };