Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,15 @@ async def _messages_count_tokens(
# standalone function to make it easier to override
tools, tool_choice = self._prepare_tools_and_tool_choice(model_settings, model_request_parameters)
tools, mcp_servers, native_tool_betas = self._add_native_tools(tools, model_request_parameters, model_settings)
# The count_tokens endpoint rejects server-side tools (code execution, web search, web fetch).
# Strip them from the request; their token contribution is negligible compared to message content.
tools = [
t for t in tools
if not any(
cast(str, t.get('type', '')).startswith(prefix)
for prefix in _COUNT_TOKENS_UNSUPPORTED_TOOL_TYPE_PREFIXES
)
]

auto_cache_control, resolved_cache_ttl = self._build_automatic_cache_control(model_settings)
system_prompt, anthropic_messages = await self._map_message(messages, model_request_parameters, model_settings)
Expand Down Expand Up @@ -2436,6 +2445,15 @@ def _build_tool_search_replay_block(
'web_fetch': WebFetchTool.kind,
}

# Tool `type` prefixes that the Anthropic count_tokens endpoint rejects.
# These are server-executed tools (code execution, web search, web fetch) that the API
# runs on its side; the count_tokens endpoint only accepts client-side/function tools.
_COUNT_TOKENS_UNSUPPORTED_TOOL_TYPE_PREFIXES: tuple[str, ...] = (
'code_execution_',
'web_search_',
'web_fetch_',
)


def _anthropic_code_execution_tool_provider_details(
tool_name: _AnthropicCodeExecutionProviderDetailToolName,
Expand Down
25 changes: 25 additions & 0 deletions tests/models/test_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10524,6 +10524,31 @@ async def test_anthropic_bedrock_count_tokens_not_supported(env: TestEnv):
await agent.run('hello', usage_limits=UsageLimits(input_tokens_limit=20, count_tokens_before_request=True))


async def test_count_tokens_strips_server_tools(allow_model_requests: None) -> None:
"""count_tokens must not forward server-side tools (CodeExecutionTool, WebSearchTool, WebFetchTool)
to the Anthropic count_tokens endpoint, which rejects them with a 400 error."""
mock_client = cast(AsyncAnthropic, MockAnthropic())
m = AnthropicModel('claude-opus-4-5', provider=AnthropicProvider(anthropic_client=mock_client))

# Call count_tokens with each server-side native tool to verify they are stripped.
for native_tool in (CodeExecutionTool(), WebSearchTool(), WebFetchTool()):
cast(MockAnthropic, mock_client).chat_completion_kwargs.clear()
await m.count_tokens(
[ModelRequest.user_text_prompt('hello')],
None,
ModelRequestParameters(native_tools=[native_tool]),
)
kwargs = cast(MockAnthropic, mock_client).chat_completion_kwargs[0]
# The tools kwarg must either be absent (OMIT/not present) or contain no server tool entries.
raw_tools = kwargs.get('tools')
tools_sent: list[Any] = raw_tools if isinstance(raw_tools, list) else []
server_tool_types = {t.get('type', '') for t in tools_sent}
assert not any(
typ.startswith(('code_execution_', 'web_search_', 'web_fetch_'))
for typ in server_tool_types
), f'Server tool leaked into count_tokens payload for {native_tool!r}: {tools_sent}'


@pytest.mark.vcr()
async def test_anthropic_cache_real_api(allow_model_requests: None, anthropic_api_key: str):
"""Test that anthropic_cache passes top-level cache_control and produces cache usage.
Expand Down
Loading