Skip to content

Commit 77e6c0d

Browse files
authored
feat(llm): cache hint TTL, breakpoint cap, and tool placement (#26779)
1 parent fed716a commit 77e6c0d

12 files changed

Lines changed: 555 additions & 39 deletions

packages/llm/src/protocols/anthropic-messages.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type ToolResultPart,
1717
} from "../schema"
1818
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
19+
import * as Cache from "./utils/cache"
1920
import { ToolStream } from "./utils/tool-stream"
2021

2122
const ADAPTER = "anthropic-messages"
@@ -25,7 +26,10 @@ export const PATH = "/messages"
2526
// =============================================================================
2627
// Request Body Schema
2728
// =============================================================================
28-
const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") })
29+
const AnthropicCacheControl = Schema.Struct({
30+
type: Schema.tag("ephemeral"),
31+
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
32+
})
2933

3034
const AnthropicTextBlock = Schema.Struct({
3135
type: Schema.tag("text"),
@@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest
193197
// =============================================================================
194198
// Request Lowering
195199
// =============================================================================
196-
const cacheControl = (cache: CacheHint | undefined) =>
197-
cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
200+
// Anthropic accepts at most 4 explicit cache_control breakpoints per request,
201+
// across `tools`, `system`, and `messages`. Beyond the cap the API returns a
202+
// 400 — so the lowering layer counts emitted markers and silently drops any
203+
// that exceed it.
204+
const ANTHROPIC_BREAKPOINT_CAP = 4
205+
206+
const EPHEMERAL_5M = { type: "ephemeral" as const }
207+
const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const }
208+
209+
const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => {
210+
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
211+
if (breakpoints.remaining <= 0) {
212+
breakpoints.dropped += 1
213+
return undefined
214+
}
215+
breakpoints.remaining -= 1
216+
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
217+
}
198218

199219
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
200220

@@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
204224
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
205225
}
206226

207-
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
227+
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
208228
name: tool.name,
209229
description: tool.description,
210230
input_schema: tool.inputSchema,
231+
cache_control: cacheControl(breakpoints, tool.cache),
211232
})
212233

213234
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
249270
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
250271
})
251272

252-
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) {
273+
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
274+
request: LLMRequest,
275+
breakpoints: Cache.Breakpoints,
276+
) {
253277
const messages: AnthropicMessage[] = []
254278

255279
for (const message of request.messages) {
@@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
258282
for (const part of message.content) {
259283
if (!ProviderShared.supportsContent(part, ["text"]))
260284
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
261-
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
285+
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
262286
}
263287
messages.push({ role: "user", content })
264288
continue
@@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
268292
const content: AnthropicAssistantBlock[] = []
269293
for (const part of message.content) {
270294
if (part.type === "text") {
271-
content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) })
295+
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
272296
continue
273297
}
274298
if (part.type === "reasoning") {
@@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
304328
tool_use_id: part.id,
305329
content: ProviderShared.toolResultText(part),
306330
is_error: part.result.type === "error" ? true : undefined,
331+
cache_control: cacheControl(breakpoints, part.cache),
307332
})
308333
}
309334
messages.push({ role: "user", content })
@@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
330355
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
331356
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
332357
const generation = request.generation
358+
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
359+
// messages. Tools live highest in the cache hierarchy, so when callers
360+
// over-mark we keep their tool hints and shed the message-tail ones first.
361+
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
362+
const tools =
363+
request.tools.length === 0 || request.toolChoice?.type === "none"
364+
? undefined
365+
: request.tools.map((tool) => lowerTool(breakpoints, tool))
366+
const system =
367+
request.system.length === 0
368+
? undefined
369+
: request.system.map((part) => ({
370+
type: "text" as const,
371+
text: part.text,
372+
cache_control: cacheControl(breakpoints, part.cache),
373+
}))
374+
const messages = yield* lowerMessages(request, breakpoints)
375+
if (breakpoints.dropped > 0) {
376+
yield* Effect.logWarning(
377+
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
378+
)
379+
}
333380
return {
334381
model: request.model.id,
335-
system:
336-
request.system.length === 0
337-
? undefined
338-
: request.system.map((part) => ({
339-
type: "text" as const,
340-
text: part.text,
341-
cache_control: cacheControl(part.cache),
342-
})),
343-
messages: yield* lowerMessages(request),
344-
tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool),
382+
system,
383+
messages,
384+
tools,
345385
tool_choice: toolChoice,
346386
stream: true as const,
347387
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096,

packages/llm/src/protocols/bedrock-converse.ts

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
108108
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
109109
type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
110110

111-
const BedrockTool = Schema.Struct({
111+
const BedrockToolSpec = Schema.Struct({
112112
toolSpec: Schema.Struct({
113113
name: Schema.String,
114114
description: Schema.String,
@@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({
117117
}),
118118
}),
119119
})
120+
type BedrockToolSpec = Schema.Schema.Type<typeof BedrockToolSpec>
121+
122+
const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock])
120123
type BedrockTool = Schema.Schema.Type<typeof BedrockTool>
121124

122125
const BedrockToolChoice = Schema.Union([
@@ -214,19 +217,33 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
214217
// =============================================================================
215218
// Request Lowering
216219
// =============================================================================
217-
const lowerTool = (tool: ToolDefinition): BedrockTool => ({
220+
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
218221
toolSpec: {
219222
name: tool.name,
220223
description: tool.description,
221224
inputSchema: { json: tool.inputSchema },
222225
},
223226
})
224227

228+
const lowerTools = (
229+
breakpoints: BedrockCache.Breakpoints,
230+
tools: ReadonlyArray<ToolDefinition>,
231+
): BedrockTool[] => {
232+
const result: BedrockTool[] = []
233+
for (const tool of tools) {
234+
result.push(lowerToolSpec(tool))
235+
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
236+
if (cachePoint) result.push(cachePoint)
237+
}
238+
return result
239+
}
240+
225241
const textWithCache = (
242+
breakpoints: BedrockCache.Breakpoints,
226243
text: string,
227244
cache: CacheHint | undefined,
228245
): Array<BedrockTextBlock | BedrockCache.CachePointBlock> => {
229-
const cachePoint = BedrockCache.block(cache)
246+
const cachePoint = BedrockCache.block(breakpoints, cache)
230247
return cachePoint ? [{ text }, cachePoint] : [{ text }]
231248
}
232249

@@ -257,7 +274,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({
257274
},
258275
})
259276

260-
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) {
277+
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
278+
request: LLMRequest,
279+
breakpoints: BedrockCache.Breakpoints,
280+
) {
261281
const messages: BedrockMessage[] = []
262282

263283
for (const message of request.messages) {
@@ -267,7 +287,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
267287
if (!ProviderShared.supportsContent(part, ["text", "media"]))
268288
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
269289
if (part.type === "text") {
270-
content.push(...textWithCache(part.text, part.cache))
290+
content.push(...textWithCache(breakpoints, part.text, part.cache))
271291
continue
272292
}
273293
if (part.type === "media") {
@@ -289,7 +309,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
289309
"tool-call",
290310
])
291311
if (part.type === "text") {
292-
content.push(...textWithCache(part.text, part.cache))
312+
content.push(...textWithCache(breakpoints, part.text, part.cache))
293313
continue
294314
}
295315
if (part.type === "reasoning") {
@@ -309,11 +329,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
309329
continue
310330
}
311331

312-
const content: BedrockToolResultBlock[] = []
332+
const content: BedrockUserBlock[] = []
313333
for (const part of message.content) {
314334
if (!ProviderShared.supportsContent(part, ["tool-result"]))
315335
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
316336
content.push(lowerToolResult(part))
337+
const cachePoint = BedrockCache.block(breakpoints, part.cache)
338+
if (cachePoint) content.push(cachePoint)
317339
}
318340
messages.push({ role: "user", content })
319341
}
@@ -323,16 +345,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
323345

324346
// System prompts share the cache-point convention: emit the text block, then
325347
// optionally a positional `cachePoint` marker.
326-
const lowerSystem = (system: ReadonlyArray<LLMRequest["system"][number]>): BedrockSystemBlock[] =>
327-
system.flatMap((part) => textWithCache(part.text, part.cache))
348+
const lowerSystem = (
349+
breakpoints: BedrockCache.Breakpoints,
350+
system: ReadonlyArray<LLMRequest["system"][number]>,
351+
): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
328352

329353
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
330354
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
331355
const generation = request.generation
356+
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
357+
// tools → system → messages order to favour the highest-impact prefixes.
358+
const breakpoints = BedrockCache.breakpoints()
359+
const toolConfig =
360+
request.tools.length > 0 && request.toolChoice?.type !== "none"
361+
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
362+
: undefined
363+
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
364+
const messages = yield* lowerMessages(request, breakpoints)
365+
if (breakpoints.dropped > 0) {
366+
yield* Effect.logWarning(
367+
`Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`,
368+
)
369+
}
332370
return {
333371
modelId: request.model.id,
334-
messages: yield* lowerMessages(request),
335-
system: request.system.length === 0 ? undefined : lowerSystem(request.system),
372+
messages,
373+
system,
336374
inferenceConfig:
337375
generation?.maxTokens === undefined &&
338376
generation?.temperature === undefined &&
@@ -345,10 +383,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
345383
topP: generation?.topP,
346384
stopSequences: generation?.stop,
347385
},
348-
toolConfig:
349-
request.tools.length > 0 && request.toolChoice?.type !== "none"
350-
? { tools: request.tools.map(lowerTool), toolChoice }
351-
: undefined,
386+
toolConfig,
352387
}
353388
})
354389

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
import { Schema } from "effect"
22
import type { CacheHint } from "../../schema"
3+
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache"
34

45
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
5-
// after the content the caller wants treated as a cacheable prefix.
6+
// after the content the caller wants treated as a cacheable prefix. Bedrock
7+
// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic.
68
export const CachePointBlock = Schema.Struct({
7-
cachePoint: Schema.Struct({ type: Schema.tag("default") }),
9+
cachePoint: Schema.Struct({
10+
type: Schema.tag("default"),
11+
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
12+
}),
813
})
914
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
1015

11-
// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map
12-
// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape.
13-
const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } }
16+
// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages
17+
// API. Callers pass a shared counter through every `block()` call site so the
18+
// budget is respected across `system`, `messages`, and `tools`.
19+
export const BEDROCK_BREAKPOINT_CAP = 4
1420

15-
export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => {
21+
export type { Breakpoints } from "./cache"
22+
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
23+
24+
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
25+
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
26+
27+
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
1628
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
17-
return DEFAULT
29+
if (breakpoints.remaining <= 0) {
30+
breakpoints.dropped += 1
31+
return undefined
32+
}
33+
breakpoints.remaining -= 1
34+
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
1835
}
1936

2037
export * as BedrockCache from "./bedrock-cache"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock
2+
// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h`
3+
// TTL buckets, so the counter and TTL mapping live here.
4+
5+
export interface Breakpoints {
6+
remaining: number
7+
dropped: number
8+
}
9+
10+
export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 })
11+
12+
// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the
13+
// provider default 5m). Anthropic & Bedrock both treat anything shorter than
14+
// an hour as 5m.
15+
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
16+
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined

packages/llm/src/schema/messages.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign(
7979
name: Schema.String,
8080
result: ToolResultValue,
8181
providerExecuted: Schema.optional(Schema.Boolean),
82+
cache: Schema.optional(CacheHint),
8283
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
8384
providerMetadata: Schema.optional(ProviderMetadata),
8485
}).annotate({ identifier: "LLM.Content.ToolResult" }),
@@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign(
9495
name: input.name,
9596
result: ToolResultValue.make(input.result, input.resultType),
9697
providerExecuted: input.providerExecuted,
98+
cache: input.cache,
9799
metadata: input.metadata,
98100
providerMetadata: input.providerMetadata,
99101
}),
@@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("LLM.ToolDefini
151153
name: Schema.String,
152154
description: Schema.String,
153155
inputSchema: JsonSchema,
156+
cache: Schema.optional(CacheHint),
154157
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
155158
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
156159
}) {}

0 commit comments

Comments
 (0)