fix(agents): bound Anthropic cache markers

This commit is contained in:
Ayaan Zaidi
2026-06-03 14:42:55 +05:30
parent 2454952544
commit 01cc68ee0d
3 changed files with 134 additions and 18 deletions
@@ -14,6 +14,7 @@ type TestPayload = {
messages: Array<{ role: string; content: unknown }>;
service_tier?: string;
system?: unknown;
tools?: unknown;
};
function textBlock(text: string, cache_control?: { type: "ephemeral"; ttl?: "1h" }) {
@@ -214,6 +215,55 @@ describe("anthropic payload policy", () => {
});
});
it("uses the latest tool result when only one message cache marker remains", () => {
const policy = resolveAnthropicPayloadPolicy({
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com/v1",
cacheRetention: "short",
enableCacheControl: true,
});
const payload: TestPayload = {
system: [
{ type: "text", text: "Claude Code identity." },
{ type: "text", text: "Follow policy." },
],
tools: [{ name: "Read", cache_control: { type: "ephemeral" } }],
messages: [
{
role: "user",
content: [{ type: "text", text: "Investigate the cache writes." }],
},
{
role: "assistant",
content: [{ type: "text", text: "I'll inspect the logs." }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "log chunk" }],
},
],
};
applyAnthropicPayloadPolicyToParams(payload, policy);
expect(payload.messages[0]).toEqual({
role: "user",
content: [{ type: "text", text: "Investigate the cache writes." }],
});
expect(payload.messages[2]).toEqual({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
content: "log chunk",
cache_control: { type: "ephemeral" },
},
],
});
});
it("denies proxied Anthropic service tier but honors explicit long TTL for custom hosts", () => {
const policy = resolveAnthropicPayloadPolicy({
provider: "anthropic",
+38 -3
View File
@@ -27,6 +27,8 @@ type AnthropicPayloadPolicyInput = {
serviceTier?: AnthropicServiceTier;
};
const ANTHROPIC_CACHE_CONTROL_LIMIT = 4;
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
export type AnthropicPayloadPolicy = {
allowsServiceTier: boolean;
@@ -142,8 +144,9 @@ function stripAnthropicSystemPromptBoundary(system: unknown): void {
function applyAnthropicCacheControlToMessages(
messages: unknown,
cacheControl: AnthropicEphemeralCacheControl,
markerLimit: number,
): void {
if (!Array.isArray(messages) || messages.length === 0) {
if (!Array.isArray(messages) || messages.length === 0 || markerLimit <= 0) {
return;
}
@@ -162,6 +165,10 @@ function applyAnthropicCacheControlToMessages(
const content = record.content;
if (typeof content === "string") {
if (fallbackToolResult && markerLimit === 1) {
fallbackToolResult.cache_control = cacheControl;
return;
}
record.content = [
{
type: "text",
@@ -169,6 +176,9 @@ function applyAnthropicCacheControlToMessages(
cache_control: cacheControl,
},
];
if (fallbackToolResult && markerLimit > 1) {
fallbackToolResult.cache_control = cacheControl;
}
return;
}
@@ -184,8 +194,12 @@ function applyAnthropicCacheControlToMessages(
const blockRecord = block as Record<string, unknown>;
if (blockRecord.type === "text" || blockRecord.type === "image") {
if (fallbackToolResult && markerLimit === 1) {
fallbackToolResult.cache_control = cacheControl;
return;
}
blockRecord.cache_control = cacheControl;
if (fallbackToolResult) {
if (fallbackToolResult && markerLimit > 1) {
fallbackToolResult.cache_control = cacheControl;
}
return;
@@ -201,6 +215,20 @@ function applyAnthropicCacheControlToMessages(
}
}
function countAnthropicCacheControlMarkers(blocks: unknown): number {
if (!Array.isArray(blocks)) {
return 0;
}
let count = 0;
for (const block of blocks) {
if (block && typeof block === "object" && "cache_control" in block) {
count += 1;
}
}
return count;
}
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
export function resolveAnthropicPayloadPolicy(
input: AnthropicPayloadPolicyInput,
@@ -246,7 +274,14 @@ export function applyAnthropicPayloadPolicyToParams(
return;
}
applyAnthropicCacheControlToMessages(payloadObj.messages, policy.cacheControl);
const usedMarkers =
countAnthropicCacheControlMarkers(payloadObj.system) +
countAnthropicCacheControlMarkers(payloadObj.tools);
applyAnthropicCacheControlToMessages(
payloadObj.messages,
policy.cacheControl,
ANTHROPIC_CACHE_CONTROL_LIMIT - usedMarkers,
);
}
/** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */
+46 -15
View File
@@ -41,6 +41,8 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
const ANTHROPIC_CACHE_CONTROL_LIMIT = 4;
/**
* Resolve cache retention preference.
* Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility.
@@ -939,9 +941,27 @@ function buildParams(
options?: AnthropicOptions,
): MessageCreateParamsStreaming {
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
const systemCacheControlCount = !cacheControl
? 0
: isOAuthTokenResult
? 1 + (context.systemPrompt ? 1 : 0)
: context.systemPrompt
? 1
: 0;
const toolCacheControlCount = cacheControl && context.tools?.length ? 1 : 0;
const messageCacheControlLimit = Math.max(
0,
ANTHROPIC_CACHE_CONTROL_LIMIT - systemCacheControlCount - toolCacheControlCount,
);
const params: MessageCreateParamsStreaming = {
model: model.id,
messages: convertMessages(context.messages, model, isOAuthTokenResult, cacheControl),
messages: convertMessages(
context.messages,
model,
isOAuthTokenResult,
cacheControl,
messageCacheControlLimit,
),
max_tokens: options?.maxTokens ?? model.maxTokens,
stream: true,
};
@@ -1041,6 +1061,7 @@ function convertMessages(
model: Model<"anthropic-messages">,
isOAuthTokenValue: boolean,
cacheControl?: CacheControlEphemeral,
messageCacheControlLimit = 4,
): MessageParam[] {
const params: MessageParam[] = [];
@@ -1178,7 +1199,7 @@ function convertMessages(
}
}
if (cacheControl && params.length > 0) {
if (cacheControl && params.length > 0 && messageCacheControlLimit > 0) {
let fallbackToolResult: ContentBlockParam | undefined;
for (let i = params.length - 1; i >= 0; i--) {
@@ -1191,14 +1212,13 @@ function convertMessages(
for (let j = message.content.length - 1; j >= 0; j--) {
const block = message.content[j];
if (block.type === "text" || block.type === "image") {
(block as typeof block & { cache_control?: typeof cacheControl }).cache_control =
cacheControl;
if (fallbackToolResult) {
(
fallbackToolResult as typeof fallbackToolResult & {
cache_control?: typeof cacheControl;
}
).cache_control = cacheControl;
if (fallbackToolResult && messageCacheControlLimit === 1) {
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
return params;
}
applyContentBlockCacheControl(block, cacheControl);
if (fallbackToolResult && messageCacheControlLimit > 1) {
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
}
return params;
}
@@ -1210,6 +1230,10 @@ function convertMessages(
}
if (typeof message.content === "string") {
if (fallbackToolResult && messageCacheControlLimit === 1) {
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
return params;
}
message.content = [
{
type: "text",
@@ -1217,16 +1241,15 @@ function convertMessages(
cache_control: cacheControl,
},
] as ContentBlockParam[];
if (fallbackToolResult && messageCacheControlLimit > 1) {
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
}
return params;
}
}
if (fallbackToolResult) {
(
fallbackToolResult as typeof fallbackToolResult & {
cache_control?: typeof cacheControl;
}
).cache_control = cacheControl;
applyContentBlockCacheControl(fallbackToolResult, cacheControl);
}
}
@@ -1268,6 +1291,14 @@ function buildSystemPromptBlocks(
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
}
function applyContentBlockCacheControl(
block: ContentBlockParam,
cacheControl: CacheControlEphemeral,
): void {
(block as ContentBlockParam & { cache_control?: CacheControlEphemeral }).cache_control =
cacheControl;
}
function shouldUseFineGrainedToolStreamingBeta(
model: Model<"anthropic-messages">,
context: Context,