@juspay/neurolink 12.20.1 → 12.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,21 +23,39 @@ export declare class CodexFallbackStreamError extends Error {
23
23
  constructor(payload: Record<string, unknown>, eventType: string, usage?: CodexFallbackResult["usage"]);
24
24
  }
25
25
  /**
26
- * Stable per-conversation routing key for OpenAI prompt caching.
26
+ * Routing key that pins a request to the cache already holding its prefix.
27
27
  *
28
28
  * Anthropic caching is explicit — the client marks breakpoints and the provider
29
29
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
30
30
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
31
- * what pins a conversation to one. Without it this hop takes its chances on
32
- * routing, which is the worst case for a fallback that arrives in bursts
33
- * separated by long gaps.
31
+ * what pins a request to one. Without it this hop takes its chances on routing,
32
+ * which is the worst case for a fallback that arrives in bursts separated by
33
+ * long gaps.
34
34
  *
35
- * Claude Code carries its session id inside `metadata.user_id`, alongside
36
- * account and device identifiers. Only the session id is read, and it is
37
- * hashed, so no account or device identifier reaches the upstream. When no
38
- * session id can be recovered the field is omitted rather than filled with
39
- * something shared: a key common to every conversation would pin unrelated
35
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
36
+ * session id gives every session a private namespace, so N agents that share
37
+ * one system prompt and tool set each pay that shared prefix in full rather
38
+ * than paying it once — the cost is linear in fan-out width and invisible,
39
+ * because each request looks individually well-behaved. Across 42 captured
40
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
41
+ * shared by 12 requests belonging to different sessions.
42
+ *
43
+ * So derive the key from the prefix itself: the instructions and the tool
44
+ * declarations, which are exactly the leading bytes the upstream caches and
45
+ * the only part stable across a conversation's turns. Identical prefixes then
46
+ * share one cache across sessions and subagents, a prefix that genuinely
47
+ * differs still gets its own key so nothing unrelated contends, and turns
48
+ * within one session keep sharing a key because neither input changes between
49
+ * them.
50
+ *
51
+ * A request carrying neither instructions nor tools has no prefix worth
52
+ * pinning, and falls back to the session id so its turns at least stay
53
+ * together. With neither available the field is omitted rather than filled
54
+ * with something shared: a key common to every request would pin unrelated
40
55
  * prefixes onto one cache instead of separating them.
56
+ *
57
+ * The digest covers only content already sent upstream in `instructions` and
58
+ * `tools`; no account, device or session identifier reaches the wire.
41
59
  */
42
60
  export declare function codexPromptCacheKey(body: ClaudeRequest): string | undefined;
43
61
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
@@ -237,23 +237,95 @@ function convertClaudeMessage(role, content) {
237
237
  return input;
238
238
  }
239
239
  /**
240
- * Stable per-conversation routing key for OpenAI prompt caching.
240
+ * Routing key that pins a request to the cache already holding its prefix.
241
241
  *
242
242
  * Anthropic caching is explicit — the client marks breakpoints and the provider
243
243
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
244
244
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
245
- * what pins a conversation to one. Without it this hop takes its chances on
246
- * routing, which is the worst case for a fallback that arrives in bursts
247
- * separated by long gaps.
245
+ * what pins a request to one. Without it this hop takes its chances on routing,
246
+ * which is the worst case for a fallback that arrives in bursts separated by
247
+ * long gaps.
248
248
  *
249
- * Claude Code carries its session id inside `metadata.user_id`, alongside
250
- * account and device identifiers. Only the session id is read, and it is
251
- * hashed, so no account or device identifier reaches the upstream. When no
252
- * session id can be recovered the field is omitted rather than filled with
253
- * something shared: a key common to every conversation would pin unrelated
249
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
250
+ * session id gives every session a private namespace, so N agents that share
251
+ * one system prompt and tool set each pay that shared prefix in full rather
252
+ * than paying it once — the cost is linear in fan-out width and invisible,
253
+ * because each request looks individually well-behaved. Across 42 captured
254
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
255
+ * shared by 12 requests belonging to different sessions.
256
+ *
257
+ * So derive the key from the prefix itself: the instructions and the tool
258
+ * declarations, which are exactly the leading bytes the upstream caches and
259
+ * the only part stable across a conversation's turns. Identical prefixes then
260
+ * share one cache across sessions and subagents, a prefix that genuinely
261
+ * differs still gets its own key so nothing unrelated contends, and turns
262
+ * within one session keep sharing a key because neither input changes between
263
+ * them.
264
+ *
265
+ * A request carrying neither instructions nor tools has no prefix worth
266
+ * pinning, and falls back to the session id so its turns at least stay
267
+ * together. With neither available the field is omitted rather than filled
268
+ * with something shared: a key common to every request would pin unrelated
254
269
  * prefixes onto one cache instead of separating them.
270
+ *
271
+ * The digest covers only content already sent upstream in `instructions` and
272
+ * `tools`; no account, device or session identifier reaches the wire.
255
273
  */
256
274
  export function codexPromptCacheKey(body) {
275
+ const prefix = codexCachePrefix(body);
276
+ if (prefix !== undefined) {
277
+ return createHash("sha256").update(prefix).digest("hex");
278
+ }
279
+ const sessionId = codexSessionId(body);
280
+ if (sessionId !== undefined) {
281
+ return createHash("sha256").update(sessionId).digest("hex");
282
+ }
283
+ return undefined;
284
+ }
285
+ /**
286
+ * How much of the instructions the key covers.
287
+ *
288
+ * Claude Code's system prompt is not byte-stable: measured across live 16,722
289
+ * character prompts, the first 14,768 characters (88.3%) are identical and the
290
+ * divergence sits in a trailing session-memory section that is rewritten every
291
+ * turn. Hashing the whole thing therefore produces a fresh key per request —
292
+ * the opposite of pinning. Hashing a bounded head instead tracks the part the
293
+ * upstream can actually reuse, since its cache is built forward from the first
294
+ * token.
295
+ *
296
+ * 8192 is below the observed divergence point with margin, and the error is
297
+ * asymmetric: too long only costs sharing, while too short pins genuinely
298
+ * unrelated prefixes onto one cache where they evict each other.
299
+ *
300
+ * The bound only helps a prompt long enough to have a tail beyond it. A
301
+ * shorter prompt is hashed whole, so a churning section inside it would move
302
+ * the key every turn — where keying by session used to hold still. Measured on
303
+ * the same corpus, that does not happen: 85 of 92 prompts carrying a system
304
+ * block sit under this bound, and within a session they resolve to a handful
305
+ * of keys rather than one per request (28 requests to 4 keys, 17 to 2, 31 to
306
+ * 2) — those are distinct agents, not one agent churning. Identifying the
307
+ * stable section structurally would remove the assumption, and is worth doing
308
+ * if a prompt ever proves otherwise.
309
+ */
310
+ const CACHE_KEY_PREFIX_CHARS = 8192;
311
+ /**
312
+ * The leading, turn-stable bytes of a Codex request: a bounded head of the
313
+ * instructions plus the tool names, which distinguish one agent's tool surface
314
+ * from another's when their instructions open identically. Descriptions and
315
+ * schemas are deliberately left out — they sit behind the instructions in the
316
+ * prefix, so two requests agreeing on this much already share a cacheable head
317
+ * worth routing together.
318
+ */
319
+ function codexCachePrefix(body) {
320
+ const head = (buildSystemInstructions(body) ?? "").slice(0, CACHE_KEY_PREFIX_CHARS);
321
+ const toolNames = (body.tools ?? []).map((tool) => tool.name).join("\u0000");
322
+ if (!head && !toolNames) {
323
+ return undefined;
324
+ }
325
+ return `${head}\u0001${toolNames}`;
326
+ }
327
+ /** Claude Code carries its session id inside `metadata.user_id`. */
328
+ function codexSessionId(body) {
257
329
  const raw = body.metadata?.user_id;
258
330
  if (typeof raw !== "string" || raw.length === 0) {
259
331
  return undefined;
@@ -270,10 +342,9 @@ export function codexPromptCacheKey(body) {
270
342
  }
271
343
  const fields = parsed;
272
344
  const sessionId = fields.session_id ?? fields.parent_session_id;
273
- if (typeof sessionId !== "string" || sessionId.length === 0) {
274
- return undefined;
275
- }
276
- return createHash("sha256").update(sessionId).digest("hex");
345
+ return typeof sessionId === "string" && sessionId.length > 0
346
+ ? sessionId
347
+ : undefined;
277
348
  }
278
349
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
279
350
  export function convertClaudeRequestToCodex(body, model, reasoningEffort) {
@@ -11,6 +11,7 @@
11
11
  import { jsonSchema, tool } from "../utils/tool.js";
12
12
  import { randomBytes } from "crypto";
13
13
  import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js";
14
+ import { applyClaudeRequestCacheBreakpoints } from "../utils/anthropicCacheBreakpoints.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers
16
17
  // ---------------------------------------------------------------------------
@@ -603,7 +604,13 @@ export function convertOpenAIToClaudeRequest(openai) {
603
604
  ? openai.stop
604
605
  : [openai.stop];
605
606
  }
606
- return result;
607
+ // An OpenAI-shaped caller has no way to ask for caching: its provider does it
608
+ // automatically from the first token, so the format carries no equivalent of
609
+ // `cache_control`. Translating faithfully therefore produces a request
610
+ // Anthropic caches at 0% on every turn — and a caller that arrives here after
611
+ // its own pool ran dry would burn the replacement quota faster than the one
612
+ // it was spared. Synthesise the breakpoints the caller could not express.
613
+ return applyClaudeRequestCacheBreakpoints(result);
607
614
  }
608
615
  /**
609
616
  * Convert a non-streaming Claude Messages response to an OpenAI Chat
@@ -12,7 +12,7 @@
12
12
  * `tool_use`, `tool_result`, `cache_control` and thinking blocks all survive
13
13
  * here because nothing reshapes them.
14
14
  */
15
- import type { UsageContext, VertexPassthroughTerminal, VertexAnthropicPassthroughRequest, VertexAnthropicPassthroughTarget } from "../types/index.js";
15
+ import type { VertexAccessTokenProvider, UsageContext, VertexPassthroughTerminal, VertexAnthropicPassthroughRequest, VertexAnthropicPassthroughTarget } from "../types/index.js";
16
16
  /**
17
17
  * Translate `output_config` for Vertex.
18
18
  *
@@ -29,6 +29,22 @@ export declare function buildVertexAnthropicPayload(body: Readonly<Record<string
29
29
  * caller's `stream` flag selects the verb rather than changing the body.
30
30
  */
31
31
  export declare function buildVertexAnthropicUrl(target: VertexAnthropicPassthroughTarget): string;
32
+ /**
33
+ * Replace the Google credential lookup, for tests only.
34
+ *
35
+ * Without this seam the two-leg fallback chain cannot be driven end to end.
36
+ * The rest of the hop is reachable — `dispatchVertexAnthropicPassthrough`
37
+ * calls the global `fetch`, which a test can replace — but the credential
38
+ * lookup goes through gaxios, and gaxios resolves its transport to
39
+ * `(await import("node-fetch")).default` rather than `globalThis.fetch`. A
40
+ * test that swaps the global therefore cannot intercept the token exchange,
41
+ * and on a machine with no Application Default Credentials the leg throws
42
+ * before it ever reaches the model. That left the one path that bills in real
43
+ * currency with no end-to-end coverage at all.
44
+ *
45
+ * Pass `undefined` to restore the real credential lookup.
46
+ */
47
+ export declare function setVertexAccessTokenProviderForTests(provider: VertexAccessTokenProvider | undefined): void;
32
48
  /**
33
49
  * Send one Anthropic-shaped request to Vertex and return the upstream response
34
50
  * untouched, so the caller can stream its bytes straight to the client.
@@ -135,12 +135,36 @@ function auth() {
135
135
  cachedAuth ??= new GoogleAuth({ scopes: [CLOUD_PLATFORM_SCOPE] });
136
136
  return cachedAuth;
137
137
  }
138
+ let accessTokenProviderForTests;
139
+ async function vertexAccessToken() {
140
+ return accessTokenProviderForTests
141
+ ? accessTokenProviderForTests()
142
+ : auth().getAccessToken();
143
+ }
144
+ /**
145
+ * Replace the Google credential lookup, for tests only.
146
+ *
147
+ * Without this seam the two-leg fallback chain cannot be driven end to end.
148
+ * The rest of the hop is reachable — `dispatchVertexAnthropicPassthrough`
149
+ * calls the global `fetch`, which a test can replace — but the credential
150
+ * lookup goes through gaxios, and gaxios resolves its transport to
151
+ * `(await import("node-fetch")).default` rather than `globalThis.fetch`. A
152
+ * test that swaps the global therefore cannot intercept the token exchange,
153
+ * and on a machine with no Application Default Credentials the leg throws
154
+ * before it ever reaches the model. That left the one path that bills in real
155
+ * currency with no end-to-end coverage at all.
156
+ *
157
+ * Pass `undefined` to restore the real credential lookup.
158
+ */
159
+ export function setVertexAccessTokenProviderForTests(provider) {
160
+ accessTokenProviderForTests = provider;
161
+ }
138
162
  /**
139
163
  * Send one Anthropic-shaped request to Vertex and return the upstream response
140
164
  * untouched, so the caller can stream its bytes straight to the client.
141
165
  */
142
166
  export async function dispatchVertexAnthropicPassthrough(request) {
143
- const token = await auth().getAccessToken();
167
+ const token = await vertexAccessToken();
144
168
  if (!token) {
145
169
  throw new Error("Vertex passthrough could not obtain a Google access token; " +
146
170
  "check Application Default Credentials for this project");
@@ -175,6 +175,22 @@ declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>)
175
175
  * diagnostics the operator wants.
176
176
  */
177
177
  declare function redactHeadersForBorrower(headers: Record<string, string>): Record<string, string>;
178
+ /**
179
+ * The model that actually served a fallback turn.
180
+ *
181
+ * One request can leave more than one leg's model in metadata: the Codex leg
182
+ * records its model on the failure path before the chain moves on, and the
183
+ * configured chain tries Codex first. Choosing by key precedence therefore
184
+ * billed a Vertex-served turn as the Codex model — the exact misattribution
185
+ * this file's pricing fix exists to prevent. The account type that reached
186
+ * `logFinalRequest` is the only authoritative signal for which leg answered,
187
+ * so select on that and ignore the other leg's leftovers.
188
+ */
189
+ export declare function resolveServedFallbackModel(metadata: {
190
+ codexFallbackModel?: unknown;
191
+ vertexFallbackModel?: unknown;
192
+ }, accountType: string | undefined): string | undefined;
193
+ declare function clearClaudeFallbackTerminalEvidence(ctx: ServerContext): void;
178
194
  declare function executeClaudeFallbackTranslation(args: {
179
195
  ctx: ServerContext;
180
196
  body: ClaudeRequest;
@@ -462,6 +478,7 @@ export declare function isTransientHttpFailure(status: number, errBody: string):
462
478
  export declare function isUpstreamOverload(status: number, errBody: string): boolean;
463
479
  export declare function redactProviderErrorMessage(message: string): string;
464
480
  export declare const __testHooks: {
481
+ clearClaudeFallbackTerminalEvidence: typeof clearClaudeFallbackTerminalEvidence;
465
482
  normalizeQuotaForAccounts: typeof normalizeQuotaForAccounts;
466
483
  resolveHomeIndex: typeof resolveHomeIndex;
467
484
  maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
@@ -100,6 +100,16 @@ function resolveRequestLogAccountIdentity(accountLabel, accountType) {
100
100
  provider: "anthropic",
101
101
  };
102
102
  }
103
+ if (accountType === "vertex") {
104
+ // A native fallback leg has no OAuth login — the account IS the served
105
+ // model, e.g. "vertex/claude-opus-4-6". Without this branch `provider`
106
+ // stays unset, `annotateRequestPricing` falls back to
107
+ // "openai-compatible", and that resolves to a cross-provider scan which
108
+ // prices the turn from the *requested* alias in the Anthropic table
109
+ // instead of the served model in the Vertex one — a 2.5x under-report on
110
+ // the only leg that bills in real currency.
111
+ return { accountKey: accountLabel, provider: "vertex" };
112
+ }
103
113
  return {};
104
114
  }
105
115
  // ---------------------------------------------------------------------------
@@ -3567,11 +3577,29 @@ async function loadClaudeProxyAccounts(args) {
3567
3577
  isClaudeClientRequest: isLikelyClaudeClient(clientHeaders, clientSnapshotBody),
3568
3578
  };
3569
3579
  }
3580
+ /**
3581
+ * The model that actually served a fallback turn.
3582
+ *
3583
+ * One request can leave more than one leg's model in metadata: the Codex leg
3584
+ * records its model on the failure path before the chain moves on, and the
3585
+ * configured chain tries Codex first. Choosing by key precedence therefore
3586
+ * billed a Vertex-served turn as the Codex model — the exact misattribution
3587
+ * this file's pricing fix exists to prevent. The account type that reached
3588
+ * `logFinalRequest` is the only authoritative signal for which leg answered,
3589
+ * so select on that and ignore the other leg's leftovers.
3590
+ */
3591
+ export function resolveServedFallbackModel(metadata, accountType) {
3592
+ const served = accountType === "vertex"
3593
+ ? metadata.vertexFallbackModel
3594
+ : metadata.codexFallbackModel;
3595
+ return typeof served === "string" && served.length > 0 ? served : undefined;
3596
+ }
3570
3597
  function clearClaudeFallbackTerminalEvidence(ctx) {
3571
3598
  for (const key of [
3572
3599
  "codexFallbackAccount",
3573
3600
  "codexFallbackAccountType",
3574
3601
  "codexFallbackModel",
3602
+ "vertexFallbackModel",
3575
3603
  "codexFallbackFailureUsage",
3576
3604
  "codexFallbackDeferredFailure",
3577
3605
  "sdkFallbackFailure",
@@ -4054,6 +4082,19 @@ async function tryConfiguredClaudeFallbackChain(args) {
4054
4082
  // the caller's pending tool call, which strands every agentic turn.
4055
4083
  const vertexModel = fallback.model;
4056
4084
  const vertexAccount = `vertex/${vertexModel}`;
4085
+ // A Codex leg that already failed left its own terminal evidence
4086
+ // behind — its model, account, deferred-failure flag and failure
4087
+ // usage. Every one of those is read again when this turn finalizes:
4088
+ // the model would be logged and priced in place of the Vertex one,
4089
+ // the deferred-failure branch would re-attribute the tracer to
4090
+ // "openai" after the Vertex leg set it, and the failure usage would
4091
+ // merge its reasoning tokens into this turn's counts. Start the leg
4092
+ // clean, the same way a fresh upstream dispatch does.
4093
+ clearClaudeFallbackTerminalEvidence(ctx);
4094
+ // Billing follows the model that served the turn. The request log
4095
+ // otherwise records `body.model` — the alias the client asked for —
4096
+ // and prices Opus-on-Vertex at Sonnet rates.
4097
+ ctx.metadata.vertexFallbackModel = vertexModel;
4057
4098
  response = await executeVertexAnthropicFallback({
4058
4099
  body,
4059
4100
  model: vertexModel,
@@ -6212,18 +6253,24 @@ function createClaudeRequestRuntimeContext(args) {
6212
6253
  recordFinalSuccess(finalAccountLabel, finalAccountType);
6213
6254
  }
6214
6255
  const traceCtx = tracer?.getTraceContext();
6256
+ const servedModel = resolveServedFallbackModel(ctx.metadata, finalAccountType);
6215
6257
  logRequest({
6216
6258
  timestamp: new Date().toISOString(),
6217
6259
  requestId: ctx.requestId,
6218
6260
  method: ctx.method,
6219
6261
  path: ctx.path,
6220
- model: typeof ctx.metadata.codexFallbackModel === "string"
6221
- ? ctx.metadata.codexFallbackModel
6222
- : body.model,
6223
- ...(typeof ctx.metadata.codexFallbackModel === "string"
6262
+ model: servedModel ?? body.model,
6263
+ ...(servedModel
6224
6264
  ? {
6225
6265
  requestedModel: body.model,
6226
- inputIncludesCachedTokens: false,
6266
+ // Codex reports input inclusive of cached tokens; the Vertex
6267
+ // Anthropic wire reports them separately, exactly as the native
6268
+ // path does, so the flag stays Codex-only. Keyed off the serving
6269
+ // account for the same reason `servedModel` is: a failed Codex leg
6270
+ // leaves its model behind on a turn Vertex went on to serve.
6271
+ ...(finalAccountType === "vertex"
6272
+ ? {}
6273
+ : { inputIncludesCachedTokens: false }),
6227
6274
  }
6228
6275
  : {}),
6229
6276
  ...(ctx.metadata.contextPreflight
@@ -8777,6 +8824,7 @@ export function redactProviderErrorMessage(message) {
8777
8824
  // spinning up a full proxy. Keep this surface small.
8778
8825
  // ---------------------------------------------------------------------------
8779
8826
  export const __testHooks = {
8827
+ clearClaudeFallbackTerminalEvidence,
8780
8828
  normalizeQuotaForAccounts,
8781
8829
  resolveHomeIndex,
8782
8830
  maybeResetPrimaryToHome,
@@ -2281,6 +2281,14 @@ export type VertexAnthropicTool = {
2281
2281
  };
2282
2282
  cache_control?: VertexAnthropicCacheControl;
2283
2283
  };
2284
+ /**
2285
+ * Supplies the bearer token for a Vertex publisher call.
2286
+ *
2287
+ * Exists so a test can stand in for the Google credential lookup: gaxios
2288
+ * resolves its transport to node-fetch rather than `globalThis.fetch`, so a
2289
+ * test that swaps the global cannot intercept the token exchange.
2290
+ */
2291
+ export type VertexAccessTokenProvider = () => Promise<string | null | undefined>;
2284
2292
  /** Input to `applyVertexAnthropicCacheBreakpoints`. */
2285
2293
  export type VertexAnthropicCacheInput = {
2286
2294
  system?: string;
@@ -42,10 +42,27 @@ export type ModelRouterInterface = {
42
42
  getModelMappings?: () => ModelMapping[];
43
43
  getPassthroughModels?: () => string[];
44
44
  };
45
+ /**
46
+ * Anthropic prompt-cache breakpoint marker.
47
+ *
48
+ * Anthropic caching is explicit: the prefix up to each marker is cached, and a
49
+ * request carrying none is cached not at all. Four markers is the hard ceiling.
50
+ */
51
+ export type ClaudeCacheControl = {
52
+ type: "ephemeral";
53
+ /**
54
+ * How long the marked prefix stays cached. The API accepts a 5-minute or a
55
+ * 1-hour window, priced at 1.25x and 2x base input respectively; omitting it
56
+ * means 5 minutes. Without this field a caller cannot express the 1-hour
57
+ * marker at all.
58
+ */
59
+ ttl?: "5m" | "1h";
60
+ };
45
61
  /** A single text block in a Claude content array. */
46
62
  export type ClaudeTextBlock = {
47
63
  type: "text";
48
64
  text: string;
65
+ cache_control?: ClaudeCacheControl;
49
66
  };
50
67
  /** A single image block in a Claude content array. */
51
68
  export type ClaudeImageBlock = {
@@ -69,6 +86,7 @@ export type ClaudeToolResultBlock = {
69
86
  type: "tool_result";
70
87
  tool_use_id: string;
71
88
  content: string | ClaudeContentBlock[];
89
+ cache_control?: ClaudeCacheControl;
72
90
  };
73
91
  /** A thinking/reasoning block in a Claude content array. */
74
92
  export type ClaudeThinkingBlock = {
@@ -86,6 +104,7 @@ export type ClaudeTool = {
86
104
  name: string;
87
105
  description?: string;
88
106
  input_schema: Record<string, unknown>;
107
+ cache_control?: ClaudeCacheControl;
89
108
  };
90
109
  /** Metadata attached to a Claude Messages API request. */
91
110
  export type ClaudeMetadata = {
@@ -99,10 +118,7 @@ export type ClaudeRequest = {
99
118
  model: string;
100
119
  messages: ClaudeMessage[];
101
120
  max_tokens: number;
102
- system?: string | Array<{
103
- type: "text";
104
- text: string;
105
- }>;
121
+ system?: string | ClaudeTextBlock[];
106
122
  temperature?: number;
107
123
  top_p?: number;
108
124
  top_k?: number;
@@ -1,4 +1,4 @@
1
- import type { VertexAnthropicCacheInput, VertexAnthropicCacheOutput, VertexAnthropicMessage } from "../types/index.js";
1
+ import type { ClaudeRequest, VertexAnthropicCacheInput, VertexAnthropicCacheOutput, VertexAnthropicMessage } from "../types/index.js";
2
2
  /** Anthropic allows at most four `cache_control` breakpoints per request. */
3
3
  export declare const ANTHROPIC_MAX_CACHE_BREAKPOINTS = 4;
4
4
  /**
@@ -41,3 +41,24 @@ export declare function countAnthropicCacheMarkers(input: {
41
41
  * the input array is cloned, never mutated.
42
42
  */
43
43
  export declare function applyAnthropicHistoryCacheBreakpoints(input: ReadonlyArray<VertexAnthropicMessage>, budget: number): VertexAnthropicMessage[];
44
+ /**
45
+ * Breakpoint placement for a Claude Messages request the proxy built itself,
46
+ * rather than one a client authored.
47
+ *
48
+ * A request translated from another wire format arrives with no markers at
49
+ * all, because the format it came from has no way to express one: OpenAI-shaped
50
+ * callers rely on automatic prefix caching, which Anthropic does not do. A
51
+ * faithful translation is therefore cached at 0% on every turn, and a caller
52
+ * that fell back here from an exhausted pool burns the replacement quota faster
53
+ * than the one it was spared.
54
+ *
55
+ * Placement follows Anthropic's `tools → system → messages` rendering: one
56
+ * marker on the last system block covers tools *and* system in a single
57
+ * breakpoint, and the rest roll along the tail of the history so a growing
58
+ * conversation keeps its settled prefix cached. Markers the caller supplied are
59
+ * counted first and never displaced — this only spends what is left of the
60
+ * four.
61
+ *
62
+ * Pure: inputs are cloned, never mutated.
63
+ */
64
+ export declare function applyClaudeRequestCacheBreakpoints(request: ClaudeRequest): ClaudeRequest;
@@ -148,3 +148,116 @@ function markLastContentBlock(messages, i) {
148
148
  messages[i] = { ...message, content };
149
149
  return true;
150
150
  }
151
+ /**
152
+ * Breakpoint placement for a Claude Messages request the proxy built itself,
153
+ * rather than one a client authored.
154
+ *
155
+ * A request translated from another wire format arrives with no markers at
156
+ * all, because the format it came from has no way to express one: OpenAI-shaped
157
+ * callers rely on automatic prefix caching, which Anthropic does not do. A
158
+ * faithful translation is therefore cached at 0% on every turn, and a caller
159
+ * that fell back here from an exhausted pool burns the replacement quota faster
160
+ * than the one it was spared.
161
+ *
162
+ * Placement follows Anthropic's `tools → system → messages` rendering: one
163
+ * marker on the last system block covers tools *and* system in a single
164
+ * breakpoint, and the rest roll along the tail of the history so a growing
165
+ * conversation keeps its settled prefix cached. Markers the caller supplied are
166
+ * counted first and never displaced — this only spends what is left of the
167
+ * four.
168
+ *
169
+ * Pure: inputs are cloned, never mutated.
170
+ */
171
+ export function applyClaudeRequestCacheBreakpoints(request) {
172
+ const existing = countAnthropicCacheMarkers({
173
+ system: request.system,
174
+ tools: request.tools,
175
+ messages: [],
176
+ });
177
+ const inMessages = request.messages.reduce((n, m) => n +
178
+ (Array.isArray(m.content)
179
+ ? m.content.filter((b) => "cache_control" in b && b.cache_control)
180
+ .length
181
+ : 0), 0);
182
+ let budget = MAX_BREAKPOINTS - existing - inMessages;
183
+ if (budget <= 0) {
184
+ return request;
185
+ }
186
+ const out = { ...request };
187
+ // One marker on the stable head. The last system block is the better anchor
188
+ // than the last tool, because system renders after tools and so a single
189
+ // marker there caches both.
190
+ if (Array.isArray(out.system) && out.system.length > 0 && existing === 0) {
191
+ const system = out.system.map((b) => ({ ...b }));
192
+ system[system.length - 1] = {
193
+ ...system[system.length - 1],
194
+ cache_control: CLAUDE_EPHEMERAL,
195
+ };
196
+ out.system = system;
197
+ budget--;
198
+ }
199
+ else if ((!out.system || (Array.isArray(out.system) && out.system.length === 0)) &&
200
+ out.tools &&
201
+ out.tools.length > 0 &&
202
+ existing === 0) {
203
+ const tools = out.tools.map((t) => ({ ...t }));
204
+ tools[tools.length - 1] = {
205
+ ...tools[tools.length - 1],
206
+ cache_control: CLAUDE_EPHEMERAL,
207
+ };
208
+ out.tools = tools;
209
+ budget--;
210
+ }
211
+ if (budget > 0) {
212
+ out.messages = applyClaudeHistoryBreakpoints(out.messages, budget);
213
+ }
214
+ return out;
215
+ }
216
+ const CLAUDE_EPHEMERAL = { type: "ephemeral" };
217
+ /**
218
+ * Roll `budget` markers backwards along the history, marking the last content
219
+ * block of each message that can carry one. Only text and tool_result blocks
220
+ * accept `cache_control`; a message ending in anything else is skipped without
221
+ * spending budget.
222
+ *
223
+ * A message whose content is a plain string is promoted to the single text
224
+ * block the marker has to live on. Skipping strings instead would have left an
225
+ * ordinary conversation with no history breakpoint at all, since the OpenAI
226
+ * bridge emits every plain-text turn as a string — which is precisely the
227
+ * growing history this is here to keep cached.
228
+ */
229
+ function applyClaudeHistoryBreakpoints(input, budget) {
230
+ const messages = input.map((m) => ({ ...m }));
231
+ let remaining = Math.max(0, Math.min(budget, MAX_BREAKPOINTS));
232
+ for (let i = messages.length - 1; i >= 0 && remaining > 0; i--) {
233
+ const content = messages[i].content;
234
+ if (typeof content === "string") {
235
+ if (content.length === 0) {
236
+ continue;
237
+ }
238
+ messages[i] = {
239
+ ...messages[i],
240
+ content: [
241
+ { type: "text", text: content, cache_control: CLAUDE_EPHEMERAL },
242
+ ],
243
+ };
244
+ remaining--;
245
+ continue;
246
+ }
247
+ if (!Array.isArray(content) || content.length === 0) {
248
+ continue;
249
+ }
250
+ const last = content[content.length - 1];
251
+ if (last.type !== "text" && last.type !== "tool_result") {
252
+ continue;
253
+ }
254
+ if (last.cache_control) {
255
+ continue;
256
+ }
257
+ const blocks = content.map((b) => ({ ...b }));
258
+ blocks[blocks.length - 1] = { ...last, cache_control: CLAUDE_EPHEMERAL };
259
+ messages[i] = { ...messages[i], content: blocks };
260
+ remaining--;
261
+ }
262
+ return messages;
263
+ }