@cjhyy/code-shell-core 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import { countTokens } from "../token-counter.js";
9
9
  import { capabilitiesFor } from "../capabilities/index.js";
10
10
  import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
11
11
  import { stripVisionFromHistory } from "../strip-vision.js";
12
+ import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
12
13
  /**
13
14
  * Anthropic's `max_tokens` is required, so unlike OpenAI we can't omit it when
14
15
  * the model's ceiling is unknown. Use a conservative floor in that rare case
@@ -75,12 +76,22 @@ export class AnthropicClient extends LLMClientBase {
75
76
  }
76
77
  return this._capability;
77
78
  }
79
+ promptCachePolicy(request) {
80
+ return resolvePromptCachePolicy({
81
+ provider: this.provider,
82
+ providerKind: this.config.providerKind,
83
+ model: this.model,
84
+ request,
85
+ });
86
+ }
78
87
  getPromptCacheConfigIdentity() {
88
+ const cachePolicy = this.promptCachePolicy();
79
89
  return {
80
90
  ...super.getPromptCacheConfigIdentity(),
81
- cacheStrategy: "anthropic-explicit",
82
- cacheLayoutVersion: "system-tools-history-v1",
83
- breakpointCount: 3,
91
+ cacheStrategy: cachePolicy.strategy,
92
+ cacheLayoutVersion: cachePolicy.layoutVersion,
93
+ cacheBreakpoints: cachePolicy.breakpoints,
94
+ breakpointCount: cachePolicy.breakpoints.length,
84
95
  reasoningShape: this.capability.reasoning,
85
96
  };
86
97
  }
@@ -133,8 +144,9 @@ export class AnthropicClient extends LLMClientBase {
133
144
  }
134
145
  async createMessage(options) {
135
146
  return this.withRetry(async (requestSignal) => {
136
- const messages = this.buildMessages(options.messages);
137
- const tools = options.tools ? this.convertTools(options.tools) : undefined;
147
+ const cachePolicy = this.promptCachePolicy(options.promptCache);
148
+ const messages = this.buildMessages(options.messages, options.promptCache, cachePolicy);
149
+ const tools = options.tools ? this.convertTools(options.tools, cachePolicy) : undefined;
138
150
  // One span per outbound LLM request. Begin emits debug-level so the
139
151
  // info log isn't spammed during normal operation; end emits info with
140
152
  // duration_ms + usage so `--debug=llm` already gives a quick latency
@@ -146,6 +158,7 @@ export class AnthropicClient extends LLMClientBase {
146
158
  stream: !!(options.stream && options.onChunk),
147
159
  messageCount: messages.length,
148
160
  toolCount: tools?.length ?? 0,
161
+ cacheStrategy: cachePolicy.strategy,
149
162
  });
150
163
  try {
151
164
  const response = options.stream && options.onChunk
@@ -176,13 +189,7 @@ export class AnthropicClient extends LLMClientBase {
176
189
  const response = await this.client.messages.create({
177
190
  model: this.model,
178
191
  max_tokens: maxTokens,
179
- system: [
180
- {
181
- type: "text",
182
- text: options.systemPrompt,
183
- cache_control: { type: "ephemeral" },
184
- },
185
- ],
192
+ system: this.buildSystem(options.systemPrompt, this.promptCachePolicy(options.promptCache)),
186
193
  messages,
187
194
  ...(tools?.length ? { tools } : {}),
188
195
  ...(thinking ? { thinking } : {}),
@@ -207,13 +214,7 @@ export class AnthropicClient extends LLMClientBase {
207
214
  const stream = this.client.messages.stream({
208
215
  model: this.model,
209
216
  max_tokens: maxTokens,
210
- system: [
211
- {
212
- type: "text",
213
- text: options.systemPrompt,
214
- cache_control: { type: "ephemeral" },
215
- },
216
- ],
217
+ system: this.buildSystem(options.systemPrompt, this.promptCachePolicy(options.promptCache)),
217
218
  messages,
218
219
  ...(tools?.length ? { tools } : {}),
219
220
  ...(thinking ? { thinking } : {}),
@@ -316,10 +317,27 @@ export class AnthropicClient extends LLMClientBase {
316
317
  stopReason: response.stop_reason ?? undefined,
317
318
  };
318
319
  }
319
- buildMessages(messages) {
320
+ buildSystem(systemPrompt, policy) {
321
+ return [
322
+ {
323
+ type: "text",
324
+ text: systemPrompt,
325
+ ...(policy.breakpoints.includes("system")
326
+ ? { cache_control: { type: "ephemeral" } }
327
+ : {}),
328
+ },
329
+ ];
330
+ }
331
+ buildMessages(messages, promptCache, policy) {
320
332
  messages = stripVisionFromHistory(messages, this.capability.supportsVision);
321
333
  const result = [];
322
- for (const msg of messages) {
334
+ const stablePrefixMessageCount = Math.max(0, Math.min(messages.length, promptCache?.stablePrefixMessageCount ?? messages.length));
335
+ let stablePrefixEndMessage;
336
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
337
+ if (sourceIndex === stablePrefixMessageCount) {
338
+ stablePrefixEndMessage = result[result.length - 1];
339
+ }
340
+ const msg = messages[sourceIndex];
323
341
  if (msg.role === "system")
324
342
  continue;
325
343
  const role = msg.role === "tool" ? "user" : msg.role;
@@ -390,40 +408,45 @@ export class AnthropicClient extends LLMClientBase {
390
408
  }
391
409
  }
392
410
  }
393
- // Prompt-cache breakpoint on the history: mark the LAST content block of
394
- // the LAST message. The API caches everything up to the marker, so the
395
- // stable prefix (all prior turns) is reused; only the growing tail is
396
- // re-billed. CC does exactly this (one marker at messages.length - 1) and
397
- // warns a second history marker causes KV page eviction. We only ANNOTATE
398
- // the tail block — never reorder — so the tool_use/tool_result adjacency
399
- // invariant is untouched. A string-content message is lifted to a single
400
- // text block so it can carry cache_control. See
401
- // docs/todo/prompt-cache-optimization.md.
402
- const lastMsg = result[result.length - 1];
403
- if (lastMsg) {
404
- if (typeof lastMsg.content === "string") {
405
- lastMsg.content = [
406
- {
407
- type: "text",
408
- text: lastMsg.content,
409
- cache_control: { type: "ephemeral" },
410
- },
411
- ];
412
- }
413
- else {
414
- const lastBlock = lastMsg.content[lastMsg.content.length - 1];
415
- // Skip thinking/redacted_thinking blocks: they reject cache_control and
416
- // marking them would 400. buildMessages never emits them today (thinking
417
- // is a top-level request field, not history content), but guard anyway
418
- // so a future block type can't silently break the request.
419
- if (lastBlock && lastBlock.type !== "thinking" && lastBlock.type !== "redacted_thinking") {
420
- lastBlock.cache_control = { type: "ephemeral" };
421
- }
422
- }
411
+ stablePrefixEndMessage ??= result[result.length - 1];
412
+ const stablePrefixEndIndex = stablePrefixEndMessage
413
+ ? result.indexOf(stablePrefixEndMessage)
414
+ : undefined;
415
+ const breakpointIndexes = uniquePromptCacheBreakpointIndexes([
416
+ policy.breakpoints.includes("stable-history") && stablePrefixEndIndex !== undefined
417
+ ? stablePrefixEndIndex
418
+ : undefined,
419
+ policy.breakpoints.includes("rolling-history") ? result.length - 1 : undefined,
420
+ ]);
421
+ for (const index of breakpointIndexes) {
422
+ this.markAnthropicCacheBreakpoint(result[index]);
423
423
  }
424
424
  return result;
425
425
  }
426
- convertTools(tools) {
426
+ markAnthropicCacheBreakpoint(message) {
427
+ if (!message)
428
+ return;
429
+ if (typeof message.content === "string") {
430
+ message.content = [
431
+ {
432
+ type: "text",
433
+ text: message.content,
434
+ cache_control: { type: "ephemeral" },
435
+ },
436
+ ];
437
+ return;
438
+ }
439
+ for (let index = message.content.length - 1; index >= 0; index--) {
440
+ const block = message.content[index];
441
+ // Thinking blocks reject cache_control. Walk backward so a preceding
442
+ // cacheable text/tool block can still anchor the prefix.
443
+ if (block.type === "thinking" || block.type === "redacted_thinking")
444
+ continue;
445
+ block.cache_control = { type: "ephemeral" };
446
+ return;
447
+ }
448
+ }
449
+ convertTools(tools, policy) {
427
450
  const converted = tools.map((t) => ({
428
451
  name: t.name,
429
452
  description: t.description,
@@ -436,8 +459,9 @@ export class AnthropicClient extends LLMClientBase {
436
459
  // not per-tool); a second marker here would waste a scarce cache_control
437
460
  // slot (max 4) and risk KV eviction. See docs/todo/prompt-cache-optimization.md.
438
461
  const last = converted[converted.length - 1];
439
- if (last)
462
+ if (last && policy.breakpoints.includes("tools")) {
440
463
  last.cache_control = { type: "ephemeral" };
464
+ }
441
465
  return converted;
442
466
  }
443
467
  handleApiError(err) {
@@ -52,6 +52,9 @@ export declare class OpenAIClient extends LLMClientBase {
52
52
  private readonly dangerouslyAllowBrowser;
53
53
  private _forceMaxCompletionTokens;
54
54
  private _dropReasoningEffort;
55
+ /** Compatibility fallbacks for OpenAI-compatible gateways that lag the API. */
56
+ private _disableExplicitPromptCache;
57
+ private _disablePromptCacheKey;
55
58
  constructor(config: LLMConfig, defaults?: ClientDefaults, runtimeOptions?: {
56
59
  dangerouslyAllowBrowser?: boolean;
57
60
  });
@@ -66,20 +69,7 @@ export declare class OpenAIClient extends LLMClientBase {
66
69
  */
67
70
  private _capability;
68
71
  private get capability();
69
- /**
70
- * True when this client routes an Anthropic-family model through OpenRouter's
71
- * OpenAI-compatible endpoint. Anthropic caching is EXPLICIT — nothing is
72
- * cached unless the request carries `cache_control` breakpoints (verified live
73
- * 2026-07-02: plain requests to anthropic/claude-opus-4.7-fast via OpenRouter
74
- * report cached_tokens 0 on every repeat; a single system-block breakpoint
75
- * turns the whole stable prefix — tools + system — into a cache hit, ~89%
76
- * cheaper on the follow-up). OpenAI and other OpenRouter models cache
77
- * automatically, so they must NOT get breakpoints. The slug arrives resolved
78
- * (e.g. "anthropic/claude-opus-4.7-fast") or as the router alias
79
- * ("~anthropic/claude-opus-latest") — both start with an optional "~" then
80
- * "anthropic/".
81
- */
82
- private get isOpenRouterAnthropic();
72
+ private promptCachePolicy;
83
73
  getPromptCacheConfigIdentity(): Readonly<Record<string, unknown>>;
84
74
  createMessage(options: CreateMessageOptions): Promise<LLMResponse>;
85
75
  /**
@@ -93,20 +83,10 @@ export declare class OpenAIClient extends LLMClientBase {
93
83
  private processChoice;
94
84
  private buildMessages;
95
85
  /**
96
- * In-place: add prompt-cache breakpoints for Anthropic-over-OpenRouter.
97
- * Mirrors the native anthropic provider (≤4 breakpoints):
98
- * 1. System block — the stable prefix. Anthropic sees tools BEFORE the
99
- * system prompt, so one marker on the system block caches tools too
100
- * (verified live: system-only marker cached 3511/3952 prompt tokens
101
- * including tool defs).
102
- * 2. Last message — one rolling breakpoint so the growing conversation
103
- * history becomes a cached prefix. Not scrolled: as history grows the
104
- * "last message" naturally advances and its tail is the next write.
105
- * A string `content` is lifted to a single-element `[{type:"text",...}]`
106
- * array so it can carry `cache_control`; OpenRouter accepts this OpenAI
107
- * multimodal wire form for text.
86
+ * Translate semantic prefix boundaries to the active wire format. Both
87
+ * formats annotate content blocks without reordering messages.
108
88
  */
109
- private applyAnthropicCacheBreakpoints;
89
+ private applyPromptCacheBreakpoints;
110
90
  private convertTools;
111
91
  private handleApiError;
112
92
  }
@@ -16,7 +16,8 @@ import { capabilitiesFor } from "../capabilities/index.js";
16
16
  import { clampMaxTokens } from "../clamp-max-tokens.js";
17
17
  import { resolveApiKey, resolveHeaders } from "../provider-auth.js";
18
18
  import { stripVisionFromHistory } from "../strip-vision.js";
19
- import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError, } from "../stream-watchdog.js";
19
+ import { resolvePromptCachePolicy, uniquePromptCacheBreakpointIndexes, } from "../prompt-cache.js";
20
+ import { STREAM_WATCHDOG_CONFIG, StreamIdleTimeoutError } from "../stream-watchdog.js";
20
21
  /**
21
22
  * Extract prompt-cache counts from an OpenAI-compatible usage object.
22
23
  *
@@ -74,9 +75,7 @@ export async function runStreamWithWatchdog(stream, opts = {}) {
74
75
  // An explicit idleTimeoutMs always activates the watchdog. Otherwise follow
75
76
  // disableWatchdog (per-call override) if set, else the env default.
76
77
  const watchdogActive = opts.idleTimeoutMs !== undefined ||
77
- (opts.disableWatchdog === undefined
78
- ? STREAM_WATCHDOG_CONFIG.enabled
79
- : !opts.disableWatchdog);
78
+ (opts.disableWatchdog === undefined ? STREAM_WATCHDOG_CONFIG.enabled : !opts.disableWatchdog);
80
79
  const idleTimeoutMs = opts.idleTimeoutMs ?? STREAM_WATCHDOG_CONFIG.idleTimeoutMs;
81
80
  let text = "";
82
81
  // Fast path: watchdog disabled AND caller did not override → no overhead.
@@ -224,6 +223,9 @@ export class OpenAIClient extends LLMClientBase {
224
223
  // succeed. Omitting the field just means "model default reasoning", which is
225
224
  // fine for our background/aux calls.
226
225
  _dropReasoningEffort = false;
226
+ /** Compatibility fallbacks for OpenAI-compatible gateways that lag the API. */
227
+ _disableExplicitPromptCache = false;
228
+ _disablePromptCacheKey = false;
227
229
  constructor(config, defaults, runtimeOptions = {}) {
228
230
  super(config, defaults);
229
231
  this.dangerouslyAllowBrowser = runtimeOptions.dangerouslyAllowBrowser === true;
@@ -264,30 +266,26 @@ export class OpenAIClient extends LLMClientBase {
264
266
  }
265
267
  return this._capability;
266
268
  }
267
- /**
268
- * True when this client routes an Anthropic-family model through OpenRouter's
269
- * OpenAI-compatible endpoint. Anthropic caching is EXPLICIT — nothing is
270
- * cached unless the request carries `cache_control` breakpoints (verified live
271
- * 2026-07-02: plain requests to anthropic/claude-opus-4.7-fast via OpenRouter
272
- * report cached_tokens 0 on every repeat; a single system-block breakpoint
273
- * turns the whole stable prefix — tools + system — into a cache hit, ~89%
274
- * cheaper on the follow-up). OpenAI and other OpenRouter models cache
275
- * automatically, so they must NOT get breakpoints. The slug arrives resolved
276
- * (e.g. "anthropic/claude-opus-4.7-fast") or as the router alias
277
- * ("~anthropic/claude-opus-latest") — both start with an optional "~" then
278
- * "anthropic/".
279
- */
280
- get isOpenRouterAnthropic() {
281
- return this.config.providerKind === "openrouter" && /^~?anthropic\//.test(this.model);
269
+ promptCachePolicy(request) {
270
+ const policy = resolvePromptCachePolicy({
271
+ provider: this.provider,
272
+ providerKind: this.config.providerKind,
273
+ model: this.model,
274
+ request,
275
+ explicitDisabled: this._disableExplicitPromptCache,
276
+ });
277
+ return this._disablePromptCacheKey && policy.cacheKey
278
+ ? { ...policy, cacheKey: undefined }
279
+ : policy;
282
280
  }
283
281
  getPromptCacheConfigIdentity() {
284
282
  const capability = this.capability;
283
+ const cachePolicy = this.promptCachePolicy();
285
284
  return {
286
285
  ...super.getPromptCacheConfigIdentity(),
287
- cacheStrategy: this.isOpenRouterAnthropic
288
- ? "openrouter-anthropic-explicit"
289
- : "provider-automatic",
290
- cacheLayoutVersion: this.isOpenRouterAnthropic ? "system-history-v1" : "automatic-v1",
286
+ cacheStrategy: cachePolicy.strategy,
287
+ cacheLayoutVersion: cachePolicy.layoutVersion,
288
+ cacheBreakpoints: cachePolicy.breakpoints,
291
289
  tokenLimitField: this._forceMaxCompletionTokens
292
290
  ? "max_completion_tokens"
293
291
  : capability.tokenLimitField,
@@ -295,6 +293,8 @@ export class OpenAIClient extends LLMClientBase {
295
293
  rejectedParams: [...capability.rejectedParams].sort(),
296
294
  forceMaxCompletionTokens: this._forceMaxCompletionTokens,
297
295
  dropReasoningEffort: this._dropReasoningEffort,
296
+ disableExplicitPromptCache: this._disableExplicitPromptCache,
297
+ disablePromptCacheKey: this._disablePromptCacheKey,
298
298
  };
299
299
  }
300
300
  async createMessage(options) {
@@ -305,7 +305,7 @@ export class OpenAIClient extends LLMClientBase {
305
305
  // Per-call reasoning wins; otherwise fall back to provider default
306
306
  // (settings.providers[].reasoning, threaded through LLMConfig).
307
307
  const reasoning = options.reasoning ?? this.config.reasoning;
308
- const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning);
308
+ const messages = this.buildMessages(options.systemPrompt, options.messages, reasoning, options.promptCache);
309
309
  const tools = options.tools?.length ? this.convertTools(options.tools) : undefined;
310
310
  const span = logger.span("llm.request", {
311
311
  cat: "llm",
@@ -314,6 +314,7 @@ export class OpenAIClient extends LLMClientBase {
314
314
  stream: !!(options.stream && options.onChunk),
315
315
  messageCount: messages.length,
316
316
  toolCount: tools?.length ?? 0,
317
+ cacheStrategy: this.promptCachePolicy(options.promptCache).strategy,
317
318
  });
318
319
  try {
319
320
  const response = options.stream && options.onChunk
@@ -341,6 +342,7 @@ export class OpenAIClient extends LLMClientBase {
341
342
  */
342
343
  buildRequestBody(options, messages, tools, reasoning, stream) {
343
344
  const cap = this.capability;
345
+ const cachePolicy = this.promptCachePolicy(options.promptCache);
344
346
  // Clamp to the model's known output ceiling so a stale catalog value
345
347
  // (e.g. 384000 inherited after a hot model switch) can't 400 a
346
348
  // smaller-cap model. No known cap → send the value as-is.
@@ -449,10 +451,10 @@ export class OpenAIClient extends LLMClientBase {
449
451
  // requested summary level to that object. For the bare `reasoning_effort`
450
452
  // shape there's no summary field on chat-completions, so we skip it rather
451
453
  // than send an unknown top-level param.
452
- if (this.config.reasoningSummary && reasoningBody.reasoning &&
454
+ if (this.config.reasoningSummary &&
455
+ reasoningBody.reasoning &&
453
456
  typeof reasoningBody.reasoning === "object") {
454
- reasoningBody.reasoning.summary =
455
- this.config.reasoningSummary;
457
+ reasoningBody.reasoning.summary = this.config.reasoningSummary;
456
458
  }
457
459
  // Catalog-driven passthrough params (temperature/top_p/thinking etc, already
458
460
  // wire-mapped from the connection's paramValues by applyParams). Filter each
@@ -489,6 +491,12 @@ export class OpenAIClient extends LLMClientBase {
489
491
  ...paramBody,
490
492
  // service_tier (TODO 7.2): passed through verbatim when configured.
491
493
  ...(this.config.serviceTier ? { service_tier: this.config.serviceTier } : {}),
494
+ ...(options.promptCache && cachePolicy.cacheKey
495
+ ? { prompt_cache_key: cachePolicy.cacheKey }
496
+ : {}),
497
+ ...(options.promptCache && cachePolicy.promptCacheOptions
498
+ ? { prompt_cache_options: cachePolicy.promptCacheOptions }
499
+ : {}),
492
500
  ...(tools ? { tools } : {}),
493
501
  ...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
494
502
  };
@@ -740,7 +748,7 @@ export class OpenAIClient extends LLMClientBase {
740
748
  ...(reasoningContent ? { reasoningContent } : {}),
741
749
  };
742
750
  }
743
- buildMessages(systemPrompt, messages, reasoning) {
751
+ buildMessages(systemPrompt, messages, reasoning, promptCache) {
744
752
  const result = [{ role: "system", content: systemPrompt }];
745
753
  // Drop historical image blocks when the active model can't accept vision.
746
754
  // Engine.run only gates *new* attachments; an image left in history from
@@ -748,6 +756,8 @@ export class OpenAIClient extends LLMClientBase {
748
756
  // below and 400s ("unknown variant `image_url`") after a model switch.
749
757
  // Identity-preserving on the common path (vision models / no images).
750
758
  messages = stripVisionFromHistory(messages, this.capability.supportsVision);
759
+ const stablePrefixMessageCount = Math.max(0, Math.min(messages.length, promptCache?.stablePrefixMessageCount ?? messages.length));
760
+ let stablePrefixEndMessage = stablePrefixMessageCount === 0 ? result[0] : undefined;
751
761
  // Reasoning-content echo-back contract — driven by capability:
752
762
  // "when-tools" : backfill an empty placeholder if the prior assistant
753
763
  // turn doesn't carry one (DeepSeek V4 + tools 400s
@@ -760,11 +770,13 @@ export class OpenAIClient extends LLMClientBase {
760
770
  const cap = this.capability;
761
771
  const hasTools = messages.some((m) => Array.isArray(m.content) &&
762
772
  m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
763
- const needsReasoningBackfill = reasoning?.mode !== "off" &&
764
- cap.echoReasoning === "when-tools" &&
765
- hasTools;
773
+ const needsReasoningBackfill = reasoning?.mode !== "off" && cap.echoReasoning === "when-tools" && hasTools;
766
774
  const stripReasoning = cap.echoReasoning === "never";
767
- for (const msg of messages) {
775
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
776
+ if (sourceIndex === stablePrefixMessageCount) {
777
+ stablePrefixEndMessage = result[result.length - 1];
778
+ }
779
+ const msg = messages[sourceIndex];
768
780
  if (msg.role === "system")
769
781
  continue;
770
782
  if (msg.role === "assistant") {
@@ -948,55 +960,64 @@ export class OpenAIClient extends LLMClientBase {
948
960
  }
949
961
  }
950
962
  }
963
+ stablePrefixEndMessage ??= result[result.length - 1];
951
964
  const normalized = normalizeOpenAIToolMessagePairs(result);
952
- if (this.isOpenRouterAnthropic) {
953
- this.applyAnthropicCacheBreakpoints(normalized);
965
+ const stablePrefixEndIndex = stablePrefixEndMessage
966
+ ? normalized.indexOf(stablePrefixEndMessage)
967
+ : undefined;
968
+ const cachePolicy = this.promptCachePolicy(promptCache);
969
+ if (promptCache || cachePolicy.strategy === "anthropic-explicit") {
970
+ this.applyPromptCacheBreakpoints(normalized, cachePolicy, stablePrefixEndIndex !== undefined && stablePrefixEndIndex >= 0
971
+ ? stablePrefixEndIndex
972
+ : undefined);
954
973
  }
955
974
  return normalized;
956
975
  }
957
976
  /**
958
- * In-place: add prompt-cache breakpoints for Anthropic-over-OpenRouter.
959
- * Mirrors the native anthropic provider (≤4 breakpoints):
960
- * 1. System block — the stable prefix. Anthropic sees tools BEFORE the
961
- * system prompt, so one marker on the system block caches tools too
962
- * (verified live: system-only marker cached 3511/3952 prompt tokens
963
- * including tool defs).
964
- * 2. Last message — one rolling breakpoint so the growing conversation
965
- * history becomes a cached prefix. Not scrolled: as history grows the
966
- * "last message" naturally advances and its tail is the next write.
967
- * A string `content` is lifted to a single-element `[{type:"text",...}]`
968
- * array so it can carry `cache_control`; OpenRouter accepts this OpenAI
969
- * multimodal wire form for text.
977
+ * Translate semantic prefix boundaries to the active wire format. Both
978
+ * formats annotate content blocks without reordering messages.
970
979
  */
971
- applyAnthropicCacheBreakpoints(messages) {
972
- const mark = (m) => {
973
- if (!m)
980
+ applyPromptCacheBreakpoints(messages, policy, stablePrefixEndIndex) {
981
+ if (policy.strategy !== "anthropic-explicit" && policy.strategy !== "openai-explicit") {
982
+ return;
983
+ }
984
+ const markedMessages = new Set();
985
+ const mark = (index) => {
986
+ let cursor = Math.min(index, messages.length - 1);
987
+ let m;
988
+ while (cursor >= 0) {
989
+ const candidate = messages[cursor];
990
+ if ((typeof candidate.content === "string" && candidate.content.length > 0) ||
991
+ (Array.isArray(candidate.content) && candidate.content.length > 0)) {
992
+ m = candidate;
993
+ break;
994
+ }
995
+ cursor--;
996
+ }
997
+ if (!m || markedMessages.has(m))
974
998
  return;
999
+ markedMessages.add(m);
1000
+ const marker = policy.strategy === "anthropic-explicit"
1001
+ ? { cache_control: { type: "ephemeral" } }
1002
+ : { prompt_cache_breakpoint: { mode: "explicit" } };
975
1003
  // Lift a plain-string content to a text-block array so it can carry the
976
- // marker. Non-text content (tool messages, image arrays) already uses an
977
- // array of parts — mark the last part instead.
1004
+ // marker. Non-text content already uses an array of parts.
978
1005
  if (typeof m.content === "string") {
979
- m.content = [
980
- { type: "text", text: m.content, cache_control: { type: "ephemeral" } },
981
- ];
1006
+ m.content = [{ type: "text", text: m.content, ...marker }];
982
1007
  return;
983
1008
  }
984
1009
  if (Array.isArray(m.content) && m.content.length > 0) {
985
- // cache_control is an Anthropic-via-OpenRouter extension field, not in
986
- // the OpenAI content-part union — attach through `unknown`.
987
1010
  const last = m.content[m.content.length - 1];
988
- last.cache_control = { type: "ephemeral" };
1011
+ Object.assign(last, marker);
989
1012
  }
990
1013
  };
991
- // 1. System block (always index 0 — buildMessages seeds it first).
992
- const sys = messages[0];
993
- if (sys && sys.role === "system")
994
- mark(sys);
995
- // 2. Rolling history breakpoint on the very last message. Skip if it IS the
996
- // system message (no conversation yet) — one breakpoint already covers it.
997
- const last = messages[messages.length - 1];
998
- if (last && last !== sys)
999
- mark(last);
1014
+ const requested = uniquePromptCacheBreakpointIndexes([
1015
+ policy.breakpoints.includes("system") ? 0 : undefined,
1016
+ policy.breakpoints.includes("stable-history") ? stablePrefixEndIndex : undefined,
1017
+ policy.breakpoints.includes("rolling-history") ? messages.length - 1 : undefined,
1018
+ ]);
1019
+ for (const index of requested)
1020
+ mark(index);
1000
1021
  }
1001
1022
  convertTools(tools) {
1002
1023
  return tools.map((t) => ({
@@ -1031,6 +1052,33 @@ export class OpenAIClient extends LLMClientBase {
1031
1052
  // the corrected body — fixing the call that triggered it, not just the
1032
1053
  // next one.
1033
1054
  let selfCorrected = false;
1055
+ // New GPT-5.6 cache fields may reach an OpenAI-compatible gateway before
1056
+ // that gateway supports them. Downgrade sticky-per-client and retry once:
1057
+ // first explicit -> implicit, then omit the affinity key only if that is
1058
+ // also rejected. Native OpenAI keeps the optimized path.
1059
+ if (err.status === 400 && msg.includes("prompt_cache")) {
1060
+ if ((msg.includes("prompt_cache_options") || msg.includes("prompt_cache_breakpoint")) &&
1061
+ !this._disableExplicitPromptCache) {
1062
+ this._disableExplicitPromptCache = true;
1063
+ selfCorrected = true;
1064
+ logger.warn("llm.prompt_cache_explicit_unsupported", {
1065
+ cat: "llm",
1066
+ provider: this.provider,
1067
+ providerKind: this.config.providerKind,
1068
+ model: this.model,
1069
+ });
1070
+ }
1071
+ if (msg.includes("prompt_cache_key") && !this._disablePromptCacheKey) {
1072
+ this._disablePromptCacheKey = true;
1073
+ selfCorrected = true;
1074
+ logger.warn("llm.prompt_cache_key_unsupported", {
1075
+ cat: "llm",
1076
+ provider: this.provider,
1077
+ providerKind: this.config.providerKind,
1078
+ model: this.model,
1079
+ });
1080
+ }
1081
+ }
1034
1082
  // o-series / gpt-5+ reject `max_tokens` and demand
1035
1083
  // `max_completion_tokens`. The id-based regex catches the common
1036
1084
  // cases; this is the belt-and-suspenders path for ids that ship
@@ -1095,8 +1143,12 @@ export class OpenAIClient extends LLMClientBase {
1095
1143
  function deepMergeInto(dst, src) {
1096
1144
  for (const [k, v] of Object.entries(src)) {
1097
1145
  const cur = dst[k];
1098
- if (v && typeof v === "object" && !Array.isArray(v) &&
1099
- cur && typeof cur === "object" && !Array.isArray(cur)) {
1146
+ if (v &&
1147
+ typeof v === "object" &&
1148
+ !Array.isArray(v) &&
1149
+ cur &&
1150
+ typeof cur === "object" &&
1151
+ !Array.isArray(cur)) {
1100
1152
  deepMergeInto(cur, v);
1101
1153
  }
1102
1154
  else {
@@ -2,6 +2,7 @@
2
2
  * LLM-specific internal types.
3
3
  */
4
4
  import type { Message, ToolDefinition, LLMStreamChunk, TokenUsage } from "../types.js";
5
+ import type { PromptCacheRequestContext } from "./prompt-cache.js";
5
6
  export interface CreateMessageOptions {
6
7
  systemPrompt: string;
7
8
  messages: Message[];
@@ -33,6 +34,8 @@ export interface CreateMessageOptions {
33
34
  * the client's capability layer.
34
35
  */
35
36
  reasoning?: import("./reasoning-setting.js").ReasoningSetting;
37
+ /** Provider-neutral prompt-cache context supplied by the model facade. */
38
+ promptCache?: PromptCacheRequestContext;
36
39
  }
37
40
  export interface LLMUsageTracker {
38
41
  records: TokenUsage[];