fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
1017 lines
37 KiB
Python
1017 lines
37 KiB
Python
"""
|
|
This file contains common utils for anthropic calls.
|
|
"""
|
|
|
|
import copy
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
import httpx
|
|
|
|
import litellm
|
|
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|
get_file_ids_from_messages,
|
|
)
|
|
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
|
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
|
from litellm.types.llms.anthropic import (
|
|
ANTHROPIC_HOSTED_TOOLS,
|
|
ANTHROPIC_OAUTH_BETA_HEADER,
|
|
ANTHROPIC_OAUTH_TOKEN_PREFIX,
|
|
AllAnthropicToolsValues,
|
|
AnthropicMcpServerTool,
|
|
)
|
|
from litellm.types.llms.openai import AllMessageValues
|
|
|
|
|
|
def is_anthropic_oauth_key(value: Optional[str]) -> bool:
|
|
"""Check if a value contains an Anthropic OAuth token (sk-ant-oat*)."""
|
|
if value is None:
|
|
return False
|
|
# Handle both raw token and "Bearer <token>" format
|
|
if value.startswith("Bearer "):
|
|
value = value[7:]
|
|
return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
|
|
|
|
|
|
def _merge_beta_headers(existing: Optional[str], new_beta: str) -> str:
|
|
"""Merge a new beta value into an existing comma-separated anthropic-beta header."""
|
|
if not existing:
|
|
return new_beta
|
|
betas = {b.strip() for b in existing.split(",") if b.strip()}
|
|
betas.add(new_beta)
|
|
return ",".join(sorted(betas))
|
|
|
|
|
|
def optionally_handle_anthropic_oauth(
|
|
headers: dict, api_key: Optional[str]
|
|
) -> tuple[dict, Optional[str]]:
|
|
"""
|
|
Handle Anthropic OAuth token detection and header setup.
|
|
|
|
If an OAuth token is detected in the Authorization header, extracts it
|
|
and sets the required OAuth headers.
|
|
|
|
Args:
|
|
headers: Request headers dict
|
|
api_key: Current API key (may be None)
|
|
|
|
Returns:
|
|
Tuple of (updated headers, api_key)
|
|
"""
|
|
# Check Authorization header (passthrough / forwarded requests)
|
|
auth_header = headers.get("authorization", "")
|
|
if auth_header and auth_header.startswith(f"Bearer {ANTHROPIC_OAUTH_TOKEN_PREFIX}"):
|
|
api_key = auth_header.replace("Bearer ", "")
|
|
headers.pop("x-api-key", None)
|
|
headers["anthropic-beta"] = _merge_beta_headers(
|
|
headers.get("anthropic-beta"), ANTHROPIC_OAUTH_BETA_HEADER
|
|
)
|
|
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
|
return headers, api_key
|
|
# Check api_key directly (standard chat/completion flow)
|
|
if api_key and api_key.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX):
|
|
headers.pop("x-api-key", None)
|
|
headers["authorization"] = f"Bearer {api_key}"
|
|
headers["anthropic-beta"] = _merge_beta_headers(
|
|
headers.get("anthropic-beta"), ANTHROPIC_OAUTH_BETA_HEADER
|
|
)
|
|
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
|
return headers, api_key
|
|
|
|
|
|
class AnthropicError(BaseLLMException):
|
|
def __init__(
|
|
self,
|
|
status_code: int,
|
|
message,
|
|
headers: Optional[httpx.Headers] = None,
|
|
):
|
|
super().__init__(status_code=status_code, message=message, headers=headers)
|
|
|
|
|
|
class AnthropicModelInfo(BaseLLMModelInfo):
|
|
def is_cache_control_set(self, messages: List[AllMessageValues]) -> bool:
|
|
"""
|
|
Return if {"cache_control": ..} in message content block
|
|
|
|
Used to check if anthropic prompt caching headers need to be set.
|
|
"""
|
|
for message in messages:
|
|
if message.get("cache_control", None) is not None:
|
|
return True
|
|
_message_content = message.get("content")
|
|
if _message_content is not None and isinstance(_message_content, list):
|
|
for content in _message_content:
|
|
if "cache_control" in content:
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_file_id_used(self, messages: List[AllMessageValues]) -> bool:
|
|
"""
|
|
Return if {"source": {"type": "file", "file_id": ..}} in message content block
|
|
"""
|
|
file_ids = get_file_ids_from_messages(messages)
|
|
return len(file_ids) > 0
|
|
|
|
def is_mcp_server_used(
|
|
self, mcp_servers: Optional[List[AnthropicMcpServerTool]]
|
|
) -> bool:
|
|
if mcp_servers is None:
|
|
return False
|
|
if mcp_servers:
|
|
return True
|
|
return False
|
|
|
|
def is_computer_tool_used(
|
|
self, tools: Optional[List[AllAnthropicToolsValues]]
|
|
) -> Optional[str]:
|
|
"""Returns the computer tool version if used, e.g. 'computer_20250124' or None"""
|
|
if tools is None:
|
|
return None
|
|
for tool in tools:
|
|
if "type" in tool and tool["type"].startswith("computer_"):
|
|
return tool["type"]
|
|
return None
|
|
|
|
def is_web_search_tool_used(
|
|
self, tools: Optional[List[AllAnthropicToolsValues]]
|
|
) -> bool:
|
|
"""Returns True if web_search tool is used"""
|
|
if tools is None:
|
|
return False
|
|
for tool in tools:
|
|
if "type" in tool and tool["type"].startswith(
|
|
ANTHROPIC_HOSTED_TOOLS.WEB_SEARCH.value
|
|
):
|
|
return True
|
|
return False
|
|
|
|
def is_pdf_used(self, messages: List[AllMessageValues]) -> bool:
|
|
"""
|
|
Set to true if media passed into messages.
|
|
|
|
"""
|
|
for message in messages:
|
|
if (
|
|
"content" in message
|
|
and message["content"] is not None
|
|
and isinstance(message["content"], list)
|
|
):
|
|
for content in message["content"]:
|
|
if "type" in content and content["type"] != "text":
|
|
return True
|
|
return False
|
|
|
|
def is_tool_search_used(self, tools: Optional[List]) -> bool:
|
|
"""
|
|
Check if tool search tools are present in the tools list.
|
|
"""
|
|
if not tools:
|
|
return False
|
|
|
|
for tool in tools:
|
|
tool_type = tool.get("type", "")
|
|
if tool_type in [
|
|
"tool_search_tool_regex_20251119",
|
|
"tool_search_tool_bm25_20251119",
|
|
]:
|
|
return True
|
|
return False
|
|
|
|
def is_programmatic_tool_calling_used(self, tools: Optional[List]) -> bool:
|
|
"""
|
|
Check if programmatic tool calling is being used (tools with allowed_callers field).
|
|
|
|
Returns True if any tool has allowed_callers containing 'code_execution_20250825'.
|
|
"""
|
|
if not tools:
|
|
return False
|
|
|
|
for tool in tools:
|
|
# Check top-level allowed_callers
|
|
allowed_callers = tool.get("allowed_callers", None)
|
|
if allowed_callers and isinstance(allowed_callers, list):
|
|
if "code_execution_20250825" in allowed_callers:
|
|
return True
|
|
|
|
# Check function.allowed_callers for OpenAI format tools
|
|
function = tool.get("function", {})
|
|
if isinstance(function, dict):
|
|
function_allowed_callers = function.get("allowed_callers", None)
|
|
if function_allowed_callers and isinstance(
|
|
function_allowed_callers, list
|
|
):
|
|
if "code_execution_20250825" in function_allowed_callers:
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_input_examples_used(self, tools: Optional[List]) -> bool:
|
|
"""
|
|
Check if input_examples is being used in any tools.
|
|
|
|
Returns True if any tool has input_examples field.
|
|
"""
|
|
if not tools:
|
|
return False
|
|
|
|
for tool in tools:
|
|
# Check top-level input_examples
|
|
input_examples = tool.get("input_examples", None)
|
|
if (
|
|
input_examples
|
|
and isinstance(input_examples, list)
|
|
and len(input_examples) > 0
|
|
):
|
|
return True
|
|
|
|
# Check function.input_examples for OpenAI format tools
|
|
function = tool.get("function", {})
|
|
if isinstance(function, dict):
|
|
function_input_examples = function.get("input_examples", None)
|
|
if (
|
|
function_input_examples
|
|
and isinstance(function_input_examples, list)
|
|
and len(function_input_examples) > 0
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
@staticmethod
|
|
def _is_claude_4_6_model(model: str) -> bool:
|
|
"""Check if the model is a Claude 4.6 model (Opus 4.6 or Sonnet 4.6)."""
|
|
model_lower = model.lower()
|
|
return any(
|
|
v in model_lower
|
|
for v in (
|
|
"opus-4-6",
|
|
"opus_4_6",
|
|
"opus-4.6",
|
|
"opus_4.6",
|
|
"sonnet-4-6",
|
|
"sonnet_4_6",
|
|
"sonnet-4.6",
|
|
"sonnet_4.6",
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_claude_4_7_model(model: str) -> bool:
|
|
"""Check if the model is a Claude 4.7 model (Opus 4.7)."""
|
|
model_lower = model.lower()
|
|
return any(
|
|
v in model_lower
|
|
for v in (
|
|
"opus-4-7",
|
|
"opus_4_7",
|
|
"opus-4.7",
|
|
"opus_4.7",
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _supports_sampling_params(model: str) -> bool:
|
|
"""Claude 4.7+ (Opus 4.7/4.8, Fable 5) removed sampling params: the API
|
|
rejects ``top_p``, ``top_k``, and any ``temperature`` other than 1 with
|
|
a 400 ("`temperature` is deprecated for this model").
|
|
|
|
Driven by the ``supports_sampling_params`` flag in the model map; the
|
|
name check remains only as a fallback for provider-routed ids whose
|
|
map entries predate the flag."""
|
|
flag = AnthropicModelInfo._get_model_capability(
|
|
model, "supports_sampling_params"
|
|
)
|
|
if flag is not None:
|
|
return flag
|
|
model_lower = model.lower()
|
|
return not any(
|
|
v in model_lower
|
|
for v in (
|
|
"fable",
|
|
"opus-4-7",
|
|
"opus_4_7",
|
|
"opus-4.7",
|
|
"opus_4.7",
|
|
"opus-4-8",
|
|
"opus_4_8",
|
|
"opus-4.8",
|
|
"opus_4.8",
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _apply_sampling_param(
|
|
optional_params: dict,
|
|
model: str,
|
|
param: str,
|
|
value: Any,
|
|
drop_params: bool,
|
|
output_key: str,
|
|
) -> None:
|
|
"""Forward ``temperature``/``top_p``/``top_k`` to
|
|
``optional_params[output_key]`` unless the model removed sampling
|
|
params, in which case drop the param (with drop_params) or raise a
|
|
clean client-side 400."""
|
|
if AnthropicModelInfo._supports_sampling_params(model) or (
|
|
param == "temperature" and value == 1
|
|
):
|
|
optional_params[output_key] = value
|
|
elif not (litellm.drop_params or drop_params):
|
|
supported_hint = (
|
|
"Only temperature=1 is supported. " if param == "temperature" else ""
|
|
)
|
|
raise litellm.utils.UnsupportedParamsError(
|
|
message=(
|
|
f"{model} does not support {param}={value}. {supported_hint}"
|
|
"To drop unsupported params, set `litellm.drop_params = True`."
|
|
),
|
|
status_code=400,
|
|
)
|
|
|
|
@staticmethod
|
|
def _model_map_lookup_candidates(model: str) -> List[str]:
|
|
"""Model-map keys to try for ``model``, stripping bedrock/vertex
|
|
prefixes so a provider-routed Claude still resolves to its entry."""
|
|
candidates = [model]
|
|
for prefix in (
|
|
"bedrock/converse/",
|
|
"bedrock/invoke/",
|
|
"bedrock/",
|
|
"vertex_ai/",
|
|
):
|
|
if model.startswith(prefix):
|
|
candidates.append(model[len(prefix) :])
|
|
try:
|
|
from litellm.llms.bedrock.common_utils import BedrockModelInfo
|
|
|
|
base = BedrockModelInfo.get_base_model(model)
|
|
if base:
|
|
candidates.append(base)
|
|
candidates.append(f"bedrock/{base}")
|
|
except Exception:
|
|
pass
|
|
return candidates
|
|
|
|
@staticmethod
|
|
def _get_model_capability(model: str, key: str) -> Optional[bool]:
|
|
"""Read boolean capability ``key`` from the model map, or None when
|
|
no entry declares it."""
|
|
try:
|
|
for cand in AnthropicModelInfo._model_map_lookup_candidates(model):
|
|
value = litellm.model_cost.get(cand, {}).get(key)
|
|
if isinstance(value, bool):
|
|
return value
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
@staticmethod
|
|
def _supports_model_capability(model: str, key: str) -> bool:
|
|
"""Check a boolean capability ``key`` in the model map.
|
|
|
|
Strips bedrock/vertex prefixes so a provider-routed Claude still
|
|
resolves to the Anthropic model-map entry.
|
|
"""
|
|
from litellm.utils import _supports_factory
|
|
|
|
try:
|
|
if _supports_factory(
|
|
model=model,
|
|
custom_llm_provider="anthropic",
|
|
key=key,
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return AnthropicModelInfo._get_model_capability(model, key) is True
|
|
|
|
@staticmethod
|
|
def _is_adaptive_thinking_model(model: str) -> bool:
|
|
"""Claude 4.6+ models use adaptive thinking with ``output_config.effort``.
|
|
|
|
Driven by the ``supports_adaptive_thinking`` flag in the model map; the
|
|
4.6/4.7 name checks remain only as a fallback for provider-routed ids
|
|
whose map entries predate the flag.
|
|
"""
|
|
if AnthropicModelInfo._supports_model_capability(
|
|
model, "supports_adaptive_thinking"
|
|
):
|
|
return True
|
|
return AnthropicModelInfo._is_claude_4_6_model(
|
|
model
|
|
) or AnthropicModelInfo._is_claude_4_7_model(model)
|
|
|
|
def is_effort_used(
|
|
self, optional_params: Optional[dict], model: Optional[str] = None
|
|
) -> bool:
|
|
"""
|
|
Check if effort parameter is being used and requires a beta header.
|
|
|
|
Returns True if effort-related parameters are present and
|
|
the model requires the effort beta header. Claude 4.6+ models
|
|
use output_config as a stable API feature — no beta header needed.
|
|
"""
|
|
if not optional_params:
|
|
return False
|
|
|
|
# Claude 4.6+ models use output_config as a stable API feature — no beta header needed
|
|
if model and self._is_adaptive_thinking_model(model):
|
|
return False
|
|
|
|
# Check if reasoning_effort is provided for Claude Opus 4.5
|
|
if model and ("opus-4-5" in model.lower() or "opus_4_5" in model.lower()):
|
|
reasoning_effort = optional_params.get("reasoning_effort")
|
|
if reasoning_effort and isinstance(reasoning_effort, str):
|
|
return True
|
|
|
|
# Check if output_config is directly provided (for non-4.6 models)
|
|
output_config = optional_params.get("output_config")
|
|
if output_config and isinstance(output_config, dict):
|
|
effort = output_config.get("effort")
|
|
if effort and isinstance(effort, str):
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_code_execution_tool_used(self, tools: Optional[List]) -> bool:
|
|
"""
|
|
Check if code execution tool is being used.
|
|
|
|
Returns True if any tool has type "code_execution_20250825".
|
|
"""
|
|
if not tools:
|
|
return False
|
|
|
|
for tool in tools:
|
|
tool_type = tool.get("type", "")
|
|
if tool_type == "code_execution_20250825":
|
|
return True
|
|
return False
|
|
|
|
def is_container_with_skills_used(self, optional_params: Optional[dict]) -> bool:
|
|
"""
|
|
Check if container with skills is being used.
|
|
|
|
Returns True if optional_params contains container with skills.
|
|
"""
|
|
if not optional_params:
|
|
return False
|
|
|
|
container = optional_params.get("container")
|
|
if container and isinstance(container, dict):
|
|
skills = container.get("skills")
|
|
if skills and isinstance(skills, list) and len(skills) > 0:
|
|
return True
|
|
return False
|
|
|
|
def _get_user_anthropic_beta_headers(
|
|
self, anthropic_beta_header: Optional[str]
|
|
) -> Optional[List[str]]:
|
|
if anthropic_beta_header is None:
|
|
return None
|
|
return anthropic_beta_header.split(",")
|
|
|
|
def get_computer_tool_beta_header(self, computer_tool_version: str) -> str:
|
|
"""
|
|
Get the appropriate beta header for a given computer tool version.
|
|
|
|
Args:
|
|
computer_tool_version: The computer tool version (e.g., 'computer_20250124', 'computer_20241022')
|
|
|
|
Returns:
|
|
The corresponding beta header string
|
|
"""
|
|
computer_tool_beta_mapping = {
|
|
"computer_20250124": "computer-use-2025-01-24",
|
|
"computer_20241022": "computer-use-2024-10-22",
|
|
}
|
|
return computer_tool_beta_mapping.get(
|
|
computer_tool_version, "computer-use-2024-10-22" # Default fallback
|
|
)
|
|
|
|
def get_anthropic_beta_list(
|
|
self,
|
|
model: str,
|
|
optional_params: Optional[dict] = None,
|
|
computer_tool_used: Optional[str] = None,
|
|
prompt_caching_set: bool = False,
|
|
file_id_used: bool = False,
|
|
mcp_server_used: bool = False,
|
|
) -> List[str]:
|
|
"""
|
|
Get list of common beta headers based on the features that are active.
|
|
|
|
Returns:
|
|
List of beta header strings
|
|
"""
|
|
from litellm.types.llms.anthropic import ANTHROPIC_EFFORT_BETA_HEADER
|
|
|
|
betas = []
|
|
|
|
# Detect features
|
|
effort_used = self.is_effort_used(optional_params, model)
|
|
|
|
if effort_used:
|
|
betas.append(ANTHROPIC_EFFORT_BETA_HEADER) # effort-2025-11-24
|
|
|
|
if computer_tool_used:
|
|
beta_header = self.get_computer_tool_beta_header(computer_tool_used)
|
|
betas.append(beta_header)
|
|
|
|
# Anthropic no longer requires the prompt-caching beta header
|
|
# Prompt caching now works automatically when cache_control is used in messages
|
|
# Reference: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
|
|
|
if file_id_used:
|
|
betas.append("files-api-2025-04-14")
|
|
betas.append("code-execution-2025-05-22")
|
|
|
|
if mcp_server_used:
|
|
betas.append("mcp-client-2025-04-04")
|
|
|
|
return list(set(betas))
|
|
|
|
def get_anthropic_headers(
|
|
self,
|
|
api_key: Optional[str] = None,
|
|
auth_token: Optional[str] = None,
|
|
anthropic_version: Optional[str] = None,
|
|
computer_tool_used: Optional[str] = None,
|
|
prompt_caching_set: bool = False,
|
|
pdf_used: bool = False,
|
|
file_id_used: bool = False,
|
|
mcp_server_used: bool = False,
|
|
web_search_tool_used: bool = False,
|
|
tool_search_used: bool = False,
|
|
programmatic_tool_calling_used: bool = False,
|
|
input_examples_used: bool = False,
|
|
effort_used: bool = False,
|
|
is_vertex_request: bool = False,
|
|
user_anthropic_beta_headers: Optional[List[str]] = None,
|
|
code_execution_tool_used: bool = False,
|
|
container_with_skills_used: bool = False,
|
|
) -> dict:
|
|
betas = set()
|
|
# Anthropic no longer requires the prompt-caching beta header
|
|
# Prompt caching now works automatically when cache_control is used in messages
|
|
# Reference: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
|
if computer_tool_used:
|
|
beta_header = self.get_computer_tool_beta_header(computer_tool_used)
|
|
betas.add(beta_header)
|
|
# if pdf_used:
|
|
# betas.add("pdfs-2024-09-25")
|
|
if file_id_used:
|
|
betas.add("files-api-2025-04-14")
|
|
betas.add("code-execution-2025-05-22")
|
|
if mcp_server_used:
|
|
betas.add("mcp-client-2025-04-04")
|
|
# Tool search, programmatic tool calling, and input_examples all use the same beta header
|
|
if tool_search_used or programmatic_tool_calling_used or input_examples_used:
|
|
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
|
|
|
|
betas.add(ANTHROPIC_TOOL_SEARCH_BETA_HEADER)
|
|
|
|
# Effort parameter uses a separate beta header
|
|
if effort_used:
|
|
from litellm.types.llms.anthropic import ANTHROPIC_EFFORT_BETA_HEADER
|
|
|
|
betas.add(ANTHROPIC_EFFORT_BETA_HEADER)
|
|
|
|
# Code execution tool uses a separate beta header
|
|
if code_execution_tool_used:
|
|
betas.add("code-execution-2025-08-25")
|
|
|
|
# Container with skills uses a separate beta header
|
|
if container_with_skills_used:
|
|
betas.add("skills-2025-10-02")
|
|
|
|
_is_oauth = api_key and api_key.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
|
|
headers = {
|
|
"anthropic-version": anthropic_version or "2023-06-01",
|
|
"accept": "application/json",
|
|
"content-type": "application/json",
|
|
}
|
|
if _is_oauth:
|
|
headers["authorization"] = f"Bearer {api_key}"
|
|
headers["anthropic-dangerous-direct-browser-access"] = "true"
|
|
betas.add(ANTHROPIC_OAUTH_BETA_HEADER)
|
|
elif auth_token and not api_key:
|
|
headers["authorization"] = f"Bearer {auth_token}"
|
|
elif api_key:
|
|
headers["x-api-key"] = api_key
|
|
|
|
if user_anthropic_beta_headers is not None:
|
|
betas.update(user_anthropic_beta_headers)
|
|
|
|
# Don't send any beta headers to Vertex, except web search which is required
|
|
if is_vertex_request is True:
|
|
# Vertex AI requires web search beta header for web search to work
|
|
if web_search_tool_used:
|
|
from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES
|
|
|
|
headers["anthropic-beta"] = (
|
|
ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value
|
|
)
|
|
elif len(betas) > 0:
|
|
headers["anthropic-beta"] = ",".join(betas)
|
|
|
|
return headers
|
|
|
|
def validate_environment(
|
|
self,
|
|
headers: dict,
|
|
model: str,
|
|
messages: List[AllMessageValues],
|
|
optional_params: dict,
|
|
litellm_params: dict,
|
|
api_key: Optional[str] = None,
|
|
api_base: Optional[str] = None,
|
|
) -> Dict:
|
|
# Check for Anthropic OAuth token in headers
|
|
headers, api_key = optionally_handle_anthropic_oauth(
|
|
headers=headers, api_key=api_key
|
|
)
|
|
api_key = AnthropicModelInfo.get_api_key(api_key)
|
|
# Resolve auth_token from ANTHROPIC_AUTH_TOKEN if api_key is not set
|
|
auth_token: Optional[str] = None
|
|
if api_key is None:
|
|
auth_token = AnthropicModelInfo.get_auth_token()
|
|
if api_key is None and auth_token is None:
|
|
raise litellm.AuthenticationError(
|
|
message="Missing Anthropic API Key - A call is being made to anthropic but no key is set either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` in your environment vars",
|
|
llm_provider="anthropic",
|
|
model=model,
|
|
)
|
|
|
|
tools = optional_params.get("tools")
|
|
prompt_caching_set = self.is_cache_control_set(messages=messages)
|
|
computer_tool_used = self.is_computer_tool_used(tools=tools)
|
|
mcp_server_used = self.is_mcp_server_used(
|
|
mcp_servers=optional_params.get("mcp_servers")
|
|
)
|
|
pdf_used = self.is_pdf_used(messages=messages)
|
|
file_id_used = self.is_file_id_used(messages=messages)
|
|
web_search_tool_used = self.is_web_search_tool_used(tools=tools)
|
|
tool_search_used = self.is_tool_search_used(tools=tools)
|
|
programmatic_tool_calling_used = self.is_programmatic_tool_calling_used(
|
|
tools=tools
|
|
)
|
|
input_examples_used = self.is_input_examples_used(tools=tools)
|
|
effort_used = self.is_effort_used(optional_params=optional_params, model=model)
|
|
code_execution_tool_used = self.is_code_execution_tool_used(tools=tools)
|
|
container_with_skills_used = self.is_container_with_skills_used(
|
|
optional_params=optional_params
|
|
)
|
|
user_anthropic_beta_headers = self._get_user_anthropic_beta_headers(
|
|
anthropic_beta_header=headers.get("anthropic-beta")
|
|
)
|
|
anthropic_headers = self.get_anthropic_headers(
|
|
computer_tool_used=computer_tool_used,
|
|
prompt_caching_set=prompt_caching_set,
|
|
pdf_used=pdf_used,
|
|
api_key=api_key,
|
|
auth_token=auth_token,
|
|
file_id_used=file_id_used,
|
|
web_search_tool_used=web_search_tool_used,
|
|
is_vertex_request=optional_params.get("is_vertex_request", False),
|
|
user_anthropic_beta_headers=user_anthropic_beta_headers,
|
|
mcp_server_used=mcp_server_used,
|
|
tool_search_used=tool_search_used,
|
|
programmatic_tool_calling_used=programmatic_tool_calling_used,
|
|
input_examples_used=input_examples_used,
|
|
effort_used=effort_used,
|
|
code_execution_tool_used=code_execution_tool_used,
|
|
container_with_skills_used=container_with_skills_used,
|
|
)
|
|
|
|
headers = {**headers, **anthropic_headers}
|
|
|
|
return headers
|
|
|
|
@staticmethod
|
|
def get_api_base(api_base: Optional[str] = None) -> Optional[str]:
|
|
from litellm.secret_managers.main import get_secret_str
|
|
|
|
return (
|
|
api_base
|
|
or get_secret_str("ANTHROPIC_API_BASE")
|
|
or get_secret_str("ANTHROPIC_BASE_URL")
|
|
or "https://api.anthropic.com"
|
|
)
|
|
|
|
@staticmethod
|
|
def get_api_key(api_key: Optional[str] = None) -> Optional[str]:
|
|
from litellm.secret_managers.main import get_secret_str
|
|
|
|
return api_key or get_secret_str("ANTHROPIC_API_KEY")
|
|
|
|
@staticmethod
|
|
def get_auth_token(auth_token: Optional[str] = None) -> Optional[str]:
|
|
"""Get auth token from ANTHROPIC_AUTH_TOKEN env var.
|
|
|
|
Unlike api_key (which uses X-Api-Key header), auth_token uses
|
|
Authorization: Bearer header, matching the official Anthropic SDK behavior.
|
|
"""
|
|
from litellm.secret_managers.main import get_secret_str
|
|
|
|
return auth_token or get_secret_str("ANTHROPIC_AUTH_TOKEN")
|
|
|
|
@staticmethod
|
|
def get_auth_header(api_key: Optional[str] = None) -> Optional[dict]:
|
|
"""Resolve Anthropic credentials and return the appropriate auth header dict.
|
|
|
|
Checks ANTHROPIC_API_KEY first (-> x-api-key), then
|
|
ANTHROPIC_AUTH_TOKEN (-> Authorization: Bearer).
|
|
Returns None if neither is available.
|
|
"""
|
|
resolved_key = AnthropicModelInfo.get_api_key(api_key)
|
|
if resolved_key is not None:
|
|
if is_anthropic_oauth_key(resolved_key):
|
|
return {"authorization": f"Bearer {resolved_key}"}
|
|
return {"x-api-key": resolved_key}
|
|
auth_token = AnthropicModelInfo.get_auth_token()
|
|
if auth_token is not None:
|
|
return {"authorization": f"Bearer {auth_token}"}
|
|
return None
|
|
|
|
@staticmethod
|
|
def get_base_model(model: Optional[str] = None) -> Optional[str]:
|
|
return model.replace("anthropic/", "") if model else None
|
|
|
|
def get_models(
|
|
self, api_key: Optional[str] = None, api_base: Optional[str] = None
|
|
) -> List[str]:
|
|
api_base = AnthropicModelInfo.get_api_base(api_base)
|
|
auth_header = AnthropicModelInfo.get_auth_header(api_key)
|
|
if api_base is None or auth_header is None:
|
|
raise ValueError(
|
|
"ANTHROPIC_API_BASE/ANTHROPIC_BASE_URL or ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN is not set. Please set the environment variable, to query Anthropic's `/models` endpoint."
|
|
)
|
|
headers = {"anthropic-version": "2023-06-01"}
|
|
headers.update(auth_header)
|
|
response = litellm.module_level_client.get(
|
|
url=f"{api_base}/v1/models",
|
|
headers=headers,
|
|
)
|
|
|
|
try:
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError:
|
|
raise Exception(
|
|
f"Failed to fetch models from Anthropic. Status code: {response.status_code}, Response: {response.text}"
|
|
)
|
|
|
|
models = response.json()["data"]
|
|
|
|
litellm_model_names = []
|
|
for model in models:
|
|
stripped_model_name = model["id"]
|
|
litellm_model_name = "anthropic/" + stripped_model_name
|
|
litellm_model_names.append(litellm_model_name)
|
|
return litellm_model_names
|
|
|
|
def get_token_counter(self) -> Optional[BaseTokenCounter]:
|
|
"""
|
|
Factory method to create an Anthropic token counter.
|
|
|
|
Returns:
|
|
AnthropicTokenCounter instance for this provider.
|
|
"""
|
|
from litellm.llms.anthropic.count_tokens.token_counter import (
|
|
AnthropicTokenCounter,
|
|
)
|
|
|
|
return AnthropicTokenCounter()
|
|
|
|
|
|
def strip_advisor_blocks_from_messages(
|
|
messages: List[Any], replace_with_text: bool = False
|
|
) -> List[Any]:
|
|
"""
|
|
Remove (or replace) server_tool_use (name='advisor') and advisor_tool_result blocks
|
|
from assistant message content.
|
|
|
|
Prevents Anthropic 400 invalid_request_error: if advisor_tool_result blocks
|
|
exist in history but the advisor tool is not in the tools array, the API rejects
|
|
the request. This happens when the user has removed the advisor tool for cost
|
|
control or on a follow-up turn.
|
|
|
|
Args:
|
|
messages: Conversation history to process (mutated in-place).
|
|
replace_with_text: When True, replace the advisor exchange with an
|
|
<advisor_feedback> text block so the executor retains the semantic
|
|
context of what the advisor said. When False (default), strip silently.
|
|
"""
|
|
for message in messages:
|
|
if not isinstance(message, dict) or message.get("role") != "assistant":
|
|
continue
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
continue
|
|
|
|
# Collect advisor server_tool_use ids and their advice text (for replace mode).
|
|
advisor_id_to_text: dict = {}
|
|
for block in content:
|
|
if (
|
|
isinstance(block, dict)
|
|
and block.get("type") == "server_tool_use"
|
|
and block.get("name") == "advisor"
|
|
):
|
|
bid = block.get("id")
|
|
if bid:
|
|
advisor_id_to_text[bid] = None # text filled in below
|
|
|
|
if not advisor_id_to_text:
|
|
continue
|
|
|
|
# If replacing, collect the advisor response text from advisor_tool_result blocks.
|
|
if replace_with_text:
|
|
for block in content:
|
|
if (
|
|
isinstance(block, dict)
|
|
and block.get("type") == "advisor_tool_result"
|
|
and block.get("tool_use_id") in advisor_id_to_text
|
|
):
|
|
raw = block.get("content") or ""
|
|
text = (
|
|
raw
|
|
if isinstance(raw, str)
|
|
else next(
|
|
(
|
|
b.get("text", "")
|
|
for b in raw
|
|
if isinstance(b, dict) and b.get("type") == "text"
|
|
),
|
|
"",
|
|
)
|
|
)
|
|
advisor_id_to_text[block["tool_use_id"]] = text
|
|
|
|
new_content = []
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
new_content.append(block)
|
|
continue
|
|
is_advisor_use = (
|
|
block.get("type") == "server_tool_use"
|
|
and block.get("name") == "advisor"
|
|
and block.get("id") in advisor_id_to_text
|
|
)
|
|
is_advisor_result = (
|
|
block.get("type") == "advisor_tool_result"
|
|
and block.get("tool_use_id") in advisor_id_to_text
|
|
)
|
|
if is_advisor_use:
|
|
if replace_with_text:
|
|
advice = advisor_id_to_text.get(block.get("id")) or ""
|
|
if advice:
|
|
new_content.append(
|
|
{
|
|
"type": "text",
|
|
"text": f"<advisor_feedback>\n{advice}\n</advisor_feedback>",
|
|
}
|
|
)
|
|
# else: drop silently
|
|
elif is_advisor_result:
|
|
pass # always drop — replaced above (or stripped)
|
|
else:
|
|
new_content.append(block)
|
|
|
|
message["content"] = new_content
|
|
return messages
|
|
|
|
|
|
def is_anthropic_invalid_thinking_signature_error(error_text: str) -> bool:
|
|
"""
|
|
Detect Anthropic 400 when encrypted thinking signatures in history do not match
|
|
the current deployment (e.g. user rotated API key or switched model endpoint).
|
|
|
|
Example API message:
|
|
messages.N.content.M: Invalid `signature` in `thinking` block
|
|
"""
|
|
if not error_text:
|
|
return False
|
|
lower = error_text.lower()
|
|
return (
|
|
"invalid" in lower
|
|
and "signature" in lower
|
|
and "thinking" in lower
|
|
and "block" in lower
|
|
)
|
|
|
|
|
|
def strip_thinking_blocks_from_anthropic_messages(messages: List[Any]) -> List[Any]:
|
|
"""
|
|
Return a new message list with thinking / redacted_thinking content blocks removed
|
|
from each message. Used to recover from invalid thinking signatures on retry.
|
|
|
|
Messages whose content is a list and becomes empty after stripping are omitted,
|
|
since Anthropic rejects empty content arrays.
|
|
"""
|
|
out: List[Any] = []
|
|
for m in messages:
|
|
if not isinstance(m, dict):
|
|
out.append(m)
|
|
continue
|
|
mm = copy.deepcopy(m)
|
|
content = mm.get("content")
|
|
if isinstance(content, list):
|
|
filtered = [
|
|
b
|
|
for b in content
|
|
if not (
|
|
isinstance(b, dict)
|
|
and b.get("type") in ("thinking", "redacted_thinking")
|
|
)
|
|
]
|
|
if not filtered:
|
|
continue
|
|
mm["content"] = filtered
|
|
out.append(mm)
|
|
return out
|
|
|
|
|
|
def strip_thinking_blocks_from_anthropic_messages_request_dict(
|
|
data: Dict[str, Any],
|
|
) -> None:
|
|
"""
|
|
Mutate an Anthropic Messages-style request dict: strip thinking blocks from
|
|
``messages`` and remove the top-level ``thinking`` extended-thinking param.
|
|
"""
|
|
msgs = data.get("messages")
|
|
if isinstance(msgs, list):
|
|
data["messages"] = strip_thinking_blocks_from_anthropic_messages(msgs)
|
|
data.pop("thinking", None)
|
|
|
|
|
|
def strip_empty_text_blocks_from_anthropic_messages(
|
|
messages: List[Any],
|
|
) -> List[Any]:
|
|
"""
|
|
Return a new message list with empty or whitespace-only ``{"type": "text"}``
|
|
content blocks removed.
|
|
|
|
Anthropic's API rejects requests containing such blocks with
|
|
``"messages: text content blocks must be non-empty"``, but assistant
|
|
messages from Anthropic routinely arrive with ``{"type": "text", "text": ""}``
|
|
alongside ``tool_use`` blocks (see anthropics/anthropic-sdk-python#461).
|
|
Multi-turn tool-use clients (e.g. Claude Code) loop these prior responses
|
|
back as conversation history, which then causes the next request to 400
|
|
on the unified ``/v1/messages`` path. ``/v1/chat/completions`` already
|
|
handles this in ``anthropic_messages_pt``; this helper provides the
|
|
equivalent guarantee for the native Anthropic Messages path.
|
|
|
|
Messages whose content is a list and becomes empty after stripping are
|
|
omitted, matching :func:`strip_thinking_blocks_from_anthropic_messages`.
|
|
The caller's list and its content blocks are never mutated; modified
|
|
messages are returned as shallow copies with a fresh content list.
|
|
"""
|
|
out: List[Any] = []
|
|
for m in messages:
|
|
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
|
|
out.append(m)
|
|
continue
|
|
content = m["content"]
|
|
filtered = [b for b in content if not _is_empty_text_block(b)]
|
|
if len(filtered) == len(content):
|
|
out.append(m)
|
|
elif filtered:
|
|
out.append({**m, "content": filtered})
|
|
return out
|
|
|
|
|
|
def _is_empty_text_block(block: Any) -> bool:
|
|
if not isinstance(block, dict) or block.get("type") != "text":
|
|
return False
|
|
text = block.get("text")
|
|
return not isinstance(text, str) or not text.strip()
|
|
|
|
|
|
def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict:
|
|
openai_headers = {}
|
|
if "anthropic-ratelimit-requests-limit" in headers:
|
|
openai_headers["x-ratelimit-limit-requests"] = headers[
|
|
"anthropic-ratelimit-requests-limit"
|
|
]
|
|
if "anthropic-ratelimit-requests-remaining" in headers:
|
|
openai_headers["x-ratelimit-remaining-requests"] = headers[
|
|
"anthropic-ratelimit-requests-remaining"
|
|
]
|
|
if "anthropic-ratelimit-tokens-limit" in headers:
|
|
openai_headers["x-ratelimit-limit-tokens"] = headers[
|
|
"anthropic-ratelimit-tokens-limit"
|
|
]
|
|
if "anthropic-ratelimit-tokens-remaining" in headers:
|
|
openai_headers["x-ratelimit-remaining-tokens"] = headers[
|
|
"anthropic-ratelimit-tokens-remaining"
|
|
]
|
|
|
|
llm_response_headers = {
|
|
"{}-{}".format("llm_provider", k): v for k, v in headers.items()
|
|
}
|
|
|
|
additional_headers = {**llm_response_headers, **openai_headers}
|
|
return additional_headers
|