@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.
- package/CHANGELOG.md +25 -0
- package/dist/types/dialect/examples.d.ts +18 -2
- package/dist/types/dialect/inventory.d.ts +6 -9
- package/dist/types/dialect/rendering.d.ts +9 -0
- package/dist/types/dialect/types.d.ts +0 -1
- package/dist/types/error/aws.d.ts +5 -1
- package/dist/types/error/flags.d.ts +5 -0
- package/dist/types/providers/aws-credentials.d.ts +5 -10
- package/dist/types/providers/bedrock-mantle.d.ts +13 -0
- package/dist/types/providers/openai-shared.d.ts +17 -5
- package/dist/types/providers/transform-messages.d.ts +1 -1
- package/dist/types/registry/amazon-bedrock.d.ts +7 -1
- package/dist/types/registry/aws.d.ts +13 -0
- package/dist/types/registry/bedrock-mantle.d.ts +22 -0
- package/dist/types/registry/registry.d.ts +25 -1
- package/dist/types/registry/types.d.ts +24 -0
- package/dist/types/types.d.ts +11 -4
- package/dist/types/utils/aws-profile.d.ts +17 -0
- package/dist/types/utils/harmony-leak.d.ts +9 -0
- package/dist/types/utils/schema/typescript.d.ts +8 -2
- package/package.json +4 -4
- package/src/auth-broker/discover.ts +2 -1
- package/src/auth-broker/wire-schema-resource.ts +6 -1
- package/src/auth-storage.ts +2 -2
- package/src/dialect/examples.ts +50 -12
- package/src/dialect/gemini.ts +17 -31
- package/src/dialect/harmony.ts +1 -2
- package/src/dialect/inventory.ts +21 -64
- package/src/dialect/rendering.ts +54 -0
- package/src/dialect/types.ts +0 -1
- package/src/error/aws.ts +5 -1
- package/src/error/flags.ts +8 -0
- package/src/providers/amazon-bedrock.ts +5 -5
- package/src/providers/anthropic.ts +77 -32
- package/src/providers/aws-credentials.ts +262 -76
- package/src/providers/bedrock-mantle.ts +110 -0
- package/src/providers/cursor.ts +6 -3
- package/src/providers/ollama.ts +27 -1
- package/src/providers/openai-codex-responses.ts +19 -5
- package/src/providers/openai-shared.ts +84 -35
- package/src/providers/transform-messages.ts +1 -1
- package/src/registry/amazon-bedrock.ts +9 -14
- package/src/registry/aws.ts +57 -0
- package/src/registry/bedrock-mantle.ts +34 -0
- package/src/registry/google-vertex.ts +2 -2
- package/src/registry/registry.ts +2 -0
- package/src/registry/types.ts +30 -0
- package/src/stream.ts +46 -30
- package/src/types.ts +14 -8
- package/src/utils/aws-profile.ts +88 -0
- package/src/utils/harmony-leak.ts +12 -0
- package/src/utils/schema/typescript.ts +21 -7
- package/src/utils.ts +1 -1
package/src/dialect/gemini.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
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,
|
package/src/dialect/harmony.ts
CHANGED
|
@@ -273,8 +273,7 @@ function parseRecipient(header: string): string {
|
|
|
273
273
|
return match?.[1] ?? "";
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
function renderToolCall(call: ToolCall,
|
|
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
|
|
package/src/dialect/inventory.ts
CHANGED
|
@@ -1,73 +1,30 @@
|
|
|
1
|
-
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
2
1
|
import { jsonSchemaToTypeScript, toolWireSchema } from "../utils/schema";
|
|
3
|
-
import {
|
|
2
|
+
import { renderToolExamplesJsdoc } from "./examples";
|
|
4
3
|
import type { InbandTool } from "./types";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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[]
|
|
12
|
+
export function renderToolInventory(tools: readonly InbandTool[]): string {
|
|
17
13
|
if (tools.length === 0) return "";
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/dialect/rendering.ts
CHANGED
|
@@ -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("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
|
24
78
|
}
|
package/src/dialect/types.ts
CHANGED
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 {
|
package/src/error/flags.ts
CHANGED
|
@@ -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 { $
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 ||
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
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;
|