@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.
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 +1 -1
@@ -1650,7 +1650,11 @@ export async function createProxyStartApp(params) {
1650
1650
  // Keep bridge capabilities, accounting and cancellation in this worker
1651
1651
  // across supervisor generation switches; never re-enter the public listener.
1652
1652
  (request) => app.fetch(request));
1653
- const codexRouteGroup = createCodexProxyRoutes("");
1653
+ const codexRouteGroup = createCodexProxyRoutes("", runtimeConfigProvider, params.port,
1654
+ // Same in-process loopback discipline as the OpenAI->Anthropic bridge:
1655
+ // stay in this worker across supervisor generation switches, never
1656
+ // re-enter the public listener.
1657
+ (request) => app.fetch(request));
1654
1658
  const geminiRouteGroup = createGeminiProxyRoutes(params.modelRouter, "", params.port, runtimeConfigProvider);
1655
1659
  const allProxyRoutes = [
1656
1660
  ...routeGroup.routes,
@@ -166,6 +166,7 @@ async function advanceCursor(fileName, cursor) {
166
166
  outputTokens: finiteNumber(record.outputTokens),
167
167
  cacheReadTokens: finiteNumber(record.cacheReadTokens),
168
168
  cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
169
+ cacheCreation1hTokens: finiteNumber(record.cacheCreation1hTokens),
169
170
  clientApp: resolveClientApp(record),
170
171
  };
171
172
  // A later record for the same request enriches the earlier one — it must
@@ -198,6 +199,7 @@ async function advanceCursor(fileName, cursor) {
198
199
  outputTokens: Math.max(previous.outputTokens, next.outputTokens),
199
200
  cacheReadTokens: Math.max(previous.cacheReadTokens, next.cacheReadTokens),
200
201
  cacheCreationTokens: Math.max(previous.cacheCreationTokens, next.cacheCreationTokens),
202
+ cacheCreation1hTokens: Math.max(previous.cacheCreation1hTokens, next.cacheCreation1hTokens),
201
203
  // Likewise keep a real model name over a placeholder.
202
204
  model: next.model && next.model !== "-" ? next.model : previous.model,
203
205
  }
@@ -339,6 +341,7 @@ export async function readAccountUsage(date = currentUsageDate()) {
339
341
  total: entry.inputTokens + entry.outputTokens,
340
342
  cacheReadTokens: entry.cacheReadTokens,
341
343
  cacheCreationTokens: entry.cacheCreationTokens,
344
+ cacheCreation1hTokens: entry.cacheCreation1hTokens,
342
345
  });
343
346
  row.costUsd += cost;
344
347
  client.costUsd += cost;
@@ -0,0 +1,52 @@
1
+ import type { ClaudeMessage, ClaudeTextBlock, CodexAnthropicAffinityKey, CodexNativeRequest } from "../types/index.js";
2
+ /**
3
+ * Thrown by `assertClaudeSystemPrefixShape` and `assertClaudeMessagesAlternate`
4
+ * when the translated request does not have the shape breakpoint placement
5
+ * (or Anthropic's role-alternation requirement) depends on. Never logged and
6
+ * swallowed — a silent violation here defeats caching with no visible
7
+ * symptom besides a slow-moving cache-hit-rate metric, or turns into an
8
+ * undifferentiated Anthropic 400.
9
+ */
10
+ export declare class ClaudeSystemPrefixShapeError extends Error {
11
+ }
12
+ /**
13
+ * Enforces that `system` is exactly the stable developer-block prefix before
14
+ * a caller marks breakpoint 1 on its last element. `translateCodexRequestToClaude`
15
+ * is contracted to produce `system` containing only the developer-role-derived
16
+ * blocks and nothing else — `environment_context` and any other per-turn
17
+ * payload belong in `messages`. `system[system.length-1]` is only a safe
18
+ * breakpoint-1 anchor while that invariant holds; trusting it silently would
19
+ * be exactly the failure this guards against.
20
+ */
21
+ export declare function assertClaudeSystemPrefixShape(system: ClaudeTextBlock[] | string | undefined, expectedLength: number): void;
22
+ /**
23
+ * Throws, naming the offending index, when `messages` is not alternation-safe.
24
+ *
25
+ * Anthropic requires alternating `user`/`assistant` roles. A native Codex
26
+ * request can carry two consecutive `user`-role items (an auto-injected
27
+ * notice immediately followed by the literal user turn, or a synthetic
28
+ * `environment_context` pair) — mapped 1:1 into `ClaudeMessage` entries that
29
+ * would otherwise reach Anthropic as back-to-back `user` messages and get
30
+ * rejected on every request that hits it. Merging same-role Codex items into
31
+ * one Claude message with multiple content blocks is translation-correctness
32
+ * work owned by `translateCodexRequestToClaude`, not this guard's job — this
33
+ * only turns a missed merge into an attributable thrown error at the new
34
+ * hop's boundary instead of an undifferentiated Anthropic 400.
35
+ */
36
+ export declare function assertClaudeMessagesAlternate(messages: ReadonlyArray<ClaudeMessage>): void;
37
+ /**
38
+ * Derives the session-affinity key a Codex-origin request supplies to the
39
+ * Claude pipeline's existing session-affinity machinery, so consecutive
40
+ * turns of one conversation route back to the account already holding their
41
+ * cache. Preference order mirrors what a real Codex CLI capture carries:
42
+ * `prompt_cache_key` (top-level, the client's own pre-computed key — already
43
+ * exactly the signal being reconstructed) beats `client_metadata.thread_id`
44
+ * beats `client_metadata.session_id`; a genuinely identity-free request
45
+ * degrades to a prefix-derived key so at least concurrent turns of the same
46
+ * agent config bucket together.
47
+ *
48
+ * The three-way prefix (`codex-thread:` / `codex-session:` / `codex-prefix:`)
49
+ * keeps this namespace from colliding with a Claude-Code `metadata.user_id`-
50
+ * derived UUID or with itself across cases.
51
+ */
52
+ export declare function codexAnthropicAffinityKey(native: CodexNativeRequest): CodexAnthropicAffinityKey | undefined;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Codex-outbound fallback: cache-preservation guards and affinity-key
3
+ * derivation for the Codex -> Anthropic/Vertex hop.
4
+ *
5
+ * Owns three things, all pure: the defensive shape check that guards where
6
+ * `applyClaudeRequestCacheBreakpoints` marks breakpoint 1 (`system` must be
7
+ * exactly the stable developer-block prefix), the role-alternation guard
8
+ * that turns a translation defect into a loud, attributable error instead of
9
+ * a silent Anthropic 400, and the session-affinity key a Codex-origin
10
+ * request supplies so consecutive turns of one conversation route back to
11
+ * the account already holding their cache.
12
+ *
13
+ * Both guards are scoped to the new Codex-fallback entry point only, called
14
+ * immediately after `translateCodexRequestToClaude()` and before
15
+ * `applyClaudeRequestCacheBreakpoints()`. They are not folded into the
16
+ * shared breakpoint function itself, which the existing OpenAI bridge also
17
+ * calls: adding a hard throw there would newly reject that bridge's
18
+ * already-shipped, already-latent-buggy same-role-message traffic that has
19
+ * shipped without incident.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ /**
23
+ * Thrown by `assertClaudeSystemPrefixShape` and `assertClaudeMessagesAlternate`
24
+ * when the translated request does not have the shape breakpoint placement
25
+ * (or Anthropic's role-alternation requirement) depends on. Never logged and
26
+ * swallowed — a silent violation here defeats caching with no visible
27
+ * symptom besides a slow-moving cache-hit-rate metric, or turns into an
28
+ * undifferentiated Anthropic 400.
29
+ */
30
+ export class ClaudeSystemPrefixShapeError extends Error {
31
+ }
32
+ /**
33
+ * Enforces that `system` is exactly the stable developer-block prefix before
34
+ * a caller marks breakpoint 1 on its last element. `translateCodexRequestToClaude`
35
+ * is contracted to produce `system` containing only the developer-role-derived
36
+ * blocks and nothing else — `environment_context` and any other per-turn
37
+ * payload belong in `messages`. `system[system.length-1]` is only a safe
38
+ * breakpoint-1 anchor while that invariant holds; trusting it silently would
39
+ * be exactly the failure this guards against.
40
+ */
41
+ export function assertClaudeSystemPrefixShape(system, expectedLength) {
42
+ if (!Array.isArray(system)) {
43
+ throw new ClaudeSystemPrefixShapeError(`expected an array-shaped system prefix, got ${typeof system}`);
44
+ }
45
+ if (system.length !== expectedLength) {
46
+ throw new ClaudeSystemPrefixShapeError(`expected system.length === ${expectedLength} (fixed developer-block prefix), got ${system.length}`);
47
+ }
48
+ }
49
+ /**
50
+ * Throws, naming the offending index, when `messages` is not alternation-safe.
51
+ *
52
+ * Anthropic requires alternating `user`/`assistant` roles. A native Codex
53
+ * request can carry two consecutive `user`-role items (an auto-injected
54
+ * notice immediately followed by the literal user turn, or a synthetic
55
+ * `environment_context` pair) — mapped 1:1 into `ClaudeMessage` entries that
56
+ * would otherwise reach Anthropic as back-to-back `user` messages and get
57
+ * rejected on every request that hits it. Merging same-role Codex items into
58
+ * one Claude message with multiple content blocks is translation-correctness
59
+ * work owned by `translateCodexRequestToClaude`, not this guard's job — this
60
+ * only turns a missed merge into an attributable thrown error at the new
61
+ * hop's boundary instead of an undifferentiated Anthropic 400.
62
+ */
63
+ export function assertClaudeMessagesAlternate(messages) {
64
+ for (let i = 1; i < messages.length; i++) {
65
+ if (messages[i].role === messages[i - 1].role) {
66
+ throw new ClaudeSystemPrefixShapeError(`messages[${i}] repeats role "${messages[i].role}" from messages[${i - 1}]` +
67
+ " — Anthropic requires alternation; the Codex-request translator" +
68
+ " must merge same-role turns into one message with multiple content blocks");
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * The leading, turn-stable bytes of a native Codex request: a bounded head of
74
+ * the developer-role (instructions) text plus the declared tool names, mirroring
75
+ * `codexCachePrefix` (`codexFallback.ts`) for the opposite direction. Descriptions
76
+ * and schemas are deliberately left out — they sit behind the instructions in
77
+ * the prefix, so two requests agreeing on this much already share a cacheable
78
+ * head worth routing together.
79
+ */
80
+ const PREFIX_DIGEST_HEAD_CHARS = 8192;
81
+ function codexNativeInstructionsHead(native) {
82
+ const developerText = native.input
83
+ .filter((item) => item.type === "message" && item.role === "developer")
84
+ .flatMap((item) => "content" in item && Array.isArray(item.content) ? item.content : [])
85
+ .filter((part) => part.type === "input_text")
86
+ .map((part) => part.text)
87
+ .join("\n");
88
+ return developerText.slice(0, PREFIX_DIGEST_HEAD_CHARS);
89
+ }
90
+ function codexNativeToolNames(native) {
91
+ const additionalTools = native.input
92
+ .filter((item) => item.type === "additional_tools")
93
+ .at(-1);
94
+ if (!additionalTools) {
95
+ return "";
96
+ }
97
+ return additionalTools.tools
98
+ .flatMap((namespace) => namespace.tools.map((tool) => tool.name))
99
+ .join("\u0000");
100
+ }
101
+ /**
102
+ * Hashed bounded-head-plus-tool-names digest of a native Codex request's
103
+ * turn-stable prefix, used only as the last-resort affinity-key fallback
104
+ * (`codex-prefix:`) when the wire carries none of `prompt_cache_key` /
105
+ * `client_metadata.thread_id` / `client_metadata.session_id`. Hashed (rather
106
+ * than returned raw, as `codexCachePrefix` does) because this value is used
107
+ * directly as a routing/log key, not as sha256 input a caller hashes itself.
108
+ */
109
+ function codexNativePrefixDigest(native) {
110
+ const head = codexNativeInstructionsHead(native);
111
+ const toolNames = codexNativeToolNames(native);
112
+ if (!head && !toolNames) {
113
+ return undefined;
114
+ }
115
+ return createHash("sha256").update(`${head}\u0001${toolNames}`).digest("hex");
116
+ }
117
+ /**
118
+ * Derives the session-affinity key a Codex-origin request supplies to the
119
+ * Claude pipeline's existing session-affinity machinery, so consecutive
120
+ * turns of one conversation route back to the account already holding their
121
+ * cache. Preference order mirrors what a real Codex CLI capture carries:
122
+ * `prompt_cache_key` (top-level, the client's own pre-computed key — already
123
+ * exactly the signal being reconstructed) beats `client_metadata.thread_id`
124
+ * beats `client_metadata.session_id`; a genuinely identity-free request
125
+ * degrades to a prefix-derived key so at least concurrent turns of the same
126
+ * agent config bucket together.
127
+ *
128
+ * The three-way prefix (`codex-thread:` / `codex-session:` / `codex-prefix:`)
129
+ * keeps this namespace from colliding with a Claude-Code `metadata.user_id`-
130
+ * derived UUID or with itself across cases.
131
+ */
132
+ export function codexAnthropicAffinityKey(native) {
133
+ const promptCacheKey = native.prompt_cache_key;
134
+ if (typeof promptCacheKey === "string" && promptCacheKey.length > 0) {
135
+ return `codex-thread:${promptCacheKey}`;
136
+ }
137
+ const threadId = native.client_metadata?.thread_id;
138
+ if (typeof threadId === "string" && threadId.length > 0) {
139
+ return `codex-thread:${threadId}`;
140
+ }
141
+ const sessionId = native.client_metadata?.session_id;
142
+ if (typeof sessionId === "string" && sessionId.length > 0) {
143
+ return `codex-session:${sessionId}`;
144
+ }
145
+ const prefix = codexNativePrefixDigest(native);
146
+ return prefix ? `codex-prefix:${prefix}` : undefined;
147
+ }
@@ -17,8 +17,8 @@
17
17
  * `tool_use` block, no kind discriminator) knows which declared tools were
18
18
  * `custom` rather than `function`.
19
19
  */
20
- import type { ClaudeRequest } from "../types/index.js";
21
- import type { CodexNativeRequest, CodexNativeToolKind, CodexTranslationError } from "../types/index.js";
20
+ import type { ClaudeRequest, ClaudeTextBlock } from "../types/index.js";
21
+ import type { CodexNativeInputItem, CodexNativeRequest, CodexNativeToolKind, CodexOutboundFailureInput, CodexOutboundFallbackDecision, CodexTranslationError } from "../types/index.js";
22
22
  export declare function parseCodexNativeRequest(raw: unknown): {
23
23
  ok: true;
24
24
  value: CodexNativeRequest;
@@ -26,6 +26,7 @@ export declare function parseCodexNativeRequest(raw: unknown): {
26
26
  ok: false;
27
27
  error: CodexTranslationError;
28
28
  };
29
+ export declare function buildSystemBlocksFromDeveloperMessages(items: readonly CodexNativeInputItem[]): ClaudeTextBlock[];
29
30
  export declare function translateCodexRequestToClaude(request: CodexNativeRequest, target: {
30
31
  provider: "anthropic" | "vertex";
31
32
  model: string;
@@ -37,3 +38,27 @@ export declare function translateCodexRequestToClaude(request: CodexNativeReques
37
38
  ok: false;
38
39
  error: CodexTranslationError;
39
40
  };
41
+ /** Caps how many times one request may cross between engines (Codex->Anthropic
42
+ * or Anthropic->Codex) in a single turn. A crossing is an engine change, never
43
+ * a same-engine account rotation, so this bounds `Codex -> Anthropic ->
44
+ * Codex -> ...` cycles without limiting ordinary in-pool retries. */
45
+ export declare const MAX_ENGINE_CROSSINGS = 1;
46
+ /** Read a transport error code the same way `codexProxyRoutes.ts`'s own
47
+ * (unexported) `getCodexTransportErrorCode` does: a direct `.code`, falling
48
+ * back to `.cause.code`. Kept here, independently, as a pure utility the
49
+ * trigger-policy classifier can be exercised against without importing a
50
+ * route-handler module. */
51
+ export declare function parseCodexErrorCode(error: unknown): string | undefined;
52
+ /**
53
+ * Classify one `codexProxyRoutes.ts` `dispatch()` failure into an
54
+ * outbound-fallback eligibility decision. A total function over
55
+ * `CodexOutboundFailureClass` (exhaustive switch, no `default:`): a missing
56
+ * case fails to compile under `noImplicitReturns`, never falls through to a
57
+ * silent `undefined`.
58
+ *
59
+ * `context_preflight` / `token_budget` errors are never modeled here: both
60
+ * are self-contained terminal returns inside `dispatch()`'s own outer `try`,
61
+ * before any of the four insertion points this classifier serves, so they
62
+ * never reach it.
63
+ */
64
+ export declare function classifyCodexOutboundFailure(input: CodexOutboundFailureInput): CodexOutboundFallbackDecision;
@@ -1,6 +1,8 @@
1
1
  import { resolveClaudeMaxTokens } from "../utils/tokenLimits.js";
2
2
  import { inlineJsonSchema } from "../utils/schemaConversion.js";
3
+ import { modelSupportsForcedToolChoice, claudeDisabledThinkingReplacement, } from "../models/modelRegistry.js";
3
4
  import { parseToolArguments } from "./argumentParsing.js";
5
+ import { classifyProxyFailureCode } from "./proxyFailureDetails.js";
4
6
  import { recordCodexOutboundSchemaDegraded, recordCodexOutboundUnsupportedField, } from "./proxyTracer.js";
5
7
  import { CODEX_STREAM_MAX_TOOL_CALLS, CODEX_STREAM_SIZE_CEILING_BYTES, } from "./streamLimits.js";
6
8
  // ---------------------------------------------------------------------------
@@ -132,21 +134,35 @@ export function parseCodexNativeRequest(raw) {
132
134
  // output to attach to, regardless of whether the source request bothered to
133
135
  // say "auto" out loud. `parallel_tool_calls: false` is inert on `"none"` —
134
136
  // there is nothing running in parallel to disable.
135
- function mapCodexToolChoiceToClaude(choice, parallelToolCalls) {
137
+ //
138
+ // `targetModel` gates a second degradation: some Claude families (Sonnet
139
+ // 5.5, Opus 5.5, Fable/Mythos 5.1 — see modelSupportsForcedToolChoice) answer
140
+ // a forced tool_choice (`any`/named `tool`) with an HTTP 400. For those
141
+ // targets only, a forced choice is downgraded to `{type:"auto"}` (keeping
142
+ // `disable_parallel_tool_use` when the source asked for it) instead of going
143
+ // out as a request the target is guaranteed to reject. Every other target
144
+ // model is unaffected — this is a per-request, per-target decision, not a
145
+ // global behavior change.
146
+ function mapCodexToolChoiceToClaude(choice, parallelToolCalls, targetModel) {
136
147
  const disable = parallelToolCalls === false;
137
148
  if (choice === undefined || choice === "auto") {
138
149
  return disable
139
150
  ? { type: "auto", disable_parallel_tool_use: true }
140
151
  : { type: "auto" };
141
152
  }
153
+ if (choice === "none") {
154
+ return { type: "none" };
155
+ }
156
+ if (!modelSupportsForcedToolChoice(targetModel)) {
157
+ return disable
158
+ ? { type: "auto", disable_parallel_tool_use: true }
159
+ : { type: "auto" };
160
+ }
142
161
  if (choice === "required") {
143
162
  return disable
144
163
  ? { type: "any", disable_parallel_tool_use: true }
145
164
  : { type: "any" };
146
165
  }
147
- if (choice === "none") {
148
- return { type: "none" };
149
- }
150
166
  return disable
151
167
  ? { type: "tool", name: choice.name, disable_parallel_tool_use: true }
152
168
  : { type: "tool", name: choice.name };
@@ -206,8 +222,10 @@ function mapCodexFunctionToolToClaude(tool) {
206
222
  }
207
223
  function mapCodexCustomToolToClaude(tool) {
208
224
  // Lossy, explicitly flagged: no JSON Schema exists for a Lark/regex grammar.
209
- // The model is no longer grammar-constrained after this mapping — needs
210
- // product sign-off before shipping (§10 of the design).
225
+ // The model is no longer grammar-constrained after this mapping. SIGNED OFF
226
+ // 2026-09-28 (design doc §10, ruling 7): grammar/custom tools wrapped as a
227
+ // single `{input: string}` parameter, with `custom_tool_call` on the way
228
+ // back, is the shipped behavior — not a placeholder awaiting approval.
211
229
  return {
212
230
  tool: {
213
231
  name: tool.name,
@@ -262,11 +280,23 @@ const REASONING_BUDGET = {
262
280
  xhigh: 24576,
263
281
  max: 32768,
264
282
  };
265
- function mapCodexReasoningToThinking(reasoning, resolvedMaxTokens) {
283
+ // Codex "no reasoning" never goes out as an explicit `thinking:{type:"disabled"}`
284
+ // — some Claude 5.5/5.1 families reject that shape with a 400 (probed on
285
+ // Vertex, 2026-09-29) because they think adaptively and always on. What
286
+ // "thinking off" means for those targets is decided by
287
+ // claudeDisabledThinkingReplacement: `between_tools` (Sonnet 5.5's lowest
288
+ // setting) when available, otherwise `omit` (nothing sent — the model cannot
289
+ // be turned off, and the field is dropped rather than asking for a rejected
290
+ // state). For every target outside that family the function returns "keep",
291
+ // which here means the historical, unaffected behavior: omit the field.
292
+ function mapCodexReasoningToThinking(reasoning, resolvedMaxTokens, targetModel) {
266
293
  if (!reasoning ||
267
294
  reasoning.effort === "none" ||
268
295
  reasoning.effort === "minimal") {
269
- return undefined;
296
+ const replacement = claudeDisabledThinkingReplacement(targetModel, reasoning?.effort);
297
+ return replacement === "between_tools"
298
+ ? { type: "between_tools" }
299
+ : undefined;
270
300
  }
271
301
  const table = REASONING_BUDGET[reasoning.effort];
272
302
  // Explicit floor: makes the ">= 1024" invariant correct by construction
@@ -395,7 +425,12 @@ function coalesceCodexInputToClaudeMessages(items) {
395
425
  : g.blocks,
396
426
  }));
397
427
  }
398
- function buildSystemBlocksFromDeveloperMessages(items) {
428
+ // Exported so callers (codexProxyRoutes.ts's cache-preservation wiring) can
429
+ // independently recompute the expected system-prefix length from the same
430
+ // native request `translateCodexRequestToClaude` translated, for
431
+ // `assertClaudeSystemPrefixShape`'s guard against a future translation
432
+ // regression that leaks extra content into `system`.
433
+ export function buildSystemBlocksFromDeveloperMessages(items) {
399
434
  return items
400
435
  .filter((i) => i.type === "message" && i.role === "developer")
401
436
  .flatMap((i) => i.content.map(toClaudeContentPart))
@@ -554,7 +589,7 @@ export function translateCodexRequestToClaude(request, target) {
554
589
  .filter((item) => item.type === "additional_tools")
555
590
  .at(-1);
556
591
  const { tools, toolKindByName, reasons: schemaReasons, } = buildClaudeTools(additionalTools);
557
- const toolChoice = mapCodexToolChoiceToClaude(request.tool_choice, request.parallel_tool_calls);
592
+ const toolChoice = mapCodexToolChoiceToClaude(request.tool_choice, request.parallel_tool_calls, target.model);
558
593
  const forcesTool = toolChoice.type === "any" || toolChoice.type === "tool";
559
594
  if (forcesTool && !tools) {
560
595
  return {
@@ -601,7 +636,7 @@ export function translateCodexRequestToClaude(request, target) {
601
636
  // its reasoning is OpenAI ciphertext.
602
637
  const thinking = forcesTool || lastAssistantMessageUsesTool(messages)
603
638
  ? undefined
604
- : mapCodexReasoningToThinking(request.reasoning, maxTokens);
639
+ : mapCodexReasoningToThinking(request.reasoning, maxTokens, target.model);
605
640
  const claudeRequest = {
606
641
  model: target.model,
607
642
  messages,
@@ -633,3 +668,104 @@ export function translateCodexRequestToClaude(request, target) {
633
668
  }
634
669
  return { ok: true, value: claudeRequest, toolKindByName };
635
670
  }
671
+ // ---------------------------------------------------------------------------
672
+ // §1 Trigger policy (stage-c-trigger.md). Pure classification of a native-
673
+ // Codex-route failure into an outbound-fallback eligibility decision. Owns no
674
+ // dispatch, no HTTP, no account state — `codexProxyRoutes.ts`'s four insertion
675
+ // points call this and still apply their own config/loop-prevention/depth
676
+ // gates on top of a `true` verdict.
677
+ // ---------------------------------------------------------------------------
678
+ /** Caps how many times one request may cross between engines (Codex->Anthropic
679
+ * or Anthropic->Codex) in a single turn. A crossing is an engine change, never
680
+ * a same-engine account rotation, so this bounds `Codex -> Anthropic ->
681
+ * Codex -> ...` cycles without limiting ordinary in-pool retries. */
682
+ export const MAX_ENGINE_CROSSINGS = 1;
683
+ /** Read a transport error code the same way `codexProxyRoutes.ts`'s own
684
+ * (unexported) `getCodexTransportErrorCode` does: a direct `.code`, falling
685
+ * back to `.cause.code`. Kept here, independently, as a pure utility the
686
+ * trigger-policy classifier can be exercised against without importing a
687
+ * route-handler module. */
688
+ export function parseCodexErrorCode(error) {
689
+ if (!error || typeof error !== "object") {
690
+ return undefined;
691
+ }
692
+ const directCode = error.code;
693
+ if (typeof directCode === "string") {
694
+ return directCode;
695
+ }
696
+ const cause = error.cause;
697
+ if (!cause || typeof cause !== "object") {
698
+ return undefined;
699
+ }
700
+ const causeCode = cause.code;
701
+ return typeof causeCode === "string" ? causeCode : undefined;
702
+ }
703
+ /**
704
+ * Classify one `codexProxyRoutes.ts` `dispatch()` failure into an
705
+ * outbound-fallback eligibility decision. A total function over
706
+ * `CodexOutboundFailureClass` (exhaustive switch, no `default:`): a missing
707
+ * case fails to compile under `noImplicitReturns`, never falls through to a
708
+ * silent `undefined`.
709
+ *
710
+ * `context_preflight` / `token_budget` errors are never modeled here: both
711
+ * are self-contained terminal returns inside `dispatch()`'s own outer `try`,
712
+ * before any of the four insertion points this classifier serves, so they
713
+ * never reach it.
714
+ */
715
+ export function classifyCodexOutboundFailure(input) {
716
+ switch (input.failureClass) {
717
+ case "no_accounts":
718
+ return {
719
+ eligible: true,
720
+ reason: "no Codex accounts are configured for this request",
721
+ };
722
+ case "pool_exhausted":
723
+ return {
724
+ eligible: true,
725
+ reason: "every configured Codex account is cooling down or otherwise unhealthy",
726
+ };
727
+ case "loop_fallthrough": {
728
+ // A bare 401/403 is a credential failure on Codex's own OAuth pool,
729
+ // which says nothing about the fallback target; only the error code can
730
+ // tell a content-policy 403 from an auth one.
731
+ if (classifyProxyFailureCode(input.lastErrorCode).retryable === false) {
732
+ return {
733
+ eligible: false,
734
+ reason: `the last exhausted account attempt ended in ${input.lastErrorCode ?? "unknown"}, a rejection of the request itself rather than a Codex pool failure`,
735
+ };
736
+ }
737
+ const status = input.lastStatus;
738
+ if (status !== undefined &&
739
+ status >= 400 &&
740
+ status < 500 &&
741
+ status !== 401 &&
742
+ status !== 403 &&
743
+ status !== 429) {
744
+ return {
745
+ eligible: false,
746
+ reason: `the last exhausted account attempt ended in a ${status}, a rejection of the request itself rather than a Codex pool failure`,
747
+ };
748
+ }
749
+ return {
750
+ eligible: true,
751
+ reason: "the account-retry loop exhausted every eligible Codex account without a terminal response",
752
+ };
753
+ }
754
+ case "non_retryable_transport": {
755
+ // Reuses the same classifier the Claude engine's own failure handling
756
+ // relies on, as loop_fallthrough does: a content-policy or
757
+ // invalid-request code is not a pool problem, and not something a
758
+ // different upstream is likely to answer differently.
759
+ if (classifyProxyFailureCode(input.transportErrorCode).retryable === false) {
760
+ return {
761
+ eligible: false,
762
+ reason: `transport error ${input.transportErrorCode ?? "unknown"} rejects the request itself, not a Codex pool failure`,
763
+ };
764
+ }
765
+ return {
766
+ eligible: true,
767
+ reason: `non-retryable transport error ${input.transportErrorCode ?? "unknown"} exhausted this Codex account attempt`,
768
+ };
769
+ }
770
+ }
771
+ }
@@ -83,14 +83,20 @@ function mapClaudeStopReasonToCodex(stopReason) {
83
83
  export function synthesizeCodexUsage(usage) {
84
84
  const cacheRead = usage.cache_read_input_tokens;
85
85
  const cacheCreation = usage.cache_creation_input_tokens;
86
+ const cacheCreation1h = usage.cacheCreation1hTokens;
86
87
  const inputTokens = usage.input_tokens + (cacheRead ?? 0) + (cacheCreation ?? 0);
87
- const details = cacheRead === undefined && cacheCreation === undefined
88
+ const details = cacheRead === undefined &&
89
+ cacheCreation === undefined &&
90
+ cacheCreation1h === undefined
88
91
  ? undefined
89
92
  : {
90
93
  ...(cacheRead === undefined ? {} : { cached_tokens: cacheRead }),
91
94
  ...(cacheCreation === undefined
92
95
  ? {}
93
96
  : { cache_write_tokens: cacheCreation }),
97
+ ...(cacheCreation1h === undefined
98
+ ? {}
99
+ : { cache_write_1h_tokens: cacheCreation1h }),
94
100
  };
95
101
  return {
96
102
  input_tokens: inputTokens,
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { preflightAnthropicStream } from "./streamOutcome.js";
23
23
  import { extractSSEEvents } from "./sseInterceptor.js";
24
+ import { readCacheCreation1hTokens, oneHourCacheWriteFields, } from "./proxyTokenUsage.js";
24
25
  import { CodexResponsesStreamSerializer, serializeCodexResponse, } from "./codexResponsesFormat.js";
25
26
  import { CODEX_STREAM_MAX_TOOL_CALLS, CODEX_STREAM_SIZE_CEILING_BYTES, } from "./streamLimits.js";
26
27
  /** Empty by default so an existing caller (no `toolKindByName` argument) keeps
@@ -144,6 +145,7 @@ export async function consumeAnthropicFallbackResponse(response, model, toolKind
144
145
  const usage = parsed.usage;
145
146
  const cacheCreate = optionalNumber(usage.cache_creation_input_tokens);
146
147
  const cacheRead = optionalNumber(usage.cache_read_input_tokens);
148
+ const cacheCreate1h = readCacheCreation1hTokens(usage);
147
149
  const result = {
148
150
  ...parsed,
149
151
  usage: {
@@ -155,6 +157,7 @@ export async function consumeAnthropicFallbackResponse(response, model, toolKind
155
157
  ...(cacheRead === undefined
156
158
  ? {}
157
159
  : { cache_read_input_tokens: cacheRead }),
160
+ ...oneHourCacheWriteFields(cacheCreate1h),
158
161
  },
159
162
  };
160
163
  return serializeCodexResponse(result, model, toolKindByName);
@@ -204,6 +207,7 @@ export async function createAnthropicFallbackStream(response, model, toolKindByN
204
207
  let capturedOutput = 0;
205
208
  let capturedCacheCreate;
206
209
  let capturedCacheRead;
210
+ let capturedCacheCreate1h;
207
211
  let capturedStopReason = null;
208
212
  // input_json_delta also streams for server_tool_use blocks, which have no
209
213
  // Codex function_call to feed; only a tool_use block's fragments count.
@@ -220,6 +224,7 @@ export async function createAnthropicFallbackStream(response, model, toolKindByN
220
224
  capturedInput = nonNegativeNumber(usage.input_tokens);
221
225
  capturedCacheCreate = optionalNumber(usage.cache_creation_input_tokens);
222
226
  capturedCacheRead = optionalNumber(usage.cache_read_input_tokens);
227
+ capturedCacheCreate1h = readCacheCreation1hTokens(usage);
223
228
  }
224
229
  return undefined;
225
230
  }
@@ -304,6 +309,7 @@ export async function createAnthropicFallbackStream(response, model, toolKindByN
304
309
  ...(capturedCacheRead === undefined
305
310
  ? {}
306
311
  : { cache_read_input_tokens: capturedCacheRead }),
312
+ ...oneHourCacheWriteFields(capturedCacheCreate1h),
307
313
  });
308
314
  case "error": {
309
315
  // A mid-stream upstream error ends the turn: without this case it
@@ -512,6 +512,9 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
512
512
  outputTokens: request.outputTokens ?? 0,
513
513
  cacheCreationTokens: request.cacheCreationTokens ?? 0,
514
514
  cacheReadTokens: request.cacheReadTokens ?? 0,
515
+ ...(request.cacheCreation1hTokens !== null
516
+ ? { cacheCreation1hTokens: request.cacheCreation1hTokens }
517
+ : {}),
515
518
  });
516
519
  requestsWithUsage += 1;
517
520
  inputTokens += billingUsage.input;
@@ -1088,6 +1091,7 @@ export async function analyzeProxyLogs(options) {
1088
1091
  outputTokens: finiteNumber(record.outputTokens),
1089
1092
  cacheReadTokens: finiteNumber(record.cacheReadTokens),
1090
1093
  cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
1094
+ cacheCreation1hTokens: finiteNumber(record.cacheCreation1hTokens),
1091
1095
  // Read as a boolean only. An absent flag means observed, which keeps
1092
1096
  // every record written before the flag existed counting as it did.
1093
1097
  // The merge below drops undefined from the later record, so a