pi-ai-python 1.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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,1782 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """Anthropic Messages API (``anthropic-messages``).
4
+
5
+ Port of pi's ``api/anthropic-messages.ts``. Like pi, requests go through the official SDK
6
+ (``AsyncAnthropic``): ``client.beta.messages.with_raw_response.create(...)`` returns the raw
7
+ response (pi's ``.asResponse()``), whose SSE body is parsed here. Workload identity federation
8
+ (``ANTHROPIC_FEDERATION_RULE_ID`` & co.) is the SDK's: the token exchange and access-token
9
+ cache run in the SDK.
10
+
11
+ The SDK speaks ``httpx2``; pi's ``http_client`` option and connection pool are ``httpx``, so
12
+ SDK requests are routed through the httpx client by a small transport bridge.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextvars
19
+ import dataclasses
20
+ import inspect
21
+ import json
22
+ import os
23
+ import re
24
+ import weakref
25
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Literal, TypedDict
28
+
29
+ import anthropic
30
+ import httpx
31
+ import httpx2
32
+ from anthropic import AsyncAnthropic, omit
33
+ from anthropic.lib.credentials import InMemoryConfig, TokenCache, WorkloadIdentityError
34
+ from anthropic.resources.beta.messages.messages import AsyncMessages
35
+
36
+ from ..abort import AbortError, abort_scope
37
+ from ..env_api_keys import (
38
+ ANTHROPIC_FEDERATION_RULE_ID_ENV,
39
+ ANTHROPIC_IDENTITY_TOKEN_FILE_ENV,
40
+ ANTHROPIC_ORGANIZATION_ID_ENV,
41
+ ANTHROPIC_SERVICE_ACCOUNT_ID_ENV,
42
+ ANTHROPIC_WORKSPACE_ID_ENV,
43
+ )
44
+ from ..event_stream import AssistantMessageEventStream
45
+ from ..models import calculate_cost
46
+ from ..serialization import from_dict
47
+ from ..types import (
48
+ AssistantMessage,
49
+ DoneEvent,
50
+ ErrorEvent,
51
+ ImageContent,
52
+ Message,
53
+ Model,
54
+ ModelCost,
55
+ ProviderResponse,
56
+ StartEvent,
57
+ StreamOptions,
58
+ TextContent,
59
+ TextDeltaEvent,
60
+ TextEndEvent,
61
+ TextStartEvent,
62
+ ThinkingContent,
63
+ ThinkingDeltaEvent,
64
+ ThinkingEndEvent,
65
+ ThinkingStartEvent,
66
+ Tool,
67
+ ToolCall,
68
+ ToolCallDeltaEvent,
69
+ ToolCallEndEvent,
70
+ ToolCallStartEvent,
71
+ ToolResultMessage,
72
+ TranscriptContext,
73
+ Usage,
74
+ now_ms,
75
+ )
76
+ from ..utils.callbacks import maybe_await
77
+ from ..utils.diagnostics import AssistantMessageDiagnostic, DiagnosticErrorInfo, append_assistant_message_diagnostic
78
+ from ..utils.http import ProviderHttpError, get_http_client, make_status_error, retry_provider_request
79
+ from ..utils.json_parse import StreamingJsonParser, parse_json_with_repair, parse_tool_call_arguments
80
+ from ..utils.misc import get_pi_user_agent, get_provider_env_value, headers_to_record, sanitize_surrogates
81
+ from ..utils.text import append_text, append_thinking, get_system_message_text, render_system_message_update
82
+ from ..utils.transcript import get_current_tools, get_initial_system_message, resolve_transcript
83
+ from .constrained_sampling import get_json_schema_tool_parameters, resolve_json_schema_strict_sampling
84
+ from .github_copilot_headers import build_copilot_dynamic_headers, has_copilot_vision_input
85
+ from .simple_options import adjust_max_tokens_for_thinking, build_base_options, clamp_max_tokens_to_context
86
+ from .transform_messages import split_error_tool_result_images, transform_messages
87
+
88
+ __all__ = [
89
+ "AnthropicEffort",
90
+ "AnthropicThinkingDisplay",
91
+ "AnthropicToolChoice",
92
+ "AnthropicOptions",
93
+ "AnthropicParams",
94
+ "ConvertedAnthropicMessages",
95
+ "WorkloadIdentityError",
96
+ "CLAUDE_CODE_VERSION",
97
+ "CLAUDE_CODE_TOOLS",
98
+ "CLAUDE_CODE_SYSTEM_PROMPT",
99
+ "stream",
100
+ "stream_simple",
101
+ "build_params",
102
+ "convert_messages",
103
+ "convert_tools",
104
+ "get_beta_features",
105
+ "insert_thinking_level_messages",
106
+ "map_stop_reason",
107
+ "normalize_tool_call_id",
108
+ "is_oauth_token",
109
+ "to_claude_code_name",
110
+ "from_claude_code_name",
111
+ ]
112
+
113
+ AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
114
+ AnthropicThinkingDisplay = Literal["summarized", "omitted"]
115
+
116
+
117
+ class _ToolChoiceTool(TypedDict):
118
+ type: Literal["tool"]
119
+ name: str
120
+
121
+
122
+ AnthropicToolChoice = Literal["auto", "any", "none"] | _ToolChoiceTool
123
+
124
+
125
+ class AnthropicOptions(StreamOptions, total=False):
126
+ """Options for :func:`stream` (pi's ``AnthropicOptions``)."""
127
+
128
+ #: Enable extended thinking. Adaptive thinking models decide when/how much to think;
129
+ #: older models use budget-based thinking with ``thinking_budget_tokens``.
130
+ thinking_enabled: bool
131
+ #: Token budget for extended thinking (older models only). Default 1024 when thinking is enabled.
132
+ thinking_budget_tokens: int
133
+ #: Effort level for adaptive thinking models. Ignored for older models.
134
+ effort: AnthropicEffort
135
+ #: How thinking content is returned. Default ``"summarized"`` when thinking is enabled
136
+ #: (Anthropic's API default for Opus 4.7+ is ``"omitted"``).
137
+ thinking_display: AnthropicThinkingDisplay
138
+ #: Request the interleaved thinking beta for non-adaptive thinking models. Default True.
139
+ interleaved_thinking: bool
140
+ #: Anthropic tool choice. Strings map to built-in choices; ``{"type": "tool", "name": ...}`` forces a tool.
141
+ tool_choice: AnthropicToolChoice
142
+ #: Pre-built SDK client. When provided, skips internal client construction entirely (auth,
143
+ #: headers, ``http_client``). Use this to inject alternative SDK clients such as
144
+ #: ``AsyncAnthropicVertex`` that share the same messaging API.
145
+ client: AsyncAnthropic
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Constants
150
+ # ---------------------------------------------------------------------------
151
+
152
+ _DEFAULT_BASE_URL = "https://api.anthropic.com"
153
+
154
+ FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
155
+ INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"
156
+ SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01"
157
+ MID_CONVERSATION_OUTPUT_CONFIG_BETA = "mid-conversation-output-config-2026-07-01"
158
+ THINKING_BINDING_CONTROLS_BETA = "thinking-binding-controls-2026-08-01"
159
+ INLINE_TOOLS_BETA = "inline-tools-2026-09-15"
160
+ _CLAUDE_CODE_BETA = "claude-code-20250219"
161
+ _OAUTH_BETA = "oauth-2025-04-20"
162
+
163
+ # Stealth mode: Mimic Claude Code's tool naming exactly
164
+ CLAUDE_CODE_VERSION = "2.1.280"
165
+ CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."
166
+
167
+ # Claude Code 2.x tool names (canonical casing)
168
+ # Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md
169
+ # To update: https://github.com/badlogic/cchistory
170
+ CLAUDE_CODE_TOOLS: tuple[str, ...] = (
171
+ "Read",
172
+ "Write",
173
+ "Edit",
174
+ "Bash",
175
+ "Grep",
176
+ "Glob",
177
+ "AskUserQuestion",
178
+ "EnterPlanMode",
179
+ "ExitPlanMode",
180
+ "KillShell",
181
+ "NotebookEdit",
182
+ "Skill",
183
+ "Task",
184
+ "TaskOutput",
185
+ "TodoWrite",
186
+ "WebFetch",
187
+ "WebSearch",
188
+ )
189
+
190
+ _CC_TOOL_LOOKUP = {name.lower(): name for name in CLAUDE_CODE_TOOLS}
191
+
192
+ _ANTHROPIC_MESSAGE_EVENTS = frozenset(
193
+ {
194
+ "message_start",
195
+ "message_delta",
196
+ "message_stop",
197
+ "content_block_start",
198
+ "content_block_delta",
199
+ "content_block_stop",
200
+ }
201
+ )
202
+
203
+ # Errors for which a request with preferred (non-required) strict tools is retried non-strict.
204
+ _STRICT_TOOL_LIMIT_MESSAGES = (
205
+ "The compiled grammar is too large",
206
+ "Too many strict tools (",
207
+ "Schemas contains too many parameters with union types (",
208
+ )
209
+
210
+ # Keywords Anthropic strict tool use rejects with a 400 for the whole request.
211
+ # https://platform.claude.com/docs/en/build-with-claude/structured-outputs#json-schema-limitations
212
+ _STRICT_UNSUPPORTED_KEYWORDS = frozenset(
213
+ {
214
+ "minimum",
215
+ "maximum",
216
+ "exclusiveMinimum",
217
+ "exclusiveMaximum",
218
+ "multipleOf",
219
+ "maxItems",
220
+ "uniqueItems",
221
+ "minContains",
222
+ "maxContains",
223
+ "minProperties",
224
+ "maxProperties",
225
+ }
226
+ )
227
+ _STRICT_STRING_FORMATS = frozenset(
228
+ {"date-time", "time", "date", "duration", "email", "hostname", "uri", "ipv4", "ipv6", "uuid"}
229
+ )
230
+
231
+ #: Keyword parameters of ``beta.messages.create``; other request fields go through ``extra_body``.
232
+ _SDK_CREATE_PARAMS = frozenset(inspect.signature(AsyncMessages.create).parameters) - {
233
+ "self",
234
+ "extra_headers",
235
+ "extra_query",
236
+ "extra_body",
237
+ "timeout",
238
+ }
239
+
240
+ _ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
241
+ _TOOL_CALL_ID_RE = re.compile(r"[^a-zA-Z0-9_-]")
242
+
243
+
244
+ def _deferred_tool_placeholder() -> dict[str, Any]:
245
+ """Stable deferred tool declared whenever native tool changes are in use.
246
+
247
+ Anthropic adds hidden prompt scaffolding for mid-conversation tool changes; declaring
248
+ this placeholder from the first request keeps that scaffolding in the cached prefix, so
249
+ the first tool change does not invalidate the cache (measured: full miss without it).
250
+ It is never activated and the model cannot see it.
251
+ """
252
+ return {
253
+ "name": "__pi_deferred_placeholder__",
254
+ "description": "Reserved placeholder. Never available. Never call this.",
255
+ "input_schema": {"type": "object", "properties": {}, "required": []},
256
+ "defer_loading": True,
257
+ }
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Compat, cache control and auth helpers
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ @dataclass(slots=True, frozen=True)
266
+ class _AnthropicCompat:
267
+ supports_eager_tool_input_streaming: bool
268
+ supports_long_cache_retention: bool
269
+ send_session_affinity_headers: bool
270
+ session_affinity_format: str | None
271
+ supports_cache_control_on_tools: bool
272
+ supports_temperature: bool
273
+ allow_empty_signature: bool
274
+ supports_strict_tools: bool
275
+ supports_mid_convo_system_messages: bool
276
+ supports_mid_convo_tool_changes: bool
277
+
278
+
279
+ def _flag(compat: Mapping[str, Any], name: str, default: Any) -> Any:
280
+ value = compat.get(name)
281
+ return default if value is None else value
282
+
283
+
284
+ def _get_compat(model: Model) -> _AnthropicCompat:
285
+ compat = model.compat or {}
286
+ is_openrouter = model.provider == "openrouter" or "openrouter.ai" in model.base_url
287
+ return _AnthropicCompat(
288
+ supports_eager_tool_input_streaming=_flag(compat, "supportsEagerToolInputStreaming", True),
289
+ supports_long_cache_retention=_flag(compat, "supportsLongCacheRetention", True),
290
+ send_session_affinity_headers=_flag(compat, "sendSessionAffinityHeaders", is_openrouter),
291
+ session_affinity_format=_flag(compat, "sessionAffinityFormat", "openrouter" if is_openrouter else None),
292
+ supports_cache_control_on_tools=_flag(compat, "supportsCacheControlOnTools", True),
293
+ supports_temperature=_flag(compat, "supportsTemperature", True),
294
+ allow_empty_signature=_flag(compat, "allowEmptySignature", False),
295
+ supports_strict_tools=_flag(compat, "supportsStrictTools", False),
296
+ supports_mid_convo_system_messages=_flag(compat, "supportsMidConvoSystemMessages", False),
297
+ supports_mid_convo_tool_changes=_flag(compat, "supportsMidConvoToolChanges", False),
298
+ )
299
+
300
+
301
+ def _resolve_cache_retention(cache_retention: str | None, env: Mapping[str, str] | None) -> str:
302
+ """Resolve the cache retention preference. Defaults to ``"short"``; honors ``PI_CACHE_RETENTION``."""
303
+ if cache_retention:
304
+ return cache_retention
305
+ if get_provider_env_value("PI_CACHE_RETENTION", env) == "long":
306
+ return "long"
307
+ return "short"
308
+
309
+
310
+ def _get_cache_control(
311
+ model: Model, cache_retention: str | None, env: Mapping[str, str] | None
312
+ ) -> dict[str, Any] | None:
313
+ retention = _resolve_cache_retention(cache_retention, env)
314
+ if retention == "none":
315
+ return None
316
+ if retention == "long" and _get_compat(model).supports_long_cache_retention:
317
+ return {"type": "ephemeral", "ttl": "1h"}
318
+ return {"type": "ephemeral"}
319
+
320
+
321
+ def _has_header(headers: Mapping[str, str | None] | None, name: str) -> bool:
322
+ if not headers:
323
+ return False
324
+ expected = name.lower()
325
+ return any(key.lower() == expected and value is not None and value.strip() != "" for key, value in headers.items())
326
+
327
+
328
+ def _has_request_auth(api_key: str | None, headers: Mapping[str, str | None] | None) -> bool:
329
+ return (
330
+ bool(api_key)
331
+ or _has_header(headers, "authorization")
332
+ or _has_header(headers, "x-api-key")
333
+ or _has_header(headers, "cf-aig-authorization")
334
+ )
335
+
336
+
337
+ def _assert_request_auth(provider: str, api_key: str | None, headers: Mapping[str, str | None] | None) -> None:
338
+ if not _has_request_auth(api_key, headers):
339
+ raise ValueError(f"No API key for provider: {provider}")
340
+
341
+
342
+ def is_oauth_token(api_key: str) -> bool:
343
+ """Whether ``api_key`` is a Claude Code OAuth access token."""
344
+ return "sk-ant-oat" in api_key
345
+
346
+
347
+ def to_claude_code_name(name: str) -> str:
348
+ """Convert a tool name to Claude Code's canonical casing if it matches one (case-insensitive)."""
349
+ return _CC_TOOL_LOOKUP.get(name.lower(), name)
350
+
351
+
352
+ def from_claude_code_name(name: str, tools: Sequence[Tool] | None = None) -> str:
353
+ """Map a tool name returned in OAuth mode back to the caller's tool name."""
354
+ if tools:
355
+ lower = name.lower()
356
+ for tool in tools:
357
+ if tool.name.lower() == lower:
358
+ return tool.name
359
+ return name
360
+
361
+
362
+ def normalize_tool_call_id(tool_call_id: str, *_: Any) -> str:
363
+ """Normalize tool call ids to Anthropic's required pattern and length."""
364
+ return _TOOL_CALL_ID_RE.sub("_", tool_call_id)[:64]
365
+
366
+
367
+ def _base_url(model: Model) -> str:
368
+ return model.base_url or os.environ.get("ANTHROPIC_BASE_URL") or _DEFAULT_BASE_URL
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # SDK transport: pi's httpx clients under the SDK's httpx2 client
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def _httpx2_error(error: httpx.TransportError, request: httpx2.Request) -> httpx2.TransportError:
377
+ """The httpx2 counterpart of an httpx transport error, so the SDK classifies it (timeout vs connection)."""
378
+ cls = getattr(httpx2, type(error).__name__, None)
379
+ if not (isinstance(cls, type) and issubclass(cls, httpx2.TransportError)):
380
+ cls = httpx2.TimeoutException if isinstance(error, httpx.TimeoutException) else httpx2.TransportError
381
+ return cls(str(error), request=request)
382
+
383
+
384
+ def _response_headers(response: httpx.Response) -> list[tuple[bytes, bytes]]:
385
+ headers = response.headers.raw
386
+ if response.is_stream_consumed and "content-encoding" in response.headers:
387
+ # The body was already read (and decoded) by httpx, e.g. a canned MockTransport response.
388
+ return [
389
+ (name, value) for name, value in headers if name.lower() not in (b"content-encoding", b"content-length")
390
+ ]
391
+ return headers
392
+
393
+
394
+ class _BridgeByteStream(httpx2.AsyncByteStream):
395
+ __slots__ = ("_request", "_response")
396
+
397
+ def __init__(self, response: httpx.Response, request: httpx2.Request) -> None:
398
+ self._response = response
399
+ self._request = request
400
+
401
+ async def __aiter__(self) -> AsyncIterator[bytes]:
402
+ response = self._response
403
+ # Raw bytes keep content decoding on the httpx2 side, like a native transport.
404
+ chunks = response.aiter_bytes() if response.is_stream_consumed else response.aiter_raw()
405
+ try:
406
+ async for chunk in chunks:
407
+ yield chunk
408
+ except httpx.TransportError as error:
409
+ raise _httpx2_error(error, self._request) from error
410
+
411
+ async def aclose(self) -> None:
412
+ await self._response.aclose()
413
+
414
+
415
+ class _HttpxTransport(httpx2.AsyncBaseTransport):
416
+ """Sends the SDK's httpx2 requests through a pi ``httpx.AsyncClient``.
417
+
418
+ The Anthropic SDK is built on ``httpx2``, while pi's ``http_client`` option and connection
419
+ pool are ``httpx``. Routing through the httpx client keeps its transport (mocks, proxies,
420
+ provider-scoped proxy env) and its connection reuse. The client is held weakly so the
421
+ per-client cache below does not keep caller-owned clients alive.
422
+ """
423
+
424
+ __slots__ = ("_client",)
425
+
426
+ def __init__(self, client: httpx.AsyncClient) -> None:
427
+ self._client = weakref.ref(client)
428
+
429
+ async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
430
+ client = self._client()
431
+ if client is None:
432
+ raise httpx2.TransportError("The HTTP client was garbage collected", request=request)
433
+ body = await request.aread()
434
+ timeout = request.extensions.get("timeout")
435
+ outgoing = httpx.Request(
436
+ request.method,
437
+ str(request.url),
438
+ headers=request.headers.raw,
439
+ content=body,
440
+ extensions={"timeout": timeout} if timeout is not None else None,
441
+ )
442
+ try:
443
+ response = await client.send(outgoing, stream=True)
444
+ except httpx.TransportError as error:
445
+ raise _httpx2_error(error, request) from error
446
+ return httpx2.Response(
447
+ response.status_code,
448
+ headers=_response_headers(response),
449
+ stream=_BridgeByteStream(response, request),
450
+ request=request,
451
+ )
452
+
453
+ async def aclose(self) -> None:
454
+ # The httpx client belongs to the caller or to pi's pool; never close it here.
455
+ return None
456
+
457
+
458
+ #: The event loop of the current Messages request. The SDK's worker thread for the token
459
+ #: exchange runs in a copy of the request's context, so it sees this value.
460
+ _request_loop: contextvars.ContextVar[asyncio.AbstractEventLoop | None] = contextvars.ContextVar(
461
+ "pi_anthropic_request_loop", default=None
462
+ )
463
+
464
+
465
+ class _HttpxSyncTransport(httpx2.BaseTransport):
466
+ """Transport for the SDK's federation token exchange.
467
+
468
+ The SDK exchanges the identity token with a sync ``httpx2.Client`` in a worker thread
469
+ (``TokenCache.async_get_token``); this hands the request back to the request's event loop
470
+ and sends it with the same httpx client as the Messages request (``http_client`` or the pool).
471
+ """
472
+
473
+ __slots__ = ("_options",)
474
+
475
+ def __init__(self, options: Mapping[str, Any]) -> None:
476
+ self._options = options
477
+
478
+ def handle_request(self, request: httpx2.Request) -> httpx2.Response:
479
+ loop = _request_loop.get()
480
+ if loop is None or loop.is_closed():
481
+ raise httpx2.TransportError("The token exchange needs a running pi request", request=request)
482
+ try:
483
+ running = asyncio.get_running_loop()
484
+ except RuntimeError:
485
+ running = None
486
+ if running is loop:
487
+ raise httpx2.TransportError("The token exchange cannot block the event loop", request=request)
488
+ return asyncio.run_coroutine_threadsafe(self._send(request), loop).result()
489
+
490
+ async def _send(self, request: httpx2.Request) -> httpx2.Response:
491
+ url = str(request.url)
492
+ client = get_http_client(url, self._options)
493
+ timeout = request.extensions.get("timeout")
494
+ outgoing = httpx.Request(
495
+ request.method,
496
+ url,
497
+ headers=request.headers.raw,
498
+ content=request.read(),
499
+ extensions={"timeout": timeout} if timeout is not None else None,
500
+ )
501
+ try:
502
+ response = await client.send(outgoing)
503
+ except httpx.TransportError as error:
504
+ raise _httpx2_error(error, request) from error
505
+ headers = [
506
+ (name, value)
507
+ for name, value in response.headers.raw
508
+ if name.lower() not in (b"content-encoding", b"content-length")
509
+ ]
510
+ return httpx2.Response(response.status_code, headers=headers, content=response.content, request=request)
511
+
512
+
513
+ _sdk_http_clients: weakref.WeakKeyDictionary[httpx.AsyncClient, httpx2.AsyncClient] = weakref.WeakKeyDictionary()
514
+
515
+
516
+ def _sdk_http_client(url: str, options: Mapping[str, Any]) -> httpx2.AsyncClient:
517
+ """The SDK ``http_client``: a caller's ``httpx2.AsyncClient`` as is, else one bridged to pi's httpx client."""
518
+ custom = options.get("http_client")
519
+ if isinstance(custom, httpx2.AsyncClient):
520
+ return custom
521
+ client = get_http_client(url, options)
522
+ sdk_client = _sdk_http_clients.get(client)
523
+ if sdk_client is None:
524
+ # trust_env=False: proxies are the httpx client's business (and skip httpx2's env proxy mounts).
525
+ sdk_client = httpx2.AsyncClient(transport=_HttpxTransport(client), trust_env=False)
526
+ _sdk_http_clients[client] = sdk_client
527
+ return sdk_client
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # SDK client (headers and auth)
532
+ # ---------------------------------------------------------------------------
533
+
534
+
535
+ def _get_anthropic_federation(
536
+ model: Model,
537
+ api_key: str | None,
538
+ headers: Mapping[str, str | None] | None,
539
+ env: Mapping[str, str] | None,
540
+ ) -> dict[str, Any] | None:
541
+ """Workload identity federation config from the ``ANTHROPIC_*`` variables the SDK documents.
542
+
543
+ The SDK performs the token exchange and refresh. Only for the anthropic provider, since
544
+ the exchange is an Anthropic API endpoint, and only when no key or auth header was resolved.
545
+ """
546
+ if model.provider != "anthropic" or _has_request_auth(api_key, headers):
547
+ return None
548
+ federation_rule_id = get_provider_env_value(ANTHROPIC_FEDERATION_RULE_ID_ENV, env)
549
+ organization_id = get_provider_env_value(ANTHROPIC_ORGANIZATION_ID_ENV, env)
550
+ identity_token_file = get_provider_env_value(ANTHROPIC_IDENTITY_TOKEN_FILE_ENV, env)
551
+ if not federation_rule_id or not organization_id or not identity_token_file:
552
+ return None
553
+ return {
554
+ "organization_id": organization_id,
555
+ "workspace_id": get_provider_env_value(ANTHROPIC_WORKSPACE_ID_ENV, env),
556
+ "authentication": {
557
+ "type": "oidc_federation",
558
+ "federation_rule_id": federation_rule_id,
559
+ "service_account_id": get_provider_env_value(ANTHROPIC_SERVICE_ACCOUNT_ID_ENV, env),
560
+ "identity_token": {"source": "file", "path": identity_token_file},
561
+ },
562
+ }
563
+
564
+
565
+ class _PiAnthropic(AsyncAnthropic):
566
+ """``AsyncAnthropic`` that never runs the SDK's own credential chain.
567
+
568
+ The SDK only resolves default credentials (``ANTHROPIC_PROFILE`` config files, federation
569
+ env vars) for the base class, so a subclass skips that chain. Explicit ``None`` credentials
570
+ also stay ``None`` instead of falling back to ``ANTHROPIC_API_KEY``/``ANTHROPIC_AUTH_TOKEN``
571
+ (the JS SDK's ``apiKey: null``), so a client built for header-owned auth never adds SDK
572
+ credentials behind pi's auth resolver.
573
+
574
+ The SDK resolves the platform for its ``X-Stainless-*`` headers in a worker thread on a
575
+ client's first request; pi builds a client per request, so the result is shared.
576
+ """
577
+
578
+ _shared_platform: Any = None
579
+
580
+ def __init__(self, *, api_key: str | None, auth_token: str | None, **kwargs: Any) -> None:
581
+ super().__init__(api_key=api_key, auth_token=auth_token, **kwargs)
582
+ self.api_key = api_key
583
+ self.auth_token = auth_token
584
+ self._platform = _PiAnthropic._shared_platform
585
+
586
+
587
+ def _client_headers(*sources: Mapping[str, str | None] | None) -> dict[str, Any]:
588
+ """pi's ``mergeClientHeaders``: pi's User-Agent, then each source; ``None`` removes the SDK's header too."""
589
+ merged: dict[str, tuple[str, Any]] = {}
590
+ for source in ({"User-Agent": get_pi_user_agent()}, *sources):
591
+ if not source:
592
+ continue
593
+ for name, value in source.items():
594
+ merged[name.lower()] = (name, omit if value is None else value)
595
+ return {name: value for name, value in merged.values()}
596
+
597
+
598
+ #: The SDK caches the federated access token per client, but pi creates a client per request.
599
+ #: Keep one token cache for the current federation config and ``http_client`` and share it with
600
+ #: every per-request client, like upstream's ``withOptions()`` clones.
601
+ _federation_cache: tuple[str, Any, Any, TokenCache] | None = None
602
+
603
+
604
+ def _federation_credentials(
605
+ model: Model, federation: Mapping[str, Any], options: Mapping[str, Any]
606
+ ) -> tuple[Any, TokenCache]:
607
+ global _federation_cache
608
+ key = json.dumps([model.base_url, federation], sort_keys=True)
609
+ http_client = options.get("http_client")
610
+ cached = _federation_cache
611
+ if cached is not None and cached[0] == key and cached[1] is http_client:
612
+ return cached[2], cached[3]
613
+ exchange_options = {name: options[name] for name in ("http_client", "env") if options.get(name) is not None}
614
+ if isinstance(http_client, httpx2.AsyncClient):
615
+ exchange_options.pop("http_client")
616
+ provider = InMemoryConfig(
617
+ dict(federation), http_client=httpx2.Client(transport=_HttpxSyncTransport(exchange_options), trust_env=False)
618
+ )
619
+ token_cache = TokenCache(provider)
620
+ _federation_cache = (key, http_client, provider, token_cache)
621
+ return provider, token_cache
622
+
623
+
624
+ def _create_client(
625
+ model: Model,
626
+ api_key: str | None,
627
+ options_headers: Mapping[str, str | None] | None,
628
+ dynamic_headers: Mapping[str, str] | None,
629
+ session_id: str | None,
630
+ federation: Mapping[str, Any] | None,
631
+ options: Mapping[str, Any],
632
+ ) -> tuple[AsyncAnthropic, bool, TokenCache | None]:
633
+ """Create the SDK client: ``(client, is_oauth_token, federation token cache)``."""
634
+ base_url = _base_url(model)
635
+ # pi retries itself (retry_provider_request) so the backoff sleep stays abortable.
636
+ common: dict[str, Any] = {
637
+ "base_url": base_url,
638
+ "http_client": _sdk_http_client(base_url, options),
639
+ "max_retries": 0,
640
+ }
641
+
642
+ # Copilot: Bearer auth.
643
+ if model.provider == "github-copilot":
644
+ client = _PiAnthropic(
645
+ api_key=None,
646
+ auth_token=api_key,
647
+ default_headers=_client_headers(
648
+ {"accept": "application/json", "anthropic-dangerous-direct-browser-access": "true"},
649
+ model.headers,
650
+ dynamic_headers,
651
+ options_headers,
652
+ ),
653
+ **common,
654
+ )
655
+ return client, False, None
656
+
657
+ # OAuth: Bearer auth, Claude Code identity headers
658
+ if api_key and is_oauth_token(api_key):
659
+ client = _PiAnthropic(
660
+ api_key=None,
661
+ auth_token=api_key,
662
+ default_headers=_client_headers(
663
+ {
664
+ "accept": "application/json",
665
+ "anthropic-dangerous-direct-browser-access": "true",
666
+ "user-agent": f"claude-cli/{CLAUDE_CODE_VERSION}",
667
+ "x-app": "cli",
668
+ },
669
+ model.headers,
670
+ options_headers,
671
+ ),
672
+ **common,
673
+ )
674
+ return client, True, None
675
+
676
+ # API key, header-owned auth, or workload identity federation.
677
+ compat = _get_compat(model)
678
+ session_affinity: dict[str, str] = {}
679
+ if session_id and compat.send_session_affinity_headers:
680
+ header = "x-session-id" if compat.session_affinity_format == "openrouter" else "x-session-affinity"
681
+ session_affinity[header] = session_id
682
+ default_headers = _client_headers(
683
+ {"accept": "application/json", "anthropic-dangerous-direct-browser-access": "true"},
684
+ session_affinity,
685
+ model.headers,
686
+ options_headers,
687
+ )
688
+ if federation is not None:
689
+ provider, token_cache = _federation_credentials(model, federation, options)
690
+ client = _PiAnthropic(
691
+ api_key=None,
692
+ auth_token=None,
693
+ credentials=provider,
694
+ _token_cache=token_cache,
695
+ default_headers=default_headers,
696
+ **common,
697
+ )
698
+ return client, False, token_cache
699
+ return _PiAnthropic(api_key=api_key, auth_token=None, default_headers=default_headers, **common), False, None
700
+
701
+
702
+ def _create_kwargs(params: Mapping[str, Any]) -> dict[str, Any]:
703
+ """Keyword arguments for ``beta.messages.create``: SDK params by name, the rest via ``extra_body``.
704
+
705
+ The Python SDK rejects unknown keyword arguments, while upstream passes the params object as
706
+ is; ``extra_body`` keeps every field on the wire. ``betas``, ``user_profile_id`` and
707
+ ``workspace_id`` become headers in the SDK.
708
+ """
709
+ kwargs: dict[str, Any] = {}
710
+ extra: dict[str, Any] = {}
711
+ for name, value in params.items():
712
+ if name in _SDK_CREATE_PARAMS:
713
+ kwargs[name] = value
714
+ else:
715
+ extra[name] = value
716
+ # The JS SDK maps the deprecated output_format to output_config.format.
717
+ output_format = extra.pop("output_format", None)
718
+ if output_format:
719
+ output_config = kwargs.get("output_config")
720
+ if isinstance(output_config, Mapping) and output_config.get("format"):
721
+ raise ValueError(
722
+ "Both output_format and output_config.format were provided. "
723
+ "Please use only output_config.format (output_format is deprecated)."
724
+ )
725
+ kwargs["output_config"] = {
726
+ **(output_config if isinstance(output_config, Mapping) else {}),
727
+ "format": output_format,
728
+ }
729
+ elif output_format is not None:
730
+ extra["output_format"] = output_format
731
+ for name in ("max_tokens", "messages", "model"):
732
+ # Required by the Python signature; an on_payload hook may still have removed them.
733
+ kwargs.setdefault(name, omit)
734
+ if extra:
735
+ kwargs["extra_body"] = extra
736
+ return kwargs
737
+
738
+
739
+ def _provider_error(error: anthropic.APIError) -> ProviderHttpError:
740
+ """Convert an SDK error to a :class:`ProviderHttpError` with the JS SDK's message wording.
741
+
742
+ The JS SDK's ``APIError.message`` is ``"<status> <JSON body>"`` (``"Connection error."`` and
743
+ ``"Request timed out."`` without a response); pi's retry and overflow detection rely on it.
744
+ """
745
+ if isinstance(error, anthropic.APIStatusError):
746
+ response = error.response
747
+ try:
748
+ text = response.text
749
+ except httpx2.ResponseNotRead:
750
+ text = ""
751
+ headers = httpx.Headers(response.headers.multi_items())
752
+ return make_status_error(response.status_code, text, headers, "anthropic")
753
+ if isinstance(error, anthropic.APITimeoutError):
754
+ return ProviderHttpError("Request timed out.")
755
+ return ProviderHttpError(error.message)
756
+
757
+
758
+ async def _create_response(client: AsyncAnthropic, params: Mapping[str, Any], options: Mapping[str, Any]) -> Any:
759
+ """``client.beta.messages.create(params).asResponse()``: the raw response, before its body is read."""
760
+ kwargs = _create_kwargs(params)
761
+ _request_loop.set(asyncio.get_running_loop())
762
+ timeout_ms = options.get("timeout_ms")
763
+ if timeout_ms is not None:
764
+ kwargs["timeout"] = timeout_ms / 1000
765
+ try:
766
+ return await client.beta.messages.with_raw_response.create(**kwargs)
767
+ except (anthropic.APIStatusError, anthropic.APIConnectionError) as error:
768
+ raise _provider_error(error) from error
769
+ finally:
770
+ if _PiAnthropic._shared_platform is None and isinstance(client, _PiAnthropic):
771
+ _PiAnthropic._shared_platform = client._platform
772
+
773
+
774
+ # ---------------------------------------------------------------------------
775
+ # Payload building
776
+ # ---------------------------------------------------------------------------
777
+
778
+
779
+ def _convert_image_block(image: ImageContent) -> dict[str, Any]:
780
+ return {"type": "image", "source": {"type": "base64", "media_type": image.mime_type, "data": image.data}}
781
+
782
+
783
+ def _convert_content_blocks(content: Sequence[TextContent | ImageContent]) -> str | list[dict[str, Any]]:
784
+ """Tool result content: a string when text-only, otherwise content blocks."""
785
+ if not any(block.type == "image" for block in content):
786
+ return sanitize_surrogates("\n".join(block.text for block in content)) # type: ignore[union-attr]
787
+ blocks: list[dict[str, Any]] = [
788
+ {"type": "text", "text": sanitize_surrogates(block.text)}
789
+ if block.type == "text"
790
+ else _convert_image_block(block) # type: ignore[arg-type]
791
+ for block in content
792
+ ]
793
+ # If only images (no text), add placeholder text block
794
+ if not any(block["type"] == "text" for block in blocks):
795
+ blocks.insert(0, {"type": "text", "text": "(see attached image)"})
796
+ return blocks
797
+
798
+
799
+ def _convert_tool_result(message: ToolResultMessage, content: Sequence[TextContent | ImageContent]) -> dict[str, Any]:
800
+ return {
801
+ "type": "tool_result",
802
+ "tool_use_id": message.tool_call_id,
803
+ "content": _convert_content_blocks(content),
804
+ "is_error": message.is_error,
805
+ }
806
+
807
+
808
+ def _is_strict_unsupported_keyword(key: str, value: Any) -> bool:
809
+ if key in _STRICT_UNSUPPORTED_KEYWORDS:
810
+ return True
811
+ if key == "minItems":
812
+ return not (isinstance(value, (int, float)) and not isinstance(value, bool) and value in (0, 1))
813
+ if key == "format":
814
+ return not isinstance(value, str) or value not in _STRICT_STRING_FORMATS
815
+ return False
816
+
817
+
818
+ def _is_preferred_strict(tool: Tool) -> bool:
819
+ config = tool.constrained_sampling
820
+ return bool(config) and config.get("type") == "json_schema" and config.get("strict") == "prefer" # type: ignore[union-attr]
821
+
822
+
823
+ def convert_tools(
824
+ tools: Sequence[Tool],
825
+ is_oauth_token: bool,
826
+ supports_eager_tool_input_streaming: bool,
827
+ supports_strict_tools: bool,
828
+ cache_control: Mapping[str, Any] | None = None,
829
+ disable_preferred_strict_tools: bool = False,
830
+ ) -> list[dict[str, Any]]:
831
+ """Convert tools to Anthropic tool definitions (cache breakpoint on the last one)."""
832
+ if not tools:
833
+ return []
834
+ last = len(tools) - 1
835
+ definitions: list[dict[str, Any]] = []
836
+ for index, tool in enumerate(tools):
837
+ if disable_preferred_strict_tools and _is_preferred_strict(tool):
838
+ strict: bool | None = None
839
+ else:
840
+ strict = resolve_json_schema_strict_sampling(tool, supports_strict_tools, _is_strict_unsupported_keyword)
841
+ parameters = get_json_schema_tool_parameters(tool, strict)
842
+ properties = parameters.get("properties")
843
+ required = parameters.get("required")
844
+ legacy_input_schema = {
845
+ "type": "object",
846
+ "properties": properties if properties is not None else {},
847
+ "required": required if required is not None else [],
848
+ }
849
+ definition: dict[str, Any] = {
850
+ "name": to_claude_code_name(tool.name) if is_oauth_token else tool.name,
851
+ "description": tool.description,
852
+ }
853
+ if supports_eager_tool_input_streaming:
854
+ definition["eager_input_streaming"] = True
855
+ if strict is True:
856
+ definition["strict"] = True
857
+ definition["input_schema"] = {**parameters, **legacy_input_schema} if strict is True else legacy_input_schema
858
+ if cache_control and index == last:
859
+ definition["cache_control"] = cache_control
860
+ definitions.append(definition)
861
+ return definitions
862
+
863
+
864
+ @dataclass(slots=True)
865
+ class ConvertedAnthropicMessages:
866
+ messages: list[dict[str, Any]]
867
+ #: Wire index of each replayed assistant message -> the effort it was generated with.
868
+ assistant_levels: dict[int, str] = field(default_factory=dict)
869
+
870
+
871
+ def convert_messages(
872
+ transformed_messages: Sequence[Message],
873
+ is_oauth_token: bool,
874
+ cache_control: Mapping[str, Any] | None = None,
875
+ allow_empty_signature: bool = False,
876
+ managed_provider: str | None = None,
877
+ convert_tool_definitions: Callable[[list[Tool]], list[dict[str, Any]]] | None = None,
878
+ ) -> ConvertedAnthropicMessages:
879
+ """Convert transformed pi messages (without the leading system message) to Anthropic messages.
880
+
881
+ ``convert_tool_definitions`` converts tool definitions for native ``tool_addition``
882
+ blocks; ``None`` when tool changes are not native.
883
+ """
884
+ params: list[dict[str, Any]] = []
885
+ assistant_levels: dict[int, str] = {}
886
+ # Later system messages are held back and emitted directly before the next assistant
887
+ # message (or at the end of the transcript). Anthropic requires `tool_result` blocks to
888
+ # immediately follow their `tool_use`, so a system message between them is rejected; this
889
+ # also mirrors where the managed-effort system messages are inserted. As a result an
890
+ # update placed before a user message in the transcript lands after it on the wire.
891
+ pending_system: list[dict[str, Any]] = []
892
+
893
+ count = len(transformed_messages)
894
+ i = 0
895
+ while i < count:
896
+ message = transformed_messages[i]
897
+ role = message.role
898
+ if role == "system":
899
+ # Later system messages only reach this point when the model accepts them natively;
900
+ # otherwise the transcript was collapsed into the leading message before conversion.
901
+ text = render_system_message_update(message) # type: ignore[arg-type]
902
+ blocks: list[dict[str, Any]] = []
903
+ if text:
904
+ blocks.append({"type": "text", "text": sanitize_surrogates(text)})
905
+ if convert_tool_definitions is not None:
906
+ added = list(message.tools_added or []) # type: ignore[union-attr]
907
+ redefined = {tool.name for tool in added}
908
+ for removed in message.tools_removed or (): # type: ignore[union-attr]
909
+ # A new definition under the same name replaces the old one, so no removal is needed.
910
+ if removed.name in redefined:
911
+ continue
912
+ name = to_claude_code_name(removed.name) if is_oauth_token else removed.name
913
+ blocks.append({"type": "tool_removal", "tool": {"type": "tool_reference", "name": name}})
914
+ for definition in convert_tool_definitions(added):
915
+ blocks.append(
916
+ {"type": "tool_addition", "tool": {"type": "tool_definition", "definition": definition}}
917
+ )
918
+ if blocks:
919
+ pending_system.append({"role": "system", "content": blocks})
920
+ elif role == "user":
921
+ content = message.content
922
+ if isinstance(content, str):
923
+ if content.strip():
924
+ params.append({"role": "user", "content": sanitize_surrogates(content)})
925
+ else:
926
+ user_blocks: list[dict[str, Any]] = []
927
+ for item in content:
928
+ if item.type == "text":
929
+ if item.text.strip():
930
+ user_blocks.append({"type": "text", "text": sanitize_surrogates(item.text)})
931
+ else:
932
+ user_blocks.append(_convert_image_block(item)) # type: ignore[arg-type]
933
+ if user_blocks:
934
+ params.append({"role": "user", "content": user_blocks})
935
+ elif role == "assistant":
936
+ params.extend(pending_system)
937
+ pending_system.clear()
938
+ assistant_blocks: list[dict[str, Any]] = []
939
+ for block in message.content: # type: ignore[union-attr]
940
+ kind = block.type
941
+ if kind == "text":
942
+ if not block.text.strip():
943
+ continue
944
+ assistant_blocks.append({"type": "text", "text": sanitize_surrogates(block.text)})
945
+ elif kind == "thinking":
946
+ # Redacted thinking: pass the opaque payload back as redacted_thinking
947
+ if block.redacted:
948
+ assistant_blocks.append({"type": "redacted_thinking", "data": block.thinking_signature})
949
+ continue
950
+ signature = block.thinking_signature
951
+ has_signature = bool(signature) and bool(signature.strip())
952
+ if not block.thinking.strip() and not has_signature:
953
+ continue
954
+ # If thinking signature is missing/empty (e.g., from aborted stream),
955
+ # convert to plain text for Anthropic. Some compatible providers emit
956
+ # and accept empty signatures, so let marked models preserve the block.
957
+ if not has_signature:
958
+ if allow_empty_signature:
959
+ assistant_blocks.append(
960
+ {"type": "thinking", "thinking": sanitize_surrogates(block.thinking), "signature": ""}
961
+ )
962
+ else:
963
+ assistant_blocks.append({"type": "text", "text": sanitize_surrogates(block.thinking)})
964
+ else:
965
+ assistant_blocks.append(
966
+ {
967
+ "type": "thinking",
968
+ "thinking": sanitize_surrogates(block.thinking),
969
+ "signature": signature,
970
+ }
971
+ )
972
+ elif kind == "toolCall":
973
+ assistant_blocks.append(
974
+ {
975
+ "type": "tool_use",
976
+ "id": block.id,
977
+ "name": to_claude_code_name(block.name) if is_oauth_token else block.name,
978
+ "input": block.arguments if block.arguments is not None else {},
979
+ }
980
+ )
981
+ if assistant_blocks:
982
+ message_index = len(params)
983
+ params.append({"role": "assistant", "content": assistant_blocks})
984
+ level = message.provider_thinking_level # type: ignore[union-attr]
985
+ if (
986
+ managed_provider is not None
987
+ and message.api == "anthropic-messages" # type: ignore[union-attr]
988
+ and message.provider == managed_provider # type: ignore[union-attr]
989
+ and level in _ANTHROPIC_EFFORTS
990
+ ):
991
+ assistant_levels[message_index] = level # type: ignore[assignment]
992
+ elif role == "toolResult":
993
+ # Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint.
994
+ tool_results: list[dict[str, Any]] = []
995
+ # Anthropic rejects error tool results that contain anything but text, so their images follow
996
+ # all tool results of the turn, which must come first in the user message (#10794).
997
+ error_images: list[dict[str, Any]] = []
998
+ j = i
999
+ while j < count and transformed_messages[j].role == "toolResult":
1000
+ tool_message: ToolResultMessage = transformed_messages[j] # type: ignore[assignment]
1001
+ split = split_error_tool_result_images(tool_message)
1002
+ tool_results.append(
1003
+ _convert_tool_result(tool_message, split.content if split is not None else tool_message.content)
1004
+ )
1005
+ if split is not None:
1006
+ error_images.append({"type": "text", "text": split.caption})
1007
+ error_images.extend(_convert_image_block(image) for image in split.images)
1008
+ j += 1
1009
+ # Skip the messages we've already processed.
1010
+ i = j - 1
1011
+ params.append({"role": "user", "content": [*tool_results, *error_images]})
1012
+ i += 1
1013
+
1014
+ params.extend(pending_system)
1015
+
1016
+ # Add cache_control to the last user or system message to cache conversation history
1017
+ if cache_control and params:
1018
+ last_message = params[-1]
1019
+ if last_message["role"] in ("user", "system"):
1020
+ content = last_message["content"]
1021
+ if isinstance(content, list):
1022
+ last_block = content[-1] if content else None
1023
+ if last_block is not None and last_block.get("type") in (
1024
+ "text",
1025
+ "image",
1026
+ "tool_result",
1027
+ "tool_addition",
1028
+ "tool_removal",
1029
+ ):
1030
+ last_block["cache_control"] = cache_control
1031
+ elif isinstance(content, str):
1032
+ last_message["content"] = [{"type": "text", "text": content, "cache_control": cache_control}]
1033
+
1034
+ return ConvertedAnthropicMessages(params, assistant_levels)
1035
+
1036
+
1037
+ def insert_thinking_level_messages(converted: ConvertedAnthropicMessages, active_effort: str) -> list[dict[str, Any]]:
1038
+ """Insert effort-only system messages before replayed assistants and a final one for this turn."""
1039
+ messages: list[dict[str, Any]] = []
1040
+ for index, message in enumerate(converted.messages):
1041
+ historical = converted.assistant_levels.get(index)
1042
+ if historical is not None:
1043
+ messages.append({"role": "system", "content": [], "output_config": {"effort": historical}})
1044
+ messages.append(message)
1045
+ messages.append({"role": "system", "content": [], "output_config": {"effort": active_effort}})
1046
+ return messages
1047
+
1048
+
1049
+ def _should_use_fine_grained_tool_streaming_beta(model: Model, context: TranscriptContext) -> bool:
1050
+ return len(get_current_tools(context.messages)) > 0 and not _get_compat(model).supports_eager_tool_input_streaming
1051
+
1052
+
1053
+ def get_beta_features(
1054
+ model: Model,
1055
+ context: TranscriptContext,
1056
+ is_oauth_token: bool,
1057
+ native_tool_changes: bool,
1058
+ options: Mapping[str, Any] | None = None,
1059
+ ) -> list[str]:
1060
+ """The ``betas`` for a request. An ``anthropic-beta`` header in model/options headers replaces them."""
1061
+ options = options or {}
1062
+ found = False
1063
+ configured: str | None = None
1064
+ for headers in (model.headers, options.get("headers")):
1065
+ for name, value in (headers or {}).items():
1066
+ if name.lower() == "anthropic-beta":
1067
+ found = True
1068
+ configured = value
1069
+ if found:
1070
+ if configured is None:
1071
+ return []
1072
+ return list(dict.fromkeys(part.strip() for part in configured.split(",") if part.strip()))
1073
+
1074
+ model_compat = model.compat or {}
1075
+ features: list[str] = []
1076
+ if is_oauth_token:
1077
+ features.extend((_CLAUDE_CODE_BETA, _OAUTH_BETA))
1078
+ if _should_use_fine_grained_tool_streaming_beta(model, context):
1079
+ features.append(FINE_GRAINED_TOOL_STREAMING_BETA)
1080
+ if (
1081
+ model.reasoning
1082
+ and options.get("thinking_enabled") is True
1083
+ and _flag(options, "interleaved_thinking", True)
1084
+ and model_compat.get("forceAdaptiveThinking") is not True
1085
+ ):
1086
+ features.append(INTERLEAVED_THINKING_BETA)
1087
+ if model_compat.get("allowedFallbackModels"):
1088
+ features.append(SERVER_SIDE_FALLBACK_BETA)
1089
+ if model_compat.get("supportsMidConvoEffort") is True:
1090
+ features.extend((MID_CONVERSATION_OUTPUT_CONFIG_BETA, THINKING_BINDING_CONTROLS_BETA))
1091
+ if native_tool_changes:
1092
+ features.append(INLINE_TOOLS_BETA)
1093
+ return list(dict.fromkeys(features))
1094
+
1095
+
1096
+ @dataclass(slots=True)
1097
+ class AnthropicParams:
1098
+ """Result of :func:`build_params`."""
1099
+
1100
+ params: dict[str, Any]
1101
+ has_preferred_strict_tools: bool
1102
+ #: Effort of the final effort marker, recorded on the reply so later requests can replay it.
1103
+ provider_thinking_level: str | None
1104
+
1105
+
1106
+ def build_params(
1107
+ model: Model,
1108
+ context: TranscriptContext,
1109
+ is_oauth_token: bool,
1110
+ options: Mapping[str, Any] | None = None,
1111
+ disable_preferred_strict_tools: bool = False,
1112
+ ) -> AnthropicParams:
1113
+ """Build the ``beta.messages.create`` params (including ``betas``) for a resolved transcript."""
1114
+ options = options or {}
1115
+ cache_control = _get_cache_control(model, options.get("cache_retention"), options.get("env"))
1116
+ compat = _get_compat(model)
1117
+ model_compat = model.compat or {}
1118
+ initial = get_initial_system_message(context.messages)
1119
+ initial_text = get_system_message_text(initial) if initial is not None else ""
1120
+ transformed = transform_messages(context.messages, model, normalize_tool_call_id)
1121
+ conversation = transformed[1:] if initial is not None else transformed
1122
+
1123
+ # Native tool changes keep the request-level tool list fixed and define every later tool
1124
+ # by value in a `tool_addition` block, which also expresses same-name redefinitions.
1125
+ # Anthropic rejects a tool list where every tool is deferred, so there must be an initial
1126
+ # active tool to anchor the placeholder. Otherwise the current tool list is sent.
1127
+ has_preferred_strict_tools = False
1128
+
1129
+ def convert_tool_definitions(
1130
+ tools: list[Tool], tool_cache_control: Mapping[str, Any] | None = None
1131
+ ) -> list[dict[str, Any]]:
1132
+ nonlocal has_preferred_strict_tools
1133
+ definitions = convert_tools(
1134
+ tools,
1135
+ is_oauth_token,
1136
+ compat.supports_eager_tool_input_streaming,
1137
+ compat.supports_strict_tools,
1138
+ tool_cache_control,
1139
+ disable_preferred_strict_tools,
1140
+ )
1141
+ if any(
1142
+ definition.get("strict") is True and _is_preferred_strict(tool)
1143
+ for definition, tool in zip(definitions, tools, strict=False)
1144
+ ):
1145
+ has_preferred_strict_tools = True
1146
+ return definitions
1147
+
1148
+ initial_tools = list(initial.tools_added or []) if initial is not None else []
1149
+ native_tool_changes = (
1150
+ compat.supports_mid_convo_system_messages and compat.supports_mid_convo_tool_changes and len(initial_tools) > 0
1151
+ )
1152
+ level_map = model.thinking_level_map
1153
+ off_unsupported = level_map is not None and "off" in level_map and level_map["off"] is None
1154
+ thinking_disabled = bool(model.reasoning) and options.get("thinking_enabled") is False and not off_unsupported
1155
+ # Effort cannot change between messages while thinking is disabled, so "off" sends no effort markers.
1156
+ effort_messages = model_compat.get("supportsMidConvoEffort") is True and not thinking_disabled
1157
+ converted = convert_messages(
1158
+ conversation,
1159
+ is_oauth_token,
1160
+ cache_control,
1161
+ compat.allow_empty_signature,
1162
+ model.provider if effort_messages else None,
1163
+ convert_tool_definitions if native_tool_changes else None,
1164
+ )
1165
+ effort = options.get("effort")
1166
+ active_effort = effort if effort is not None else "high"
1167
+ betas = get_beta_features(model, context, is_oauth_token, native_tool_changes, options)
1168
+ max_tokens = options.get("max_tokens")
1169
+ params: dict[str, Any] = {
1170
+ "model": model.id,
1171
+ "messages": insert_thinking_level_messages(converted, active_effort) if effort_messages else converted.messages,
1172
+ "max_tokens": max_tokens if max_tokens is not None else model.max_tokens,
1173
+ "stream": True,
1174
+ }
1175
+ if betas:
1176
+ params["betas"] = betas
1177
+
1178
+ def system_block(text: str) -> dict[str, Any]:
1179
+ block: dict[str, Any] = {"type": "text", "text": text}
1180
+ if cache_control:
1181
+ block["cache_control"] = cache_control
1182
+ return block
1183
+
1184
+ # For OAuth tokens, we MUST include Claude Code identity
1185
+ if is_oauth_token:
1186
+ system = [system_block(CLAUDE_CODE_SYSTEM_PROMPT)]
1187
+ if initial_text:
1188
+ system.append(system_block(sanitize_surrogates(initial_text)))
1189
+ params["system"] = system
1190
+ elif initial_text:
1191
+ # Add cache control to system prompt for non-OAuth tokens
1192
+ params["system"] = [system_block(sanitize_surrogates(initial_text))]
1193
+
1194
+ # Temperature is incompatible with extended thinking and unsupported on Claude Opus 4.7+.
1195
+ temperature = options.get("temperature")
1196
+ if (
1197
+ temperature is not None
1198
+ and not options.get("thinking_enabled")
1199
+ and model_compat.get("supportsMidConvoEffort") is not True
1200
+ and compat.supports_temperature
1201
+ ):
1202
+ params["temperature"] = temperature
1203
+
1204
+ tool_cache_control = cache_control if compat.supports_cache_control_on_tools else None
1205
+ if native_tool_changes:
1206
+ # Initial tools stay active with the cache breakpoint on the last one, followed by the
1207
+ # placeholder. The list never changes afterwards: later tools are defined by value in
1208
+ # `tool_addition` blocks and withdrawn by `tool_removal`, so the cached prefix survives
1209
+ # every tool change.
1210
+ params["tools"] = [
1211
+ *convert_tool_definitions(initial_tools, tool_cache_control),
1212
+ _deferred_tool_placeholder(),
1213
+ ]
1214
+ else:
1215
+ tools = get_current_tools(context.messages)
1216
+ if tools:
1217
+ params["tools"] = convert_tool_definitions(tools, tool_cache_control)
1218
+
1219
+ if thinking_disabled:
1220
+ # No effort is sent, so the model default applies. Anthropic rejects `block_binding`
1221
+ # with disabled thinking.
1222
+ params["thinking"] = {"type": "disabled"}
1223
+ elif effort_messages:
1224
+ # Managed effort models use adaptive thinking so prefix mismatches can
1225
+ # be dropped instead of surfacing as persistent 400 responses.
1226
+ params["thinking"] = {
1227
+ "type": "adaptive",
1228
+ "display": options.get("thinking_display") or "summarized",
1229
+ "block_binding": {"prefix_mismatch_behavior": "drop_block"},
1230
+ }
1231
+ params["output_config"] = {"effort": "high"}
1232
+ elif model.reasoning and options.get("thinking_enabled"):
1233
+ # Default to "summarized" so Opus 4.7 and Mythos Preview behave like
1234
+ # older Claude 4 models (whose API default is also "summarized").
1235
+ display = options.get("thinking_display") or "summarized"
1236
+ if model_compat.get("forceAdaptiveThinking") is True:
1237
+ # Adaptive thinking: Claude decides when and how much to think.
1238
+ params["thinking"] = {"type": "adaptive", "display": display}
1239
+ if effort:
1240
+ params["output_config"] = {"effort": effort}
1241
+ else:
1242
+ # Budget-based thinking for older models
1243
+ params["thinking"] = {
1244
+ "type": "enabled",
1245
+ "budget_tokens": options.get("thinking_budget_tokens") or 1024,
1246
+ "display": display,
1247
+ }
1248
+
1249
+ metadata = options.get("metadata")
1250
+ if metadata:
1251
+ user_id = metadata.get("user_id")
1252
+ if isinstance(user_id, str):
1253
+ params["metadata"] = {"user_id": user_id}
1254
+
1255
+ tool_choice = options.get("tool_choice")
1256
+ if tool_choice:
1257
+ params["tool_choice"] = {"type": tool_choice} if isinstance(tool_choice, str) else tool_choice
1258
+
1259
+ fallbacks = model_compat.get("allowedFallbackModels")
1260
+ if fallbacks:
1261
+ params["fallbacks"] = [{"model": fallback["model"]} for fallback in fallbacks]
1262
+
1263
+ return AnthropicParams(params, has_preferred_strict_tools, active_effort if effort_messages else None)
1264
+
1265
+
1266
+ # ---------------------------------------------------------------------------
1267
+ # Response parsing
1268
+ # ---------------------------------------------------------------------------
1269
+
1270
+
1271
+ def map_stop_reason(reason: str, stop_details: Mapping[str, Any] | None = None) -> tuple[str, str | None]:
1272
+ """Map an Anthropic stop reason to ``(pi stop reason, error message)``."""
1273
+ match reason:
1274
+ case "end_turn":
1275
+ return "stop", None
1276
+ case "max_tokens":
1277
+ return "length", None
1278
+ case "tool_use":
1279
+ return "toolUse", None
1280
+ case "refusal":
1281
+ explanation = stop_details.get("explanation") if isinstance(stop_details, Mapping) else None
1282
+ return "error", explanation or "The model refused to complete the request"
1283
+ case "pause_turn": # Stop is good enough -> resubmit
1284
+ return "stop", None
1285
+ case "stop_sequence":
1286
+ return "stop", None # We don't supply stop sequences, so this should never happen
1287
+ case "sensitive": # Content flagged by safety filters
1288
+ return "error", "Provider stopped with: sensitive"
1289
+ case _:
1290
+ # Handle unknown stop reasons gracefully (API may add new values)
1291
+ raise ValueError(f"Unhandled stop reason: {reason}")
1292
+
1293
+
1294
+ async def _iter_sse_messages(response: httpx2.Response) -> AsyncIterator[tuple[str | None, str, list[str]]]:
1295
+ """Decode SSE messages as ``(event, data, raw lines)``, keeping raw lines for parse errors."""
1296
+ event: str | None = None
1297
+ data: list[str] = []
1298
+ raw: list[str] = []
1299
+ async for line in response.aiter_lines():
1300
+ if line == "":
1301
+ if event or data:
1302
+ yield event, "\n".join(data), raw
1303
+ event = None
1304
+ data = []
1305
+ raw = []
1306
+ continue
1307
+ raw.append(line)
1308
+ if line[0] == ":":
1309
+ continue
1310
+ name, _, value = line.partition(":")
1311
+ if value.startswith(" "):
1312
+ value = value[1:]
1313
+ if name == "event":
1314
+ event = value
1315
+ elif name == "data":
1316
+ data.append(value)
1317
+ if event or data:
1318
+ yield event, "\n".join(data), raw
1319
+
1320
+
1321
+ async def _iter_anthropic_events(response: httpx2.Response) -> AsyncIterator[Any]:
1322
+ saw_message_start = False
1323
+ saw_message_end = False
1324
+ async for event_name, data, raw in _iter_sse_messages(response):
1325
+ if event_name == "error":
1326
+ raise RuntimeError(data)
1327
+ if event_name not in _ANTHROPIC_MESSAGE_EVENTS:
1328
+ continue
1329
+ try:
1330
+ event = parse_json_with_repair(data)
1331
+ if event is None:
1332
+ raise ValueError("Cannot read properties of null (reading 'type')")
1333
+ except ValueError as error:
1334
+ # Raw lines are joined with a literal backslash-n, as upstream does.
1335
+ raw_text = "\\n".join(raw)
1336
+ raise RuntimeError(
1337
+ f"Could not parse Anthropic SSE event {event_name}: {error}; data={data}; raw={raw_text}"
1338
+ ) from error
1339
+ if isinstance(event, dict):
1340
+ kind = event.get("type")
1341
+ if kind == "message_start":
1342
+ saw_message_start = True
1343
+ elif kind == "message_stop":
1344
+ saw_message_end = True
1345
+ yield event
1346
+ if saw_message_start and not saw_message_end:
1347
+ raise RuntimeError("Anthropic stream ended before message_stop")
1348
+
1349
+
1350
+ def _usage_model(model: Model, response_model: str | None) -> Model:
1351
+ """The model used for pricing: a returned fallback model's pricing when it differs."""
1352
+ if response_model == model.id:
1353
+ return model
1354
+ for fallback in (model.compat or {}).get("allowedFallbackModels") or ():
1355
+ if fallback.get("provider") == model.provider and fallback.get("model") == response_model:
1356
+ cost = fallback.get("cost")
1357
+ if not cost:
1358
+ return model
1359
+ fallback_cost = cost if isinstance(cost, ModelCost) else from_dict(ModelCost, cost)
1360
+ return dataclasses.replace(model, id=response_model, cost=fallback_cost)
1361
+ return model
1362
+
1363
+
1364
+ def _update_total(usage: Usage) -> None:
1365
+ # Anthropic doesn't provide total_tokens, compute from components
1366
+ usage.total_tokens = usage.input + usage.output + usage.cache_read + usage.cache_write
1367
+
1368
+
1369
+ def _is_strict_tool_limit_error(error: BaseException) -> bool:
1370
+ if not isinstance(error, ProviderHttpError) or error.status != 400:
1371
+ return False
1372
+ body = error.error
1373
+ inner = body.get("error") if isinstance(body, dict) else None
1374
+ if not isinstance(inner, dict) or inner.get("type") != "invalid_request_error":
1375
+ return False
1376
+ message = str(error)
1377
+ return any(text in message for text in _STRICT_TOOL_LIMIT_MESSAGES)
1378
+
1379
+
1380
+ def _input_transformations_diagnostic(transformations: list[Any]) -> AssistantMessageDiagnostic:
1381
+ details: list[dict[str, Any]] = []
1382
+ for transformation in transformations:
1383
+ entry: dict[str, Any] = {}
1384
+ if isinstance(transformation, Mapping):
1385
+ for key in ("type", "path", "reason"):
1386
+ value = transformation.get(key)
1387
+ if value is not None:
1388
+ entry[key] = value
1389
+ details.append(entry)
1390
+ return AssistantMessageDiagnostic(
1391
+ type="anthropic_input_transformations", timestamp=now_ms(), details={"transformations": details}
1392
+ )
1393
+
1394
+
1395
+ # ---------------------------------------------------------------------------
1396
+ # Streaming
1397
+ # ---------------------------------------------------------------------------
1398
+
1399
+
1400
+ def stream(
1401
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
1402
+ ) -> AssistantMessageEventStream:
1403
+ """Stream a response from the Anthropic Messages API (options: :class:`AnthropicOptions`)."""
1404
+ opts: dict[str, Any] = dict(options or {})
1405
+ out = AssistantMessageEventStream()
1406
+ normalized = resolve_transcript(context, _get_compat(model).supports_mid_convo_system_messages)
1407
+ current_tools = get_current_tools(normalized.messages)
1408
+ out.start(_run(model, normalized, current_tools, opts, out))
1409
+ return out
1410
+
1411
+
1412
+ async def _run(
1413
+ model: Model,
1414
+ context: TranscriptContext,
1415
+ current_tools: list[Tool],
1416
+ options: dict[str, Any],
1417
+ out: AssistantMessageEventStream,
1418
+ ) -> None:
1419
+ output = AssistantMessage(
1420
+ [], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
1421
+ )
1422
+ signal = options.get("signal")
1423
+ try:
1424
+ async with abort_scope(signal):
1425
+ await _stream_response(model, context, current_tools, options, output, out)
1426
+ if signal is not None and signal.aborted:
1427
+ raise AbortError(signal.reason)
1428
+ if output.stop_reason == "pending":
1429
+ raise RuntimeError("Anthropic stream ended without a stop reason")
1430
+ if output.stop_reason in ("aborted", "error"):
1431
+ raise RuntimeError(output.error_message or "An unknown error occurred")
1432
+ out.push(DoneEvent(output.stop_reason, output)) # type: ignore[arg-type]
1433
+ out.end()
1434
+ except Exception as error:
1435
+ aborted = signal is not None and signal.aborted
1436
+ output.stop_reason = "aborted" if aborted else "error"
1437
+ output.error_message = (
1438
+ "Request was aborted" if isinstance(error, AbortError) else (str(error) or type(error).__name__)
1439
+ )
1440
+ out.push(ErrorEvent(output.stop_reason, output)) # type: ignore[arg-type]
1441
+ out.end()
1442
+
1443
+
1444
+ async def _stream_response(
1445
+ model: Model,
1446
+ context: TranscriptContext,
1447
+ current_tools: list[Tool],
1448
+ options: dict[str, Any],
1449
+ output: AssistantMessage,
1450
+ out: AssistantMessageEventStream,
1451
+ ) -> None:
1452
+ signal = options.get("signal")
1453
+ token_cache: TokenCache | None = None
1454
+ custom_client = options.get("client")
1455
+ if custom_client is not None:
1456
+ # Upstream sends every request with maxRetries: 0 and retries itself.
1457
+ client: AsyncAnthropic = (
1458
+ custom_client if custom_client.max_retries == 0 else custom_client.with_options(max_retries=0)
1459
+ )
1460
+ is_oauth = False
1461
+ else:
1462
+ api_key: str | None = options.get("api_key")
1463
+ options_headers: Mapping[str, str | None] | None = options.get("headers")
1464
+ federation = _get_anthropic_federation(model, api_key, options_headers, options.get("env"))
1465
+ if federation is None:
1466
+ _assert_request_auth(model.provider, api_key, options_headers)
1467
+
1468
+ dynamic_headers: dict[str, str] | None = None
1469
+ if model.provider == "github-copilot":
1470
+ dynamic_headers = build_copilot_dynamic_headers(
1471
+ messages=context.messages, has_images=has_copilot_vision_input(context.messages)
1472
+ )
1473
+ cache_retention = _resolve_cache_retention(options.get("cache_retention"), options.get("env"))
1474
+ cache_session_id = None if cache_retention == "none" else options.get("session_id")
1475
+ client, is_oauth, token_cache = _create_client(
1476
+ model, api_key, options_headers, dynamic_headers, cache_session_id, federation, options
1477
+ )
1478
+
1479
+ async def attempt(params: Mapping[str, Any]) -> Any:
1480
+ try:
1481
+ return await _create_response(client, params, options)
1482
+ except ProviderHttpError as error:
1483
+ if token_cache is not None and error.status == 401:
1484
+ # The server rejected the token even if it still looks fresh.
1485
+ token_cache.invalidate()
1486
+ raise
1487
+
1488
+ disable_preferred_strict_tools = False
1489
+ while True:
1490
+ if signal is not None:
1491
+ signal.throw_if_aborted()
1492
+ built = build_params(model, context, is_oauth, options, disable_preferred_strict_tools)
1493
+ if built.provider_thinking_level:
1494
+ output.provider_thinking_level = built.provider_thinking_level
1495
+ params = built.params
1496
+ on_payload = options.get("on_payload")
1497
+ if on_payload is not None:
1498
+ replaced = await maybe_await(on_payload(params, model))
1499
+ if replaced is not None:
1500
+ params = dict(replaced)
1501
+ params["stream"] = True
1502
+ try:
1503
+ raw = await retry_provider_request(
1504
+ lambda params=params: attempt(params),
1505
+ max_retries=options.get("max_retries") or 0,
1506
+ max_retry_delay_ms=options.get("max_retry_delay_ms"),
1507
+ signal=signal,
1508
+ )
1509
+ break
1510
+ except Exception as error:
1511
+ # Only downgrade a rejected request, never an already-started stream.
1512
+ if (
1513
+ (signal is not None and signal.aborted)
1514
+ or disable_preferred_strict_tools
1515
+ or not built.has_preferred_strict_tools
1516
+ or not _is_strict_tool_limit_error(error)
1517
+ ):
1518
+ raise
1519
+ disable_preferred_strict_tools = True
1520
+ append_assistant_message_diagnostic(
1521
+ output,
1522
+ AssistantMessageDiagnostic(
1523
+ type="anthropic_strict_tools_fallback",
1524
+ timestamp=now_ms(),
1525
+ error=DiagnosticErrorInfo(message=str(error)),
1526
+ details={"reason": "strict_tool_limits_exceeded"},
1527
+ ),
1528
+ )
1529
+
1530
+ response: httpx2.Response = raw.http_response
1531
+ try:
1532
+ on_response = options.get("on_response")
1533
+ if on_response is not None:
1534
+ await maybe_await(
1535
+ on_response(ProviderResponse(response.status_code, headers_to_record(response.headers)), model)
1536
+ )
1537
+ out.push(StartEvent(output))
1538
+ await _consume_events(model, current_tools, is_oauth, options, output, out, response)
1539
+ finally:
1540
+ await response.aclose()
1541
+
1542
+
1543
+ async def _consume_events(
1544
+ model: Model,
1545
+ current_tools: list[Tool],
1546
+ is_oauth: bool,
1547
+ options: Mapping[str, Any],
1548
+ output: AssistantMessage,
1549
+ out: AssistantMessageEventStream,
1550
+ response: httpx2.Response,
1551
+ ) -> None:
1552
+ content = output.content
1553
+ usage = output.usage
1554
+ usage_model = model
1555
+ input_transformations: list[Any] | None = None
1556
+ # Anthropic content block index -> positions in output.content of blocks not yet stopped.
1557
+ open_blocks: dict[Any, list[int]] = {}
1558
+ # Streaming argument parsers of open tool calls, keyed by content position.
1559
+ parsers: dict[int, StreamingJsonParser] = {}
1560
+ on_event = options.get("on_provider_stream_event")
1561
+
1562
+ def find(index: Any) -> int:
1563
+ positions = open_blocks.get(index)
1564
+ return positions[0] if positions else -1
1565
+
1566
+ def open_block(index: Any) -> int:
1567
+ position = len(content) - 1
1568
+ open_blocks.setdefault(index, []).append(position)
1569
+ return position
1570
+
1571
+ async for event in _iter_anthropic_events(response):
1572
+ if on_event is not None:
1573
+ await maybe_await(on_event(event, model))
1574
+ if not isinstance(event, dict):
1575
+ continue
1576
+ kind = event.get("type")
1577
+ if kind == "content_block_delta":
1578
+ position = find(event.get("index"))
1579
+ if position < 0:
1580
+ continue
1581
+ block = content[position]
1582
+ delta = event.get("delta") or {}
1583
+ delta_type = delta.get("type")
1584
+ if delta_type == "text_delta":
1585
+ if block.type == "text":
1586
+ text = delta.get("text") or ""
1587
+ append_text(block, text)
1588
+ out.push(TextDeltaEvent(position, text, output))
1589
+ elif delta_type == "thinking_delta":
1590
+ if block.type == "thinking":
1591
+ thinking = delta.get("thinking") or ""
1592
+ append_thinking(block, thinking)
1593
+ out.push(ThinkingDeltaEvent(position, thinking, output))
1594
+ elif delta_type == "input_json_delta":
1595
+ if block.type == "toolCall":
1596
+ partial_json = delta.get("partial_json") or ""
1597
+ parser = parsers.get(position)
1598
+ if parser is None:
1599
+ parser = parsers[position] = StreamingJsonParser()
1600
+ parser.feed(partial_json)
1601
+ block.arguments = parser.value()
1602
+ out.push(ToolCallDeltaEvent(position, partial_json, output))
1603
+ elif delta_type == "signature_delta":
1604
+ if block.type == "thinking":
1605
+ block.thinking_signature = (block.thinking_signature or "") + (delta.get("signature") or "")
1606
+ elif kind == "content_block_start":
1607
+ content_block = event.get("content_block") or {}
1608
+ block_type = content_block.get("type")
1609
+ index = event.get("index")
1610
+ if block_type == "fallback":
1611
+ if content:
1612
+ raise RuntimeError("Anthropic performed an unsupported mid-output model fallback")
1613
+ continue
1614
+ if block_type == "text":
1615
+ text = content_block.get("text")
1616
+ content.append(TextContent(text if text is not None else ""))
1617
+ out.push(TextStartEvent(open_block(index), output))
1618
+ elif block_type == "thinking":
1619
+ thinking = content_block.get("thinking")
1620
+ signature = content_block.get("signature")
1621
+ content.append(
1622
+ ThinkingContent(
1623
+ thinking if thinking is not None else "",
1624
+ thinking_signature=signature if signature is not None else "",
1625
+ )
1626
+ )
1627
+ out.push(ThinkingStartEvent(open_block(index), output))
1628
+ elif block_type == "redacted_thinking":
1629
+ content.append(
1630
+ ThinkingContent("[Reasoning redacted]", thinking_signature=content_block.get("data"), redacted=True)
1631
+ )
1632
+ out.push(ThinkingStartEvent(open_block(index), output))
1633
+ elif block_type == "tool_use":
1634
+ name = content_block.get("name") or ""
1635
+ arguments = content_block.get("input")
1636
+ content.append(
1637
+ ToolCall(
1638
+ content_block.get("id") or "",
1639
+ from_claude_code_name(name, current_tools) if is_oauth else name,
1640
+ arguments if isinstance(arguments, dict) else {},
1641
+ )
1642
+ )
1643
+ out.push(ToolCallStartEvent(open_block(index), output))
1644
+ elif kind == "content_block_stop":
1645
+ positions = open_blocks.get(event.get("index"))
1646
+ if not positions:
1647
+ continue
1648
+ position = positions.pop(0)
1649
+ block = content[position]
1650
+ if block.type == "text":
1651
+ out.push(TextEndEvent(position, block.text, output))
1652
+ elif block.type == "thinking":
1653
+ out.push(ThinkingEndEvent(position, block.thinking, output))
1654
+ elif block.type == "toolCall":
1655
+ parser = parsers.pop(position, None)
1656
+ arguments, arguments_error = parse_tool_call_arguments(parser.text if parser is not None else "")
1657
+ block.arguments = arguments
1658
+ if arguments_error is not None:
1659
+ block.arguments_error = arguments_error
1660
+ out.push(ToolCallEndEvent(position, block, output))
1661
+ elif kind == "message_start":
1662
+ message = event.get("message") or {}
1663
+ output.response_id = message.get("id")
1664
+ transformations = message.get("input_transformations")
1665
+ if isinstance(transformations, list):
1666
+ input_transformations = transformations
1667
+ response_model = message.get("model")
1668
+ if response_model != model.id:
1669
+ output.response_model = response_model
1670
+ usage_model = _usage_model(model, response_model)
1671
+ # Capture initial token usage from message_start event
1672
+ # This ensures we have input token counts even if the stream is aborted early
1673
+ start_usage = message.get("usage") or {}
1674
+ usage.input = start_usage.get("input_tokens") or 0
1675
+ usage.output = start_usage.get("output_tokens") or 0
1676
+ usage.cache_read = start_usage.get("cache_read_input_tokens") or 0
1677
+ usage.cache_write = start_usage.get("cache_creation_input_tokens") or 0
1678
+ usage.cache_write_1h = (start_usage.get("cache_creation") or {}).get("ephemeral_1h_input_tokens") or 0
1679
+ _update_total(usage)
1680
+ calculate_cost(usage_model, usage)
1681
+ elif kind == "message_delta":
1682
+ transformations = event.get("input_transformations")
1683
+ if isinstance(transformations, list):
1684
+ input_transformations = transformations
1685
+ delta = event.get("delta") or {}
1686
+ stop_reason = delta.get("stop_reason")
1687
+ if stop_reason:
1688
+ output.raw_stop_reason = stop_reason
1689
+ mapped, error_message = map_stop_reason(stop_reason, delta.get("stop_details"))
1690
+ output.stop_reason = mapped # type: ignore[assignment]
1691
+ if error_message:
1692
+ output.error_message = error_message
1693
+ # Only update usage fields if present (not null).
1694
+ # Preserves input_tokens from message_start when proxies omit it in message_delta.
1695
+ delta_usage = event.get("usage")
1696
+ if delta_usage:
1697
+ if delta_usage.get("input_tokens") is not None:
1698
+ usage.input = delta_usage["input_tokens"]
1699
+ if delta_usage.get("output_tokens") is not None:
1700
+ usage.output = delta_usage["output_tokens"]
1701
+ if delta_usage.get("cache_read_input_tokens") is not None:
1702
+ usage.cache_read = delta_usage["cache_read_input_tokens"]
1703
+ if delta_usage.get("cache_creation_input_tokens") is not None:
1704
+ usage.cache_write = delta_usage["cache_creation_input_tokens"]
1705
+ # Vercel AI Gateway includes the TTL breakdown in deltas, though the SDK only types it on message_start.
1706
+ cache_creation = delta_usage.get("cache_creation")
1707
+ if isinstance(cache_creation, dict) and cache_creation.get("ephemeral_1h_input_tokens") is not None:
1708
+ usage.cache_write_1h = cache_creation["ephemeral_1h_input_tokens"]
1709
+ # Anthropic reports reasoning tokens as a subset of output tokens.
1710
+ details = delta_usage.get("output_tokens_details")
1711
+ if isinstance(details, dict) and details.get("thinking_tokens") is not None:
1712
+ usage.reasoning = details["thinking_tokens"]
1713
+ _update_total(usage)
1714
+ calculate_cost(usage_model, usage)
1715
+
1716
+ if input_transformations:
1717
+ append_assistant_message_diagnostic(output, _input_transformations_diagnostic(input_transformations))
1718
+
1719
+
1720
+ # ---------------------------------------------------------------------------
1721
+ # Simple options
1722
+ # ---------------------------------------------------------------------------
1723
+
1724
+
1725
+ def _map_thinking_level_to_effort(model: Model, level: str | None) -> str:
1726
+ """Map a pi thinking level to an Anthropic effort for adaptive thinking.
1727
+
1728
+ Effort "max" is available on all adaptive-thinking Claude models, while native
1729
+ "xhigh" is only available on Opus 4.7/4.8, Sonnet 5, and Fable 5.
1730
+ """
1731
+ mapped = (model.thinking_level_map or {}).get(level) if level else None
1732
+ if isinstance(mapped, str):
1733
+ return mapped
1734
+ if level in ("minimal", "low"):
1735
+ return "low"
1736
+ if level == "medium":
1737
+ return "medium"
1738
+ return "high"
1739
+
1740
+
1741
+ def stream_simple(
1742
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
1743
+ ) -> AssistantMessageEventStream:
1744
+ """Stream with provider-neutral options (``reasoning`` maps to effort or a thinking budget).
1745
+
1746
+ Raises ``ValueError`` synchronously when no API key, auth header or federation config is available.
1747
+ """
1748
+ options = options or {}
1749
+ api_key = options.get("api_key")
1750
+ if _get_anthropic_federation(model, api_key, options.get("headers"), options.get("env")) is None:
1751
+ _assert_request_auth(model.provider, api_key, options.get("headers"))
1752
+
1753
+ base: dict[str, Any] = build_base_options(model, context, options, api_key)
1754
+ if options.get("tool_choice") is not None:
1755
+ base["tool_choice"] = options["tool_choice"]
1756
+ reasoning = options.get("reasoning")
1757
+ if not reasoning:
1758
+ return stream(model, context, {**base, "thinking_enabled": False})
1759
+
1760
+ # For models with adaptive thinking: use an effort level.
1761
+ # For older models: use budget-based thinking.
1762
+ if (model.compat or {}).get("forceAdaptiveThinking") is True:
1763
+ return stream(
1764
+ model,
1765
+ context,
1766
+ {**base, "thinking_enabled": True, "effort": _map_thinking_level_to_effort(model, reasoning)},
1767
+ )
1768
+
1769
+ adjusted_max_tokens, thinking_budget = adjust_max_tokens_for_thinking(
1770
+ base.get("max_tokens"), model.max_tokens, reasoning, options.get("thinking_budgets")
1771
+ )
1772
+ max_tokens = clamp_max_tokens_to_context(model, context, adjusted_max_tokens)
1773
+ return stream(
1774
+ model,
1775
+ context,
1776
+ {
1777
+ **base,
1778
+ "max_tokens": max_tokens,
1779
+ "thinking_enabled": True,
1780
+ "thinking_budget_tokens": min(thinking_budget, max(0, max_tokens - 1024)),
1781
+ },
1782
+ )