@oh-my-pi/pi-ai 17.2.4 → 17.2.6

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 (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/types/dialect/examples.d.ts +18 -2
  3. package/dist/types/dialect/inventory.d.ts +6 -9
  4. package/dist/types/dialect/rendering.d.ts +9 -0
  5. package/dist/types/dialect/types.d.ts +0 -1
  6. package/dist/types/error/aws.d.ts +5 -1
  7. package/dist/types/error/flags.d.ts +5 -0
  8. package/dist/types/providers/aws-credentials.d.ts +5 -10
  9. package/dist/types/providers/bedrock-mantle.d.ts +13 -0
  10. package/dist/types/providers/openai-shared.d.ts +17 -5
  11. package/dist/types/providers/transform-messages.d.ts +1 -1
  12. package/dist/types/registry/amazon-bedrock.d.ts +7 -1
  13. package/dist/types/registry/aws.d.ts +13 -0
  14. package/dist/types/registry/bedrock-mantle.d.ts +22 -0
  15. package/dist/types/registry/registry.d.ts +25 -1
  16. package/dist/types/registry/types.d.ts +24 -0
  17. package/dist/types/types.d.ts +11 -4
  18. package/dist/types/utils/aws-profile.d.ts +17 -0
  19. package/dist/types/utils/harmony-leak.d.ts +9 -0
  20. package/dist/types/utils/schema/typescript.d.ts +8 -2
  21. package/package.json +4 -4
  22. package/src/auth-broker/discover.ts +2 -1
  23. package/src/auth-broker/wire-schema-resource.ts +6 -1
  24. package/src/auth-storage.ts +2 -2
  25. package/src/dialect/examples.ts +50 -12
  26. package/src/dialect/gemini.ts +17 -31
  27. package/src/dialect/harmony.ts +1 -2
  28. package/src/dialect/inventory.ts +21 -64
  29. package/src/dialect/rendering.ts +54 -0
  30. package/src/dialect/types.ts +0 -1
  31. package/src/error/aws.ts +5 -1
  32. package/src/error/flags.ts +8 -0
  33. package/src/providers/amazon-bedrock.ts +5 -5
  34. package/src/providers/anthropic.ts +77 -32
  35. package/src/providers/aws-credentials.ts +262 -76
  36. package/src/providers/bedrock-mantle.ts +110 -0
  37. package/src/providers/cursor.ts +6 -3
  38. package/src/providers/ollama.ts +27 -1
  39. package/src/providers/openai-codex-responses.ts +19 -5
  40. package/src/providers/openai-shared.ts +84 -35
  41. package/src/providers/transform-messages.ts +1 -1
  42. package/src/registry/amazon-bedrock.ts +9 -14
  43. package/src/registry/aws.ts +57 -0
  44. package/src/registry/bedrock-mantle.ts +34 -0
  45. package/src/registry/google-vertex.ts +2 -2
  46. package/src/registry/registry.ts +2 -0
  47. package/src/registry/types.ts +30 -0
  48. package/src/stream.ts +46 -30
  49. package/src/types.ts +14 -8
  50. package/src/utils/aws-profile.ts +88 -0
  51. package/src/utils/harmony-leak.ts +12 -0
  52. package/src/utils/schema/typescript.ts +21 -7
  53. package/src/utils.ts +1 -1
@@ -2,7 +2,13 @@ import type { Message, ToolCall } from "../types";
2
2
  import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
3
3
  import { FencedThinkingScanner } from "./fenced-thinking";
4
4
  import dialectPrompt from "./gemini.md" with { type: "text" };
5
- import { assistantTranscriptParts, collectToolResultRun, joinUserBodies, messageContentText } from "./rendering";
5
+ import {
6
+ assistantTranscriptParts,
7
+ collectToolResultRun,
8
+ joinUserBodies,
9
+ messageContentText,
10
+ pyValue,
11
+ } from "./rendering";
6
12
  import type {
7
13
  DialectDefinition,
8
14
  DialectRenderOptions,
@@ -494,11 +500,15 @@ function topLevelIndexOf(text: string, ch: string): number {
494
500
  return -1;
495
501
  }
496
502
 
497
- function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
498
- const kwargs = Object.entries(call.arguments)
499
- .map(([key, value]) => `${key}=${pyValue(value)}`)
500
- .join(", ");
501
- return options.example ? `${call.name}(${kwargs})` : `default_api.${call.name}(${kwargs})`;
503
+ function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
504
+ // Always escaped single-line literals: the scanner round-trips this wire
505
+ // form through unescapePythonString, so pyCall's verbatim `"""` example
506
+ // blocks would corrupt backslash-bearing content.
507
+ let kwargs = "";
508
+ for (const key in call.arguments) {
509
+ kwargs += `${kwargs ? ", " : ""}${key}=${pyValue(call.arguments[key])}`;
510
+ }
511
+ return `default_api.${call.name}(${kwargs})`;
502
512
  }
503
513
 
504
514
  function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
@@ -507,8 +517,7 @@ function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRe
507
517
  calls.length === 1
508
518
  ? renderToolCall(calls[0]!, options)
509
519
  : `[${calls.map(call => renderToolCall(call, options)).join(", ")}]`;
510
- // Examples show the bare call; the live wire form fences it as `tool_code`.
511
- return options.example ? body : `${CODE_OPEN}\n${body}\n${FENCE}`;
520
+ return `${CODE_OPEN}\n${body}\n${FENCE}`;
512
521
  }
513
522
 
514
523
  function renderToolResults(results: readonly DialectToolResult[]): string {
@@ -560,29 +569,6 @@ function geminiTurn(role: "model" | "user", body: string): string {
560
569
  return `<start_of_turn>${role}\n${body}<end_of_turn>\n`;
561
570
  }
562
571
 
563
- function pyValue(value: unknown): string {
564
- if (value === null || value === undefined) return "None";
565
- if (typeof value === "boolean") return value ? "True" : "False";
566
- if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
567
- if (typeof value === "string") return pyString(value);
568
- if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
569
- if (typeof value === "object") {
570
- const entries = Object.entries(value as Record<string, unknown>);
571
- return `{${entries.map(([key, val]) => `${pyString(key)}: ${pyValue(val)}`).join(", ")}}`;
572
- }
573
- return pyString(String(value));
574
- }
575
-
576
- function pyString(value: string): string {
577
- const escaped = value
578
- .replaceAll("\\", "\\\\")
579
- .replaceAll('"', '\\"')
580
- .replaceAll("\n", "\\n")
581
- .replaceAll("\r", "\\r")
582
- .replaceAll("\t", "\\t");
583
- return `"${escaped}"`;
584
- }
585
-
586
572
  const definition: DialectDefinition = {
587
573
  dialect: "gemini",
588
574
  prompt: dialectPrompt,
@@ -273,8 +273,7 @@ function parseRecipient(header: string): string {
273
273
  return match?.[1] ?? "";
274
274
  }
275
275
 
276
- function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
277
- if (options.example) return stringifyJson(call.arguments);
276
+ function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
278
277
  return `${START}assistant${CHANNEL}commentary to=${harmonyRecipient(call.name)}${MESSAGE}${stringifyJson(call.arguments)}${CALL}`;
279
278
  }
280
279
 
@@ -1,73 +1,30 @@
1
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
2
1
  import { jsonSchemaToTypeScript, toolWireSchema } from "../utils/schema";
3
- import { renderToolExamples } from "./examples";
2
+ import { renderToolExamplesJsdoc } from "./examples";
4
3
  import type { InbandTool } from "./types";
5
4
 
6
5
  /**
7
- * Human-readable per-tool inventory: each tool renders as a `# Tool: <name>`
8
- * section with its description, a simplified TypeScript-style parameter
9
- * signature (derived from the wire JSON Schema), and examples in the model's
10
- * native dialect. Shared by the verbose system-prompt inventory and
11
- * `/dump` so both render the catalog the same way.
12
- *
13
- * `model` is a model id; the native example dialect is resolved from it
14
- * (`preferredDialect`, which falls back to XML for empty/unknown ids).
6
+ * Tool catalog in the OpenAI-Harmony `namespace functions { … }` shape: each
7
+ * tool renders as its full description (and Python-syntax examples) as `//`
8
+ * comment lines above a flat `type <name> = (_: {…});` declaration. Shared by
9
+ * the verbose system-prompt inventory and `/dump` so both render the catalog
10
+ * the same way.
15
11
  */
16
- export function renderToolInventory(tools: readonly InbandTool[], model: string): string {
12
+ export function renderToolInventory(tools: readonly InbandTool[]): string {
17
13
  if (tools.length === 0) return "";
18
- const dialect = preferredDialect(model);
19
- return tools
20
- .map(tool => {
21
- const params = jsonSchemaToTypeScript(toolWireSchema(tool));
22
- const examples = renderToolExamples(tool, dialect);
23
- const description = demoteDescriptionHeaders(tool.description ?? "");
24
- const parts = [`# Tool: ${tool.name}`, description, "", `Parameters: ${params}`];
25
- if (examples) parts.push("", examples);
26
- return parts.join("\n");
27
- })
28
- .join("\n\n");
29
- }
30
-
31
- const FENCE = /^ {0,3}(`{3,}|~{3,})/;
32
- const ATX = /^ {0,3}#{1,6}( |\t|$)/;
33
- const TOP_LEVEL = /^ {0,3}#( |\t|$)/;
34
-
35
- /**
36
- * Each description is rendered under a `# Tool: <name>` heading. When the
37
- * description carries its own top-level (`# `) markdown headers they sit at the
38
- * same level as that wrapper, so the section structure flattens and the
39
- * description's headers read like sibling tools. Demote every ATX header in the
40
- * description by one level so the whole block nests under `# Tool: <name>`.
41
- *
42
- * Only triggered when a level-1 header is actually present — descriptions that
43
- * already start at `##` are left untouched. Headers inside fenced code blocks
44
- * are never rewritten.
45
- */
46
- function demoteDescriptionHeaders(description: string): string {
47
- const lines = description.split("\n");
48
-
49
- let fence: string | undefined;
50
- let collides = false;
51
- for (const line of lines) {
52
- const marker = FENCE.exec(line)?.[1][0];
53
- if (marker) {
54
- fence = fence === undefined ? marker : fence === marker ? undefined : fence;
55
- } else if (fence === undefined && TOP_LEVEL.test(line)) {
56
- collides = true;
57
- break;
14
+ const declarations = tools.map(tool => {
15
+ const params = jsonSchemaToTypeScript(toolWireSchema(tool), { style: "harmony" });
16
+ const lines: string[] = [];
17
+ const description = tool.description ?? "";
18
+ if (description) {
19
+ for (const line of description.split("\n")) lines.push(`// ${line}`.trimEnd());
58
20
  }
59
- }
60
- if (!collides) return description;
61
-
62
- fence = undefined;
63
- for (let i = 0; i < lines.length; i++) {
64
- const line = lines[i];
65
- const marker = FENCE.exec(line)?.[1][0];
66
- if (marker) {
67
- fence = fence === undefined ? marker : fence === marker ? undefined : fence;
68
- } else if (fence === undefined && ATX.test(line)) {
69
- lines[i] = line.replace(/^( {0,3})#/, "$1##");
21
+ const examples = renderToolExamplesJsdoc(tool);
22
+ if (examples) {
23
+ if (description) lines.push("//");
24
+ for (const line of examples.split("\n")) lines.push(`// ${line}`.trimEnd());
70
25
  }
71
- }
72
- return lines.join("\n");
26
+ lines.push(params === "{}" ? `type ${tool.name} = ();` : `type ${tool.name} = (_: ${params});`);
27
+ return lines.join("\n");
28
+ });
29
+ return `## functions\n\nnamespace functions {\n\n${declarations.join("\n\n")}\n\n} // namespace functions`;
73
30
  }
@@ -19,6 +19,60 @@ export function stringifyJson(value: unknown): string {
19
19
  return stringifyJsonValue(value) ?? "null";
20
20
  }
21
21
 
22
+ /**
23
+ * Render `name(key=value, …)` with Python-literal argument values. Top-level
24
+ * multiline strings render as verbatim `"""…"""` blocks so payload-carrying
25
+ * args (file content, scripts, patches) keep real newlines instead of `\n`
26
+ * escape soup; nested values always use escaped single-line literals.
27
+ */
28
+ export function pyCall(name: string, args: Record<string, unknown>): string {
29
+ let kwargs = "";
30
+ for (const key in args) {
31
+ kwargs += `${kwargs ? ", " : ""}${key}=${pyArgValue(args[key])}`;
32
+ }
33
+ return `${name}(${kwargs})`;
34
+ }
35
+
36
+ function pyArgValue(value: unknown): string {
37
+ if (typeof value === "string" && value.includes("\n")) {
38
+ // Verbatim `"""` fencing is only unambiguous when the content cannot
39
+ // collide with the fence: no `"""` inside, no quote butting against a
40
+ // fence edge, no trailing backslash swallowing the closer.
41
+ const fenceSafe =
42
+ !value.includes('"""') && !value.startsWith('"') && !value.endsWith('"') && !value.endsWith("\\");
43
+ if (fenceSafe) return `"""${value}"""`;
44
+ }
45
+ return pyValue(value);
46
+ }
47
+
48
+ /** Render a JSON-ish value as a Python literal (`True`/`False`/`None`, escaped strings, lists, dicts). */
49
+ export function pyValue(value: unknown): string {
50
+ if (value === null || value === undefined) return "None";
51
+ if (typeof value === "boolean") return value ? "True" : "False";
52
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
53
+ if (typeof value === "string") return pyString(value);
54
+ if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
55
+ if (typeof value === "object") {
56
+ const record = value as Record<string, unknown>;
57
+ let entries = "";
58
+ for (const key in record) {
59
+ entries += `${entries ? ", " : ""}${pyString(key)}: ${pyValue(record[key])}`;
60
+ }
61
+ return `{${entries}}`;
62
+ }
63
+ return pyString(String(value));
64
+ }
65
+
66
+ function pyString(value: string): string {
67
+ const escaped = value
68
+ .replaceAll("\\", "\\\\")
69
+ .replaceAll('"', '\\"')
70
+ .replaceAll("\n", "\\n")
71
+ .replaceAll("\r", "\\r")
72
+ .replaceAll("\t", "\\t");
73
+ return `"${escaped}"`;
74
+ }
75
+
22
76
  export function escapeXmlAttr(value: string): string {
23
77
  return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
24
78
  }
@@ -27,7 +27,6 @@ export interface DialectToolResult {
27
27
 
28
28
  export interface DialectRenderOptions {
29
29
  readonly tools?: readonly InbandTool[];
30
- readonly example?: boolean;
31
30
  }
32
31
 
33
32
  export interface DialectDefinition {
package/src/error/aws.ts CHANGED
@@ -9,7 +9,11 @@ export type AwsCredentialsErrorKind =
9
9
  /** SSO `GetRoleCredentials` call failed or returned no role. */
10
10
  | "sso-role"
11
11
  /** External `credential_process` failed, timed out, or emitted bad output. */
12
- | "credential-process";
12
+ | "credential-process"
13
+ /** STS web-identity exchange failed or returned malformed credentials. */
14
+ | "web-identity"
15
+ /** ECS/container credential endpoint failed or returned malformed credentials. */
16
+ | "container";
13
17
 
14
18
  /** A failure resolving AWS credentials for the Bedrock provider. */
15
19
  export class AwsCredentialsError extends Error {
@@ -270,6 +270,14 @@ export function isStreamReadErrorText(text: string): boolean {
270
270
  return STREAM_READ_ERROR_PATTERN.test(text);
271
271
  }
272
272
 
273
+ /** Persisted-text form of {@link isStreamEnvelopeError}: recognizes the
274
+ * prefix-tagged envelope diagnostic on an aborted turn's `errorMessage` /
275
+ * `stopDetails.explanation` so loop-level salvage can classify it after the
276
+ * original `Error` instance is gone. */
277
+ export function isStreamEnvelopeErrorText(text: string): boolean {
278
+ return text.includes(STREAM_ENVELOPE_ERROR_PREFIX);
279
+ }
280
+
273
281
  function isTransientErrorText(text: string): boolean {
274
282
  return (
275
283
  isUnexpectedSocketCloseMessage(text) ||
@@ -10,9 +10,10 @@
10
10
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
11
11
  import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
12
12
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
13
- import { $env, $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
13
+ import { $flag, fetchWithRetry, parseStreamingJson, parseStreamingJsonThrottled } from "@oh-my-pi/pi-utils";
14
14
  import { renderDemotedThinking } from "../dialect/demotion";
15
15
  import * as AIError from "../error";
16
+ import { resolveAwsBearerToken } from "../registry/aws";
16
17
  import type {
17
18
  Api,
18
19
  AssistantMessage,
@@ -30,6 +31,7 @@ import type {
30
31
  ToolResultMessage,
31
32
  } from "../types";
32
33
  import { normalizeSystemPrompts, normalizeToolCallId, resolveCacheRetention } from "../utils";
34
+ import { resolveAwsAmbientRegion } from "../utils/aws-profile";
33
35
  import {
34
36
  clearStreamingPartialJson,
35
37
  kStreamingBlockIndex,
@@ -74,11 +76,9 @@ export interface BedrockOptions extends StreamOptions {
74
76
  */
75
77
  thinkingDisplay?: BedrockThinkingDisplay;
76
78
  }
77
- const AUTHENTICATED_API_KEY_SENTINEL = "<authenticated>";
78
79
 
79
80
  function resolveBearerToken(options: BedrockOptions): string | undefined {
80
- const apiKey = options.apiKey === AUTHENTICATED_API_KEY_SENTINEL ? undefined : options.apiKey;
81
- return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
81
+ return resolveAwsBearerToken(options.apiKey, options.bearerToken);
82
82
  }
83
83
 
84
84
  function inferRegionFromBedrockArn(modelId: string): string | undefined {
@@ -149,7 +149,7 @@ function regionServesGeo(region: string, geo: string): boolean {
149
149
  function resolveBedrockRegion(modelId: string, options: BedrockOptions): string {
150
150
  const explicit = options.region || inferRegionFromBedrockArn(modelId);
151
151
  if (explicit) return explicit;
152
- const ambient = $env.AWS_REGION || $env.AWS_DEFAULT_REGION;
152
+ const ambient = resolveAwsAmbientRegion(options.profile);
153
153
  const geo = inferenceProfileGeo(modelId);
154
154
  if (geo) {
155
155
  if (ambient && regionServesGeo(ambient, geo)) return ambient;
@@ -2563,7 +2563,22 @@ const streamAnthropicOnce = (
2563
2563
  if (!sawEvent || !sawMessageStart) {
2564
2564
  throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_start");
2565
2565
  }
2566
+ if (!sawTerminalEnvelope) {
2567
+ // Neither a message_delta stop_reason nor message_stop arrived: the
2568
+ // connection died mid-generation. Finalizing the partial message as
2569
+ // a clean "stop" would make the agent loop treat the truncated turn
2570
+ // as complete (silent mid-sentence halt), so fail the turn. The
2571
+ // envelope error is transparently retried before replay-unsafe
2572
+ // content streams; afterwards it surfaces as an error turn whose
2573
+ // complete tool calls the agent loop salvages
2574
+ // (`recoverTransientErrorToolTurn` recognizes the envelope-error
2575
+ // text and `retainCompletedToolCalls` drops half-streamed calls).
2576
+ throw new AIError.AnthropicStreamEnvelopeError("stream ended before message_stop");
2577
+ }
2566
2578
  if (!sawMessageStop) {
2579
+ // A stop_reason arrived via message_delta, so generation finished;
2580
+ // only the trailing message_stop frame is missing (non-conforming
2581
+ // gateway). Degrade to best-effort instead of discarding the turn.
2567
2582
  reportAnthropicEnvelopeAnomaly("stream ended before message_stop");
2568
2583
  }
2569
2584
  if (openBlocks.size > 0) {
@@ -2780,15 +2795,48 @@ type SystemBlockOptions = {
2780
2795
  cacheControl?: AnthropicCacheControl;
2781
2796
  };
2782
2797
 
2783
- function applyClaudeCodeSystemCache(
2798
+ /**
2799
+ * Place system-block cache breakpoints that survive volatile project context.
2800
+ *
2801
+ * omp normally appends its project footer (cwd, date, workspace tree) after the
2802
+ * stable system prefix. When cwd is outside a single direct child repository,
2803
+ * an active-repo context block follows that footer. Caching up to the last three
2804
+ * eligible blocks therefore covers both layouts:
2805
+ *
2806
+ * - stable prefix, project footer
2807
+ * - stable prefix, project footer, active-repo context
2808
+ *
2809
+ * A footer change can then fall back to the stable-prefix entry instead of
2810
+ * re-writing the entire system cache (issue #7324).
2811
+ *
2812
+ * @returns breakpoints placed, capped by `maxBreakpoints`.
2813
+ */
2814
+ function cacheSystemPrefixBreakpoints(
2784
2815
  blocks: AnthropicSystemBlock[],
2785
2816
  cacheControl: AnthropicCacheControl | undefined,
2817
+ maxBreakpoints: number,
2818
+ firstCacheableIndex: number,
2786
2819
  ): number {
2787
- if (!cacheControl || blocks.length === 0) return 0;
2788
- const lastIndex = blocks.length - 1;
2789
- if (blocks[lastIndex].cache_control != null) return 0;
2790
- blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
2791
- return 1;
2820
+ if (!cacheControl || maxBreakpoints <= 0) return 0;
2821
+ let placed = 0;
2822
+ for (let index = blocks.length - 1; index >= firstCacheableIndex && placed < maxBreakpoints; index--) {
2823
+ if (blocks[index].cache_control != null) continue;
2824
+ blocks[index] = { ...blocks[index], cache_control: cloneAnthropicCacheControl(cacheControl) };
2825
+ placed++;
2826
+ }
2827
+ return placed;
2828
+ }
2829
+
2830
+ /**
2831
+ * First system-block index that may carry a cache breakpoint. Skips the OAuth
2832
+ * cloak blocks that must stay uncached: the CC billing header (block 0, a
2833
+ * per-request fingerprint) and the Claude Code identity instruction (block 1).
2834
+ */
2835
+ function firstCacheableSystemIndex(blocks: readonly AnthropicSystemBlock[]): number {
2836
+ let index = 0;
2837
+ if (blocks[index]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX)) index++;
2838
+ if (blocks[index]?.text === claudeCodeSystemInstruction) index++;
2839
+ return index;
2792
2840
  }
2793
2841
 
2794
2842
  export function buildAnthropicSystemBlocks(
@@ -2812,7 +2860,7 @@ export function buildAnthropicSystemBlocks(
2812
2860
  for (const prompt of sanitizedPrompts) {
2813
2861
  blocks.push({ type: "text", text: prompt });
2814
2862
  }
2815
- applyClaudeCodeSystemCache(blocks, cacheControl);
2863
+ cacheSystemPrefixBreakpoints(blocks, cacheControl, 3, firstCacheableSystemIndex(blocks));
2816
2864
 
2817
2865
  return blocks;
2818
2866
  }
@@ -3083,17 +3131,6 @@ type CacheControlBlock = {
3083
3131
  cache_control?: AnthropicCacheControl | null;
3084
3132
  };
3085
3133
 
3086
- function applyCacheControlToLastBlock<T extends CacheControlBlock>(
3087
- blocks: T[],
3088
- cacheControl: AnthropicCacheControl,
3089
- ): boolean {
3090
- if (blocks.length === 0) return false;
3091
- const lastIndex = blocks.length - 1;
3092
- if (blocks[lastIndex].cache_control != null) return false;
3093
- blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
3094
- return true;
3095
- }
3096
-
3097
3134
  function applyCacheControlToLastTextBlock(
3098
3135
  blocks: Array<ContentBlockParam & CacheControlBlock>,
3099
3136
  cacheControl: AnthropicCacheControl,
@@ -3127,24 +3164,32 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3127
3164
  let isCCLayout = false;
3128
3165
 
3129
3166
  if (params.system && Array.isArray(params.system) && params.system.length > 0) {
3130
- isCCLayout =
3131
- params.system.length >= 3 &&
3132
- (params.system[0] as { text?: string }).text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
3133
- if (isCCLayout) {
3134
- const placed = Math.min(
3135
- MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed,
3136
- applyClaudeCodeSystemCache(params.system as AnthropicSystemBlock[], cacheControl),
3137
- );
3138
- cacheBreakpointsUsed += placed;
3139
- } else if (applyCacheControlToLastBlock(params.system, cacheControl)) {
3140
- cacheBreakpointsUsed++;
3141
- }
3167
+ isCCLayout = params.system[0]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
3168
+ const maxSystemBreakpoints = Math.min(3, MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed);
3169
+ cacheBreakpointsUsed += cacheSystemPrefixBreakpoints(
3170
+ params.system as AnthropicSystemBlock[],
3171
+ cacheControl,
3172
+ maxSystemBreakpoints,
3173
+ isCCLayout ? firstCacheableSystemIndex(params.system as AnthropicSystemBlock[]) : 0,
3174
+ );
3142
3175
  }
3143
3176
 
3144
3177
  if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
3145
3178
 
3146
- const start = isCCLayout ? Math.max(0, params.messages.length - 1) : Math.max(0, params.messages.length - 2);
3147
- for (let i = start; i < params.messages.length; i++) {
3179
+ // `convertAnthropicMessages` appends this neutral pad after a trailing
3180
+ // assistant because Anthropic rejects assistant-prefill endings. It is absent
3181
+ // from the next normal turn, so caching it wastes a scarce breakpoint; anchor
3182
+ // the cache window on the preceding real assistant instead.
3183
+ const trailingIndex = params.messages.length - 1;
3184
+ const trailingMessage = params.messages[trailingIndex];
3185
+ const hasTrailingAssistantPad =
3186
+ trailingMessage?.role === "user" &&
3187
+ trailingMessage.content === "Continue." &&
3188
+ params.messages[trailingIndex - 1]?.role === "assistant";
3189
+ const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
3190
+ const messageWindowSize = isCCLayout ? 1 : 2;
3191
+ const start = Math.max(0, messageEnd - messageWindowSize + 1);
3192
+ for (let i = messageEnd; i >= start; i--) {
3148
3193
  if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) break;
3149
3194
  const message = params.messages[i];
3150
3195
  if (!message) continue;