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.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|