@@ -16,6 +16,7 @@ import {
1616 type ToolResultPart ,
1717} from "../schema"
1818import { JsonObject , optionalArray , optionalNull , ProviderShared } from "./shared"
19+ import * as Cache from "./utils/cache"
1920import { ToolStream } from "./utils/tool-stream"
2021
2122const 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
3034const 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
199219const 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
213234const 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
330355const 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 ,
0 commit comments