File size: 11,899 Bytes
5ef3875 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | """Anthropic Claude provider (Messages API with streaming tool use)."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import anthropic
from ycode.errors import (
LLMAuthenticationError,
LLMBadRequestError,
LLMConnectionError,
LLMError,
LLMNotFoundError,
LLMPermissionError,
LLMRateLimitError,
LLMResponseError,
LLMServerError,
MissingCredentialsError,
YCodeError,
)
from ycode.llm.base import (
AssistantMessage,
LLMProvider,
LLMResponse,
Message,
StopReason,
StreamCallbacks,
ToolCall,
ToolResultsMessage,
ToolSpec,
Usage,
UserMessage,
)
# Models that take `thinking: {"type": "adaptive"}` and `output_config.effort`.
_ADAPTIVE_PREFIXES = (
"claude-fable-5",
"claude-mythos-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-5",
"claude-sonnet-4-6",
)
_NO_XHIGH_PREFIXES = ("claude-opus-4-6", "claude-sonnet-4-6")
# Models that accept server-side refusal fallbacks in the `"default"` form.
_FALLBACK_MODELS = {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
_FALLBACK_BETA = "server-side-fallback-2026-07-01"
_STOP_REASONS = {
"end_turn": StopReason.END_TURN,
"stop_sequence": StopReason.END_TURN,
"tool_use": StopReason.TOOL_USE,
"max_tokens": StopReason.MAX_TOKENS,
"refusal": StopReason.REFUSAL,
"pause_turn": StopReason.PAUSE,
}
_MAX_JSON_RETRIES = 2
def _base_model(model: str) -> str:
return model.split(".", 1)[1] if model.startswith("anthropic.") else model
def supports_adaptive_thinking(model: str) -> bool:
return _base_model(model).startswith(_ADAPTIVE_PREFIXES)
def supports_fallbacks(model: str) -> bool:
return _base_model(model) in _FALLBACK_MODELS
def has_credentials(env: dict[str, str] | None = None) -> bool:
"""Best-effort check that the SDK will find some credential source."""
env = dict(os.environ) if env is None else env
if env.get("ANTHROPIC_API_KEY", "").strip() or env.get("ANTHROPIC_AUTH_TOKEN", "").strip():
return True
if env.get("ANTHROPIC_PROFILE") or (
env.get("ANTHROPIC_FEDERATION_RULE_ID") and env.get("ANTHROPIC_IDENTITY_TOKEN_FILE")
):
return True
config_dir = Path(env.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "anthropic"
return config_dir.is_dir() and any(config_dir.iterdir())
def translate_error(exc: Exception) -> YCodeError:
"""Map SDK exceptions to YCode errors (most specific first)."""
if isinstance(exc, anthropic.AuthenticationError):
return LLMAuthenticationError("Authentication failed: the API key was rejected.")
if isinstance(exc, anthropic.PermissionDeniedError):
return LLMPermissionError(f"Permission denied by the API: {exc.message}")
if isinstance(exc, anthropic.NotFoundError):
return LLMNotFoundError(f"Not found: {exc.message}")
if isinstance(exc, anthropic.RateLimitError):
retry_after = exc.response.headers.get("retry-after") if exc.response is not None else None
suffix = f" Retry after {retry_after}s." if retry_after else ""
return LLMRateLimitError(f"Rate limited by the API.{suffix}")
if isinstance(exc, anthropic.BadRequestError):
return LLMBadRequestError(f"The API rejected the request: {exc.message}")
if isinstance(exc, anthropic.APIStatusError):
if exc.status_code >= 500:
return LLMServerError(f"API server error ({exc.status_code}): {exc.message}")
return LLMError(f"API error ({exc.status_code}): {exc.message}")
if isinstance(exc, anthropic.APITimeoutError):
return LLMConnectionError("The request to the API timed out.")
if isinstance(exc, anthropic.APIConnectionError):
return LLMConnectionError("Could not connect to the Anthropic API.")
if isinstance(exc, anthropic.APIResponseValidationError):
return LLMResponseError(f"The API returned an unexpected response: {exc}")
if isinstance(exc, TypeError) and "authentication" in str(exc).lower():
return MissingCredentialsError("No Anthropic API credentials found.")
return LLMError(f"Unexpected model error: {type(exc).__name__}: {exc}")
class AnthropicProvider(LLMProvider):
name = "anthropic"
def __init__(
self,
model: str,
*,
max_tokens: int = 64000,
effort: str | None = "high",
refusal_fallback: bool = True,
client: Any = None,
) -> None:
super().__init__(model)
self.max_tokens = max_tokens
self.effort = effort
self.refusal_fallback = refusal_fallback
self._client = client
# Through a custom gateway, stick to the plainest request shape.
self._custom_base_url = bool(os.environ.get("ANTHROPIC_BASE_URL"))
@property
def client(self) -> Any:
if self._client is None:
self._client = anthropic.Anthropic(max_retries=3)
return self._client
@property
def display_name(self) -> str:
return f"Claude ({self.model})"
# ---------------------------------------------------------- conversion
def _tool_param(self, spec: ToolSpec) -> dict[str, Any]:
param: dict[str, Any] = {
"name": spec.name,
"description": spec.description,
"input_schema": spec.parameters,
}
if not self._custom_base_url:
# Stream large inputs (file contents) as they are generated. The
# agent validates every input before running a tool.
param["eager_input_streaming"] = True
return param
def convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
def push(role: str, content: Any) -> None:
if out and out[-1]["role"] == role:
prev = out[-1]["content"]
if isinstance(prev, str):
prev = [{"type": "text", "text": prev}]
if isinstance(content, str):
content = [{"type": "text", "text": content}]
out[-1]["content"] = [*prev, *content]
else:
out.append({"role": role, "content": content})
for msg in messages:
if isinstance(msg, UserMessage):
push("user", msg.text)
elif isinstance(msg, ToolResultsMessage):
push("user", [
{
"type": "tool_result",
"tool_use_id": r.tool_call_id,
"content": r.content or "(no output)",
**({"is_error": True} if r.is_error else {}),
}
for r in msg.results
])
elif isinstance(msg, AssistantMessage):
if msg.provider == self.name and msg.provider_data:
# Replay native blocks unchanged (keeps thinking blocks valid).
push("assistant", list(msg.provider_data))
continue
blocks: list[dict[str, Any]] = []
if msg.text:
blocks.append({"type": "text", "text": msg.text})
for call in msg.tool_calls:
args = call.arguments if isinstance(call.arguments, dict) else {}
blocks.append({"type": "tool_use", "id": call.id, "name": call.name, "input": args})
push("assistant", blocks or [{"type": "text", "text": "(no response)"}])
return out
def build_request(self, system: str, messages: list[Message], tools: list[ToolSpec]) -> dict[str, Any]:
params: dict[str, Any] = {
"model": self.model,
"max_tokens": self.max_tokens,
"system": system,
"messages": self.convert_messages(messages),
"tools": [self._tool_param(t) for t in tools],
# Automatic prompt caching: the growing conversation prefix is reused.
"cache_control": {"type": "ephemeral"},
}
if supports_adaptive_thinking(self.model):
params["thinking"] = {"type": "adaptive"}
if self.effort:
effort = self.effort
if effort == "xhigh" and _base_model(self.model).startswith(_NO_XHIGH_PREFIXES):
effort = "high"
params["output_config"] = {"effort": effort}
if self.refusal_fallback and not self._custom_base_url and supports_fallbacks(self.model):
params["fallbacks"] = "default"
params["betas"] = [_FALLBACK_BETA]
return params
# ------------------------------------------------------------- request
def complete(
self,
*,
system: str,
messages: list[Message],
tools: list[ToolSpec],
callbacks: StreamCallbacks | None = None,
) -> LLMResponse:
callbacks = callbacks or StreamCallbacks()
params = self.build_request(system, messages, tools)
json_failures = 0
while True:
try:
with self.client.beta.messages.stream(**params) as stream:
for event in stream:
etype = getattr(event, "type", None)
if etype == "text":
callbacks.text(event.text)
elif etype == "content_block_start":
block = getattr(event, "content_block", None)
if getattr(block, "type", None) == "tool_use":
callbacks.tool_call_start(block.name)
final = stream.get_final_message()
break
except ValueError as exc:
# Tool-input JSON the SDK could not parse at all: re-issue the turn.
if isinstance(exc, anthropic.AnthropicError):
raise translate_error(exc) from None
json_failures += 1
if json_failures > _MAX_JSON_RETRIES:
raise LLMResponseError("The model produced malformed tool-call JSON repeatedly.") from None
except (anthropic.AnthropicError, TypeError) as exc:
raise translate_error(exc) from None
return self._parse(final)
def _parse(self, final: Any) -> LLMResponse:
content = list(getattr(final, "content", None) or [])
texts: list[str] = []
calls: list[ToolCall] = []
for block in content:
btype = getattr(block, "type", None)
if btype == "text":
texts.append(block.text)
elif btype == "tool_use":
calls.append(ToolCall(id=block.id, name=block.name, arguments=block.input))
raw_stop = getattr(final, "stop_reason", None)
stop = _STOP_REASONS.get(raw_stop or "", StopReason.OTHER)
detail = None
if stop is StopReason.REFUSAL:
details = getattr(final, "stop_details", None)
detail = getattr(details, "explanation", None) or getattr(details, "category", None)
usage_obj = getattr(final, "usage", None)
usage = Usage(
input_tokens=getattr(usage_obj, "input_tokens", 0) or 0,
output_tokens=getattr(usage_obj, "output_tokens", 0) or 0,
cache_read_tokens=getattr(usage_obj, "cache_read_input_tokens", 0) or 0,
cache_write_tokens=getattr(usage_obj, "cache_creation_input_tokens", 0) or 0,
)
message = AssistantMessage(
text="\n\n".join(t for t in texts if t),
tool_calls=calls,
provider=self.name,
provider_data=content,
)
return LLMResponse(message=message, stop_reason=stop, usage=usage, stop_detail=detail)
|