langchaint 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,856 @@
1
+ """Adapter for the Anthropic Messages API over the official SDK.
2
+
3
+ Verified against anthropic 0.116.0:
4
+ - `messages.parse(output_format=Model)` returns `ParsedMessage[Model]` with a `parsed_output` property;
5
+ the SDK builds the JSON-schema output format and parses the response text.
6
+ - `messages.stream(...)` returns a manager whose entered stream assembles deltas into a `ParsedMessage` snapshot;
7
+ `get_final_message()` returns it.
8
+ - `Usage.input_tokens` excludes cache reads and writes,
9
+ so the three package counters map directly and no all-inclusive provider total exists to cross-check.
10
+ - `Usage.cache_creation` splits cache writes into `ephemeral_5m_input_tokens` and `ephemeral_1h_input_tokens`,
11
+ which bill at different rates.
12
+
13
+ Cache breakpoints: with automatic_prompt_caching bound True,
14
+ the bound adapter puts one `cache_control` marker at the end of the frozen prefix (the system prompt,
15
+ or the last tool when no system prompt is bound) at bind time, and one on the last block of each request's messages,
16
+ so the cached span grows with the conversation.
17
+ Bound False, the adapter writes no marker of its own.
18
+ A part with cache_breakpoint True adds a marker under either binding: on the part's own text or image block
19
+ in a user message, and on the enclosing tool_result block for the last part of a ToolMessage
20
+ (the API documents cache_control on the tool_result block itself; a marked part that is not
21
+ the message's last would silently move the boundary to the block's end, so it aborts instead).
22
+ A system_prompt bound as parts renders one system block per part, marked parts carrying cache_control,
23
+ so a breakpoint can sit inside the frozen prefix (stable instructions marked, semi-stable context after).
24
+ The API allows at most 4 cache_control markers per request.
25
+ The binding's own markers (marked system parts, the automatic frozen-prefix and last-message markers)
26
+ spend slots first; a binding whose markers alone exceed the limit fails at bind with ValueError.
27
+ The remainder is the per-request budget for marked message parts:
28
+ the latest marks up to that budget are written and older ones left unwritten,
29
+ mirroring openai's documented latest-N rule so a conversation that accrues one mark per turn keeps working.
30
+ Every marker carries the adapter's cache_ttl ("5m" by default, omitting the ttl key since it is the API default,
31
+ so the wire form matches markers written before cache_ttl existed; "1h" writes ttl "1h",
32
+ whose writes bill at the PricingTable's cache_write_1h_usd_per_million_tokens).
33
+
34
+ Mapping decisions:
35
+ - ToolMessage becomes a `tool_result` block inside a user message;
36
+ consecutive tool results group into one user message because the API requires alternating roles.
37
+ - `stop_reason` maps end_turn/tool_use/max_tokens/refusal to themselves and every other value to "other".
38
+ - `reasoning_effort` maps to `output_config.effort`.
39
+ """
40
+
41
+ import base64
42
+ import json
43
+ from collections.abc import AsyncIterator, Callable, Sequence
44
+ from dataclasses import dataclass
45
+ from typing import Any, Literal, cast, override
46
+
47
+ import anthropic
48
+ from anthropic import (
49
+ AsyncAnthropic,
50
+ AsyncAnthropicBedrock,
51
+ AsyncAnthropicBedrockMantle,
52
+ Omit,
53
+ omit,
54
+ )
55
+ from anthropic.lib.streaming import AsyncMessageStream
56
+ from anthropic.types import (
57
+ Base64ImageSourceParam,
58
+ CacheControlEphemeralParam,
59
+ ImageBlockParam,
60
+ MessageParam,
61
+ OutputConfigParam,
62
+ ParsedMessage,
63
+ RedactedThinkingBlockParam,
64
+ TextBlockParam,
65
+ ThinkingBlockParam,
66
+ ToolChoiceParam,
67
+ ToolParam,
68
+ ToolResultBlockParam,
69
+ ToolUseBlockParam,
70
+ )
71
+ from pydantic import BaseModel
72
+
73
+ from langchaint.exceptions import (
74
+ AbortBatchError,
75
+ ExceededMaxCompletionTokensError,
76
+ RefusalError,
77
+ StreamProtocolError,
78
+ TransientError,
79
+ )
80
+ from langchaint.messages import (
81
+ AssistantMessage,
82
+ Message,
83
+ Part,
84
+ ReasoningTrace,
85
+ StopReason,
86
+ TextPart,
87
+ ToolCall,
88
+ ToolMessage,
89
+ TurnElement,
90
+ UserMessage,
91
+ )
92
+ from langchaint.provider import (
93
+ Binding,
94
+ BoundProvider,
95
+ ErrorClass,
96
+ PricingTable,
97
+ Provider,
98
+ ProviderResult,
99
+ ProviderStream,
100
+ SpecificTool,
101
+ StreamItem,
102
+ ToolChoice,
103
+ retry_after_seconds_from_headers,
104
+ )
105
+ from langchaint.tools import ToolSchema
106
+ from langchaint.usage import Usage
107
+
108
+ type _ContentBlockParam = (
109
+ TextBlockParam
110
+ | ImageBlockParam
111
+ | ToolUseBlockParam
112
+ | ToolResultBlockParam
113
+ | ThinkingBlockParam
114
+ | RedactedThinkingBlockParam
115
+ )
116
+
117
+ type _AnthropicImageMediaType = Literal["image/gif", "image/jpeg", "image/png", "image/webp"]
118
+
119
+ _ANTHROPIC_IMAGE_MEDIA_TYPES: tuple[_AnthropicImageMediaType, ...] = (
120
+ "image/gif",
121
+ "image/jpeg",
122
+ "image/png",
123
+ "image/webp",
124
+ )
125
+
126
+
127
+ _CACHE_MARKER_REQUEST_LIMIT = 4
128
+ """The API allows at most 4 cache_control markers per request; bind-time markers spend slots first."""
129
+
130
+ type CacheTtl = Literal["5m", "1h"]
131
+ """A cache entry's time to live, the two tiers the API offers; writes bill 1.25x ("5m") or 2x ("1h") base input."""
132
+
133
+
134
+ def _cache_control_param(cache_ttl: CacheTtl) -> CacheControlEphemeralParam:
135
+ """Build one cache_control marker; "5m" omits the ttl key because it is the API default.
136
+
137
+ The omission keeps the "5m" wire form byte-identical to a marker written before cache_ttl existed,
138
+ so upgrading the package alone cannot invalidate a live cache entry.
139
+ """
140
+ if cache_ttl == "5m":
141
+ return {"type": "ephemeral"}
142
+ return {"type": "ephemeral", "ttl": "1h"}
143
+
144
+
145
+ @dataclass(frozen=True, kw_only=True)
146
+ class _AnthropicRequest:
147
+ """The typed request fields one binding precomputes.
148
+
149
+ Fields set to the SDK's omit sentinel leave the provider default in place;
150
+ passing them as explicit keywords (never **kwargs) keeps the SDK's overload resolution intact.
151
+ """
152
+
153
+ model: str
154
+ max_tokens: int
155
+ temperature: float | Omit
156
+ system: list[TextBlockParam] | Omit
157
+ tools: list[ToolParam] | Omit
158
+ tool_choice: ToolChoiceParam | Omit
159
+ output_config: OutputConfigParam | Omit
160
+ automatic_prompt_caching: bool
161
+ cache_ttl: CacheTtl
162
+ message_mark_budget: int
163
+ """What the binding's own markers (system marks, the frozen-prefix and last-message markers) leave
164
+ of the API's 4-marker request limit for per-request marked parts."""
165
+
166
+
167
+ def _part_block(part: Part) -> TextBlockParam | ImageBlockParam:
168
+ """Convert one content Part to its wire block.
169
+
170
+ Raises:
171
+ AbortBatchError: an ImagePart's media_type is outside the API's accepted set;
172
+ the same request would be rejected again.
173
+ """
174
+ if isinstance(part, TextPart):
175
+ return {"type": "text", "text": part.text}
176
+ if part.media_type not in _ANTHROPIC_IMAGE_MEDIA_TYPES:
177
+ raise AbortBatchError(
178
+ f"the Anthropic API accepts image media types "
179
+ f"{_ANTHROPIC_IMAGE_MEDIA_TYPES}, not {part.media_type!r}"
180
+ )
181
+ image_source: Base64ImageSourceParam = {
182
+ "type": "base64",
183
+ "media_type": part.media_type,
184
+ "data": base64.b64encode(part.data).decode("ascii"),
185
+ }
186
+ return {"type": "image", "source": image_source}
187
+
188
+
189
+ def _user_content_blocks(
190
+ user_message: UserMessage,
191
+ ) -> tuple[list[_ContentBlockParam], list[TextBlockParam | ImageBlockParam]]:
192
+ """Convert one UserMessage's content to wire blocks; an image part propagates _part_block's AbortBatchError.
193
+
194
+ The second element holds the blocks whose part sets cache_breakpoint, in content order;
195
+ the caller applies the request-wide marker budget, so no marker is written here.
196
+ """
197
+ blocks: list[_ContentBlockParam] = []
198
+ marked: list[TextBlockParam | ImageBlockParam] = []
199
+ if isinstance(user_message.content, str):
200
+ blocks.append({"type": "text", "text": user_message.content})
201
+ return blocks, marked
202
+ for part in user_message.content:
203
+ block = _part_block(part)
204
+ blocks.append(block)
205
+ if part.cache_breakpoint:
206
+ marked.append(block)
207
+ return blocks, marked
208
+
209
+
210
+ def _tool_result_content(
211
+ content: str | tuple[Part, ...],
212
+ ) -> str | list[TextBlockParam | ImageBlockParam]:
213
+ """Convert one ToolMessage's content to the tool_result content field.
214
+
215
+ A bare string passes through; a sequence of parts becomes wire text and image blocks,
216
+ an image part propagating _part_block's AbortBatchError.
217
+ """
218
+ if isinstance(content, str):
219
+ return content
220
+ return [_part_block(part) for part in content]
221
+
222
+
223
+ def _assistant_content_blocks(assistant_message: AssistantMessage) -> list[_ContentBlockParam]:
224
+ """Convert one AssistantMessage to wire blocks in turn order.
225
+
226
+ A ReasoningTrace's reasoning dict goes to the wire unchanged, routed by its own type key,
227
+ because the API rejects a tool-use continuation whose latest thinking block was modified.
228
+ A trace another provider produced goes to the wire the same way and the API rejects its
229
+ unknown type key, so a conversation replayed through the wrong provider fails loudly;
230
+ switching providers means first rebuilding concluded assistant turns without their traces.
231
+ An empty TextPart is skipped because the API rejects empty text blocks.
232
+
233
+ Raises:
234
+ json.JSONDecodeError: a tool_call.args_json is not valid JSON.
235
+ """
236
+ blocks: list[_ContentBlockParam] = []
237
+ for element in assistant_message.turn:
238
+ if isinstance(element, TextPart):
239
+ if element.text:
240
+ blocks.append(TextBlockParam(type="text", text=element.text))
241
+ elif isinstance(element, ToolCall):
242
+ blocks.append(
243
+ ToolUseBlockParam(
244
+ type="tool_use",
245
+ id=element.id,
246
+ name=element.name,
247
+ input=json.loads(element.args_json),
248
+ )
249
+ )
250
+ elif isinstance(element, ReasoningTrace):
251
+ # The dict is the producing SDK block's model_dump; when this adapter produced it,
252
+ # its shape is the wire param's by construction, and when another provider did,
253
+ # the API rejects the unknown type key (the loud failure the docstring states).
254
+ # Reconstructing it field by field would risk the exact
255
+ # byte-level change the API rejects. The shallow copy keeps the wire path
256
+ # (which mutates blocks to place cache breakpoints) from ever writing into the
257
+ # frozen message's stored payload.
258
+ blocks.append(
259
+ cast("ThinkingBlockParam | RedactedThinkingBlockParam", dict(element.reasoning))
260
+ )
261
+ return blocks
262
+
263
+
264
+ def _tool_message_is_marked(tool_message: ToolMessage) -> bool:
265
+ """Whether the tool message's last part sets cache_breakpoint, marking the enclosing tool_result block.
266
+
267
+ The marker goes on the tool_result block itself, the placement the API documents;
268
+ for the message's last part that is equivalent, because the block's span ends where that part ends.
269
+
270
+ Raises:
271
+ AbortBatchError: a part other than the message's last sets cache_breakpoint;
272
+ the enclosing block's marker would silently move the boundary to the block's end,
273
+ and the same request would abort again.
274
+ """
275
+ if isinstance(tool_message.content, str):
276
+ return False
277
+ marked_indexes = [
278
+ index for index, part in enumerate(tool_message.content) if part.cache_breakpoint
279
+ ]
280
+ if not marked_indexes:
281
+ return False
282
+ if marked_indexes != [len(tool_message.content) - 1]:
283
+ raise AbortBatchError(
284
+ "cache_breakpoint on a ToolMessage part is honored only on the message's last part: "
285
+ "the marker goes on the enclosing tool_result block, whose span ends at the last part"
286
+ )
287
+ return True
288
+
289
+
290
+ def _wire_messages(
291
+ conversation: Sequence[Message],
292
+ *,
293
+ automatic_prompt_caching: bool,
294
+ cache_ttl: CacheTtl,
295
+ message_mark_budget: int,
296
+ ) -> list[MessageParam]:
297
+ """Convert a conversation to wire messages.
298
+
299
+ With automatic_prompt_caching, places the per-request cache breakpoint on the last content block,
300
+ so the cached span grows with the conversation.
301
+ A thinking or redacted_thinking last block gets no breakpoint (its wire param has no cache_control key),
302
+ so that request writes none.
303
+ A part with cache_breakpoint marks its own block in a user message
304
+ and the enclosing tool_result block in a tool message;
305
+ the latest marks up to message_mark_budget are written and older ones left unwritten.
306
+ message_mark_budget is what the binding's markers leave of the request limit,
307
+ computed once in _request; at 0, every mark goes unwritten.
308
+
309
+ Raises:
310
+ AbortBatchError: an image part's media_type is outside the API's set (from _part_block),
311
+ or a ToolMessage part other than the last sets cache_breakpoint (from the tool_result marking).
312
+ json.JSONDecodeError: a tool_call.args_json is not valid JSON (from _assistant_content_blocks).
313
+ """
314
+ wire: list[tuple[Literal["user", "assistant"], list[_ContentBlockParam]]] = []
315
+ pending_tool_results: list[_ContentBlockParam] = []
316
+ marked_blocks: list[TextBlockParam | ImageBlockParam | ToolResultBlockParam] = []
317
+
318
+ def flush_tool_results() -> None:
319
+ """Group buffered consecutive tool results into one user message."""
320
+ if pending_tool_results:
321
+ wire.append(("user", list(pending_tool_results)))
322
+ pending_tool_results.clear()
323
+
324
+ for message in conversation:
325
+ if isinstance(message, ToolMessage):
326
+ tool_result_block: ToolResultBlockParam = {
327
+ "type": "tool_result",
328
+ "tool_use_id": message.tool_call_id,
329
+ "content": _tool_result_content(message.content),
330
+ "is_error": message.is_error,
331
+ }
332
+ if _tool_message_is_marked(message):
333
+ marked_blocks.append(tool_result_block)
334
+ pending_tool_results.append(tool_result_block)
335
+ elif isinstance(message, UserMessage):
336
+ flush_tool_results()
337
+ blocks, marked = _user_content_blocks(message)
338
+ marked_blocks.extend(marked)
339
+ wire.append(("user", blocks))
340
+ else:
341
+ flush_tool_results()
342
+ wire.append(("assistant", _assistant_content_blocks(message)))
343
+ flush_tool_results()
344
+ if message_mark_budget > 0:
345
+ for block in marked_blocks[-message_mark_budget:]:
346
+ block["cache_control"] = _cache_control_param(cache_ttl)
347
+ if automatic_prompt_caching and wire:
348
+ last_blocks = wire[-1][1]
349
+ if last_blocks:
350
+ last_block = last_blocks[-1]
351
+ if last_block["type"] != "thinking" and last_block["type"] != "redacted_thinking":
352
+ last_block["cache_control"] = _cache_control_param(cache_ttl)
353
+ return [MessageParam(role=role, content=blocks) for role, blocks in wire]
354
+
355
+
356
+ def _wire_tool_choice(tool_choice: ToolChoice, *, parallel_tool_calls: bool) -> ToolChoiceParam:
357
+ """Convert the neutral tool choice; neutral "required" is Anthropic "any"."""
358
+ disable_parallel_tool_use = not parallel_tool_calls
359
+ if isinstance(tool_choice, SpecificTool):
360
+ return {
361
+ "type": "tool",
362
+ "name": tool_choice.tool_name,
363
+ "disable_parallel_tool_use": disable_parallel_tool_use,
364
+ }
365
+ if tool_choice == "auto":
366
+ return {"type": "auto", "disable_parallel_tool_use": disable_parallel_tool_use}
367
+ if tool_choice == "required":
368
+ return {"type": "any", "disable_parallel_tool_use": disable_parallel_tool_use}
369
+ return {"type": "none"}
370
+
371
+
372
+ def _wire_tools(
373
+ tool_schemas: tuple[ToolSchema, ...],
374
+ *,
375
+ cache_breakpoint_on_last_tool: bool,
376
+ cache_ttl: CacheTtl,
377
+ ) -> list[ToolParam]:
378
+ """Convert tool schemas to wire tools.
379
+
380
+ cache_breakpoint_on_last_tool puts the frozen-prefix cache breakpoint on the last tool,
381
+ used when no system prompt follows the tools to carry it.
382
+ """
383
+ tools: list[ToolParam] = [
384
+ {
385
+ "name": tool_schema.name,
386
+ "description": tool_schema.description,
387
+ "input_schema": dict(tool_schema.args_schema),
388
+ }
389
+ for tool_schema in tool_schemas
390
+ ]
391
+ if cache_breakpoint_on_last_tool and tools:
392
+ tools[-1]["cache_control"] = _cache_control_param(cache_ttl)
393
+ return tools
394
+
395
+
396
+ def _normalized_stop_reason(stop_reason: str | None) -> StopReason:
397
+ """Map the provider stop reason into the package vocabulary."""
398
+ if stop_reason in ("end_turn", "tool_use", "max_tokens", "refusal"):
399
+ return stop_reason
400
+ return "other"
401
+
402
+
403
+ def _assistant_message_from(message: anthropic.types.Message) -> AssistantMessage:
404
+ """Build the package assistant turn from the SDK message, block order preserved.
405
+
406
+ A thinking or redacted_thinking block becomes a ReasoningTrace carrying the block's own
407
+ model_dump for verbatim replay; server tool blocks are dropped (built-in tools are out of scope).
408
+ """
409
+ turn: list[TurnElement] = []
410
+ for block in message.content:
411
+ if block.type == "text":
412
+ turn.append(TextPart(text=block.text))
413
+ elif block.type == "tool_use":
414
+ turn.append(
415
+ ToolCall(id=block.id, name=block.name, args_json=json.dumps(block.input))
416
+ )
417
+ elif block.type in ("thinking", "redacted_thinking"):
418
+ turn.append(
419
+ ReasoningTrace(reasoning=block.model_dump(mode="python", exclude_none=True))
420
+ )
421
+ return AssistantMessage(turn=tuple(turn))
422
+
423
+
424
+ def _normalized_usage(usage: anthropic.types.Usage) -> Usage:
425
+ """Map the raw counters onto the package's disjoint partition.
426
+
427
+ `usage.input_tokens` excludes cache reads and writes (verified against anthropic 0.116.0),
428
+ so it is exactly the uncached-input counter and no provider-reported all-inclusive total exists.
429
+ """
430
+ return Usage(
431
+ input_tokens_cache_read=usage.cache_read_input_tokens or 0,
432
+ input_tokens_cache_write=usage.cache_creation_input_tokens or 0,
433
+ input_tokens_cache_none=usage.input_tokens,
434
+ output_tokens=usage.output_tokens,
435
+ input_tokens_total_provider_reported=None,
436
+ )
437
+
438
+
439
+ def _cost_in_usd(usage: anthropic.types.Usage, pricing: PricingTable) -> float:
440
+ """Price the raw counts.
441
+
442
+ 5-minute and 1-hour cache writes bill at different rates, split by usage.cache_creation.
443
+
444
+ Raises:
445
+ AbortBatchError:
446
+ the response reports 1-hour cache writes but the PricingTable has no cache_write_1h_usd_per_million_tokens.
447
+ """
448
+ cache_write_5m_tokens = usage.cache_creation_input_tokens or 0
449
+ cache_write_1h_tokens = 0
450
+ if usage.cache_creation is not None:
451
+ cache_write_5m_tokens = usage.cache_creation.ephemeral_5m_input_tokens
452
+ cache_write_1h_tokens = usage.cache_creation.ephemeral_1h_input_tokens
453
+ cost_in_usd = (
454
+ usage.input_tokens * pricing.input_cache_none_usd_per_million_tokens
455
+ + (usage.cache_read_input_tokens or 0) * pricing.cache_read_usd_per_million_tokens
456
+ + cache_write_5m_tokens * pricing.cache_write_usd_per_million_tokens
457
+ + usage.output_tokens * pricing.output_usd_per_million_tokens
458
+ ) / 1_000_000
459
+ if cache_write_1h_tokens:
460
+ if pricing.cache_write_1h_usd_per_million_tokens is None:
461
+ raise AbortBatchError(
462
+ "the response reports 1-hour cache writes but the PricingTable "
463
+ "has no cache_write_1h_usd_per_million_tokens"
464
+ )
465
+ cost_in_usd += (
466
+ cache_write_1h_tokens * pricing.cache_write_1h_usd_per_million_tokens / 1_000_000
467
+ )
468
+ return cost_in_usd
469
+
470
+
471
+ def _provider_result[OutputT](
472
+ message: anthropic.types.Message, output: OutputT, pricing: PricingTable
473
+ ) -> ProviderResult[OutputT]:
474
+ """Normalize one completed message around already-extracted output."""
475
+ return ProviderResult(
476
+ output=output,
477
+ assistant_message=_assistant_message_from(message),
478
+ usage=_normalized_usage(message.usage),
479
+ cost_in_usd=_cost_in_usd(usage=message.usage, pricing=pricing),
480
+ stop_reason=_normalized_stop_reason(message.stop_reason),
481
+ raw=message,
482
+ )
483
+
484
+
485
+ class AnthropicMessagesProvider(Provider):
486
+ """Adapter over an AsyncAnthropic, AsyncAnthropicBedrock, or AsyncAnthropicBedrockMantle client.
487
+
488
+ The three clients expose the same messages.create/parse/stream methods and with_options,
489
+ so the adapter logic is identical across the first-party API and both Bedrock surfaces.
490
+ default_max_completion_tokens fills the API-required max_tokens
491
+ when the binding's inference_params leave max_completion_tokens None.
492
+ """
493
+
494
+ name = "anthropic_messages"
495
+
496
+ def __init__(
497
+ self,
498
+ *,
499
+ client: AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicBedrockMantle,
500
+ model: str,
501
+ pricing: PricingTable,
502
+ default_max_completion_tokens: int = 4096,
503
+ cache_ttl: CacheTtl = "5m",
504
+ ) -> None:
505
+ """Store the SDK client, which owns credentials and endpoints.
506
+
507
+ The stored client is a with_options(max_retries=0) copy: the package's retry loop owns all retrying,
508
+ counts every request as an attempt, and feeds rate-limit errors to the RateLimiter,
509
+ so the SDK must never retry beneath it.
510
+ cache_ttl applies uniformly to every cache_control marker this adapter writes,
511
+ automatic and cache_breakpoint alike; "5m" is the API default and writes bill 1.25x base input,
512
+ "1h" holds entries across longer gaps and writes bill 2x
513
+ (priced by the PricingTable's cache_write_1h_usd_per_million_tokens).
514
+ A uniform TTL per adapter also sidesteps the API's rules for mixing TTLs within one request.
515
+ """
516
+ super().__init__(model=model, pricing=pricing)
517
+ self.client = client.with_options(max_retries=0)
518
+ self.default_max_completion_tokens = default_max_completion_tokens
519
+ self.cache_ttl: CacheTtl = cache_ttl
520
+
521
+ def _request(self, binding: Binding) -> _AnthropicRequest:
522
+ """Precompute the typed request fields the binding determines.
523
+
524
+ A str system_prompt is one system block; a parts system_prompt is one block per part,
525
+ each marked part carrying cache_control.
526
+ automatic_prompt_caching marks the last system block (idempotent when it is already marked)
527
+ or, with no system prompt, the last tool.
528
+ The binding's markers spend the API's 4-marker request limit first;
529
+ message_mark_budget carries the remainder to _wire_messages.
530
+
531
+ Raises:
532
+ ValueError: the binding's markers alone (marked system parts plus the automatic markers)
533
+ exceed the API's 4-marker request limit; unmark some system parts.
534
+ Also raised on an empty tuple system_prompt,
535
+ which bind rejects and only a directly constructed Binding can carry.
536
+ """
537
+ max_tokens = binding.inference_params.max_completion_tokens
538
+ system: list[TextBlockParam] | Omit = omit
539
+ bind_marker_count = 0
540
+ if binding.system_prompt is not None:
541
+ system_blocks: list[TextBlockParam] = []
542
+ if isinstance(binding.system_prompt, str):
543
+ system_blocks.append({"type": "text", "text": binding.system_prompt})
544
+ else:
545
+ if not binding.system_prompt:
546
+ raise ValueError(
547
+ "system_prompt is an empty tuple of parts; bind rejects this, "
548
+ "so it can only come from a directly constructed Binding"
549
+ )
550
+ for part in binding.system_prompt:
551
+ system_block: TextBlockParam = {"type": "text", "text": part.text}
552
+ if part.cache_breakpoint:
553
+ system_block["cache_control"] = _cache_control_param(self.cache_ttl)
554
+ system_blocks.append(system_block)
555
+ if binding.automatic_prompt_caching:
556
+ system_blocks[-1]["cache_control"] = _cache_control_param(self.cache_ttl)
557
+ bind_marker_count = sum(1 for block in system_blocks if "cache_control" in block)
558
+ system = system_blocks
559
+ tools: list[ToolParam] | Omit = omit
560
+ tool_choice: ToolChoiceParam | Omit = omit
561
+ if binding.tool_schemas:
562
+ cache_breakpoint_on_last_tool = (
563
+ binding.automatic_prompt_caching and binding.system_prompt is None
564
+ )
565
+ tools = _wire_tools(
566
+ binding.tool_schemas,
567
+ cache_breakpoint_on_last_tool=cache_breakpoint_on_last_tool,
568
+ cache_ttl=self.cache_ttl,
569
+ )
570
+ if cache_breakpoint_on_last_tool:
571
+ bind_marker_count += 1
572
+ tool_choice = _wire_tool_choice(
573
+ binding.tool_choice, parallel_tool_calls=binding.parallel_tool_calls
574
+ )
575
+ last_message_marker_count = 1 if binding.automatic_prompt_caching else 0
576
+ message_mark_budget = (
577
+ _CACHE_MARKER_REQUEST_LIMIT - bind_marker_count - last_message_marker_count
578
+ )
579
+ if message_mark_budget < 0:
580
+ raise ValueError(
581
+ f"the binding writes {bind_marker_count + last_message_marker_count} cache markers, "
582
+ f"over the API's limit of {_CACHE_MARKER_REQUEST_LIMIT} per request; "
583
+ f"unmark some system parts"
584
+ )
585
+ output_config: OutputConfigParam | Omit = omit
586
+ if binding.inference_params.reasoning_effort is not None:
587
+ output_config = {"effort": binding.inference_params.reasoning_effort}
588
+ return _AnthropicRequest(
589
+ model=self.model,
590
+ max_tokens=(
591
+ max_tokens if max_tokens is not None else self.default_max_completion_tokens
592
+ ),
593
+ temperature=(
594
+ binding.inference_params.temperature
595
+ if binding.inference_params.temperature is not None
596
+ else omit
597
+ ),
598
+ system=system,
599
+ tools=tools,
600
+ tool_choice=tool_choice,
601
+ output_config=output_config,
602
+ automatic_prompt_caching=binding.automatic_prompt_caching,
603
+ cache_ttl=self.cache_ttl,
604
+ message_mark_budget=message_mark_budget,
605
+ )
606
+
607
+ @override
608
+ def bind_text(self, binding: Binding) -> BoundProvider[str]:
609
+ """Bind for plain-text output; pure conversion, no I/O."""
610
+ return _BoundAnthropicText(adapter=self, request=self._request(binding))
611
+
612
+ @override
613
+ def bind_structured[ModelT: BaseModel](
614
+ self, binding: Binding, response_format: type[ModelT]
615
+ ) -> BoundProvider[ModelT]:
616
+ """Bind for structured output parsed by the SDK; pure conversion, no I/O."""
617
+ return _BoundAnthropicStructured(
618
+ adapter=self,
619
+ request=self._request(binding),
620
+ response_format=response_format,
621
+ )
622
+
623
+ @override
624
+ def classify(self, error: Exception) -> ErrorClass:
625
+ """Map the SDK exception to rate_limit, transient, or abort.
626
+
627
+ Rate limit: RateLimitError (429) and OverloadedError (529);
628
+ both mean further requests from this account fail the same way right now,
629
+ so admission should pause account-wide.
630
+ Transient: other 5xx, timeouts, connection failures.
631
+ Everything unrecognized is abort so bugs are not retried.
632
+ """
633
+ if isinstance(error, (anthropic.RateLimitError, anthropic.OverloadedError)):
634
+ return "rate_limit"
635
+ if isinstance(error, (anthropic.InternalServerError, anthropic.APIConnectionError)):
636
+ return "transient"
637
+ return "abort"
638
+
639
+ @override
640
+ def retry_after_seconds(self, error: Exception) -> float | None:
641
+ """Read the server-stated wait from the SDK exception's response headers."""
642
+ if isinstance(error, anthropic.APIStatusError):
643
+ return retry_after_seconds_from_headers(error.response.headers)
644
+ return None
645
+
646
+
647
+ class _AnthropicStream[OutputT](ProviderStream[OutputT]):
648
+ """One open Messages stream, backed by the SDK's AsyncMessageStream."""
649
+
650
+ def __init__(
651
+ self,
652
+ *,
653
+ sdk_stream: AsyncMessageStream[Any],
654
+ pricing: PricingTable,
655
+ output_from_message: Callable[[ParsedMessage[Any]], OutputT],
656
+ ) -> None:
657
+ self._sdk_stream = sdk_stream
658
+ self._pricing = pricing
659
+ self._output_from_message = output_from_message
660
+
661
+ @override
662
+ async def items(self) -> AsyncIterator[StreamItem]:
663
+ """Translate the SDK stream into text chunks and completed tool calls.
664
+
665
+ Text chunks are the SDK deltas' own strings, passed through without wrapping.
666
+ A tool call is yielded once, when its content block closes,
667
+ built from the SDK-accumulated block exactly like the non-streaming path.
668
+
669
+ Yields:
670
+ Stream items; SDK events the package does not model are dropped.
671
+
672
+ Raises:
673
+ StreamProtocolError: the stream ended without a stop reason.
674
+ """
675
+ async for event in self._sdk_stream:
676
+ if event.type == "content_block_delta":
677
+ if event.delta.type == "text_delta":
678
+ yield event.delta.text
679
+ elif event.type == "content_block_stop" and event.content_block.type == "tool_use":
680
+ yield ToolCall(
681
+ id=event.content_block.id,
682
+ name=event.content_block.name,
683
+ args_json=json.dumps(event.content_block.input),
684
+ )
685
+ if self._sdk_stream.current_message_snapshot.stop_reason is None:
686
+ raise StreamProtocolError("stream ended without a stop reason")
687
+
688
+ @override
689
+ async def final(self) -> ProviderResult[OutputT]:
690
+ """Return the SDK-assembled result after the stream ends."""
691
+ message = await self._sdk_stream.get_final_message()
692
+ return _provider_result(
693
+ message=message, output=self._output_from_message(message), pricing=self._pricing
694
+ )
695
+
696
+ @override
697
+ async def close(self) -> None:
698
+ """Close the underlying connection; idempotent."""
699
+ await self._sdk_stream.close()
700
+
701
+
702
+ class _BoundAnthropicText(BoundProvider[str]):
703
+ """Text-bound provider: output is the concatenated text of the turn."""
704
+
705
+ def __init__(self, *, adapter: AnthropicMessagesProvider, request: _AnthropicRequest) -> None:
706
+ self._adapter = adapter
707
+ self._request = request
708
+
709
+ @override
710
+ async def send(self, conversation: Sequence[Message]) -> ProviderResult[str]:
711
+ """Send one non-streaming request via messages.create."""
712
+ message = await self._adapter.client.messages.create(
713
+ model=self._request.model,
714
+ max_tokens=self._request.max_tokens,
715
+ temperature=self._request.temperature,
716
+ system=self._request.system,
717
+ tools=self._request.tools,
718
+ tool_choice=self._request.tool_choice,
719
+ output_config=self._request.output_config,
720
+ messages=_wire_messages(
721
+ conversation,
722
+ automatic_prompt_caching=self._request.automatic_prompt_caching,
723
+ cache_ttl=self._request.cache_ttl,
724
+ message_mark_budget=self._request.message_mark_budget,
725
+ ),
726
+ )
727
+ return _provider_result(
728
+ message=message,
729
+ output=_assistant_message_from(message).text,
730
+ pricing=self._adapter.pricing,
731
+ )
732
+
733
+ @override
734
+ async def open_stream(self, conversation: Sequence[Message]) -> ProviderStream[str]:
735
+ """Open one streaming request; connection failures raise here."""
736
+ manager = self._adapter.client.messages.stream(
737
+ model=self._request.model,
738
+ max_tokens=self._request.max_tokens,
739
+ temperature=self._request.temperature,
740
+ system=self._request.system,
741
+ tools=self._request.tools,
742
+ tool_choice=self._request.tool_choice,
743
+ output_config=self._request.output_config,
744
+ messages=_wire_messages(
745
+ conversation,
746
+ automatic_prompt_caching=self._request.automatic_prompt_caching,
747
+ cache_ttl=self._request.cache_ttl,
748
+ message_mark_budget=self._request.message_mark_budget,
749
+ ),
750
+ )
751
+ sdk_stream = await manager.__aenter__()
752
+ return _AnthropicStream(
753
+ sdk_stream=sdk_stream,
754
+ pricing=self._adapter.pricing,
755
+ output_from_message=lambda message: _assistant_message_from(message).text,
756
+ )
757
+
758
+
759
+ class _BoundAnthropicStructured[ModelT: BaseModel](BoundProvider[ModelT]):
760
+ """Structured-bound provider: output is the SDK-parsed response_format instance."""
761
+
762
+ def __init__(
763
+ self,
764
+ *,
765
+ adapter: AnthropicMessagesProvider,
766
+ request: _AnthropicRequest,
767
+ response_format: type[ModelT],
768
+ ) -> None:
769
+ self._adapter = adapter
770
+ self._request = request
771
+ self._response_format = response_format
772
+
773
+ def _parsed_output(self, message: ParsedMessage[ModelT]) -> ModelT:
774
+ """Extract the parsed instance, or raise the error that classifies why the turn produced none.
775
+
776
+ Each raised error carries this attempt's billing (usage, cost_in_usd,
777
+ stop_reason) so a rejected 200's cost is not lost.
778
+
779
+ Raises:
780
+ RefusalError: the model refused (stop_reason "refusal"); terminal per-item, not retried.
781
+ ExceededMaxCompletionTokensError: the response hit the token cap (stop_reason "max_tokens");
782
+ terminal per-item, not retried.
783
+ TransientError: the turn completed but carried no parsed output for another reason,
784
+ which a later attempt may fix.
785
+ """
786
+ parsed_output = message.parsed_output
787
+ if parsed_output is None:
788
+ usage = _normalized_usage(message.usage)
789
+ cost_in_usd = _cost_in_usd(usage=message.usage, pricing=self._adapter.pricing)
790
+ stop_reason = _normalized_stop_reason(message.stop_reason)
791
+ if message.stop_reason == "refusal":
792
+ raise RefusalError.for_rejected_200(
793
+ usage=usage, cost_in_usd=cost_in_usd, stop_reason=stop_reason
794
+ )
795
+ if message.stop_reason == "max_tokens":
796
+ raise ExceededMaxCompletionTokensError.for_rejected_200(
797
+ usage=usage, cost_in_usd=cost_in_usd, stop_reason=stop_reason
798
+ )
799
+ raise TransientError(
800
+ "structured response contained no parsed output",
801
+ usage=usage,
802
+ cost_in_usd=cost_in_usd,
803
+ stop_reason=stop_reason,
804
+ )
805
+ return parsed_output
806
+
807
+ @override
808
+ async def send(self, conversation: Sequence[Message]) -> ProviderResult[ModelT]:
809
+ """Send one non-streaming request via messages.parse."""
810
+ message = await self._adapter.client.messages.parse(
811
+ model=self._request.model,
812
+ max_tokens=self._request.max_tokens,
813
+ temperature=self._request.temperature,
814
+ system=self._request.system,
815
+ tools=self._request.tools,
816
+ tool_choice=self._request.tool_choice,
817
+ output_config=self._request.output_config,
818
+ messages=_wire_messages(
819
+ conversation,
820
+ automatic_prompt_caching=self._request.automatic_prompt_caching,
821
+ cache_ttl=self._request.cache_ttl,
822
+ message_mark_budget=self._request.message_mark_budget,
823
+ ),
824
+ output_format=self._response_format,
825
+ )
826
+ return _provider_result(
827
+ message=message,
828
+ output=self._parsed_output(message),
829
+ pricing=self._adapter.pricing,
830
+ )
831
+
832
+ @override
833
+ async def open_stream(self, conversation: Sequence[Message]) -> ProviderStream[ModelT]:
834
+ """Open one streaming request; connection failures raise here."""
835
+ manager = self._adapter.client.messages.stream(
836
+ model=self._request.model,
837
+ max_tokens=self._request.max_tokens,
838
+ temperature=self._request.temperature,
839
+ system=self._request.system,
840
+ tools=self._request.tools,
841
+ tool_choice=self._request.tool_choice,
842
+ output_config=self._request.output_config,
843
+ messages=_wire_messages(
844
+ conversation,
845
+ automatic_prompt_caching=self._request.automatic_prompt_caching,
846
+ cache_ttl=self._request.cache_ttl,
847
+ message_mark_budget=self._request.message_mark_budget,
848
+ ),
849
+ output_format=self._response_format,
850
+ )
851
+ sdk_stream = await manager.__aenter__()
852
+ return _AnthropicStream(
853
+ sdk_stream=sdk_stream,
854
+ pricing=self._adapter.pricing,
855
+ output_from_message=self._parsed_output,
856
+ )