Skip to content
Merged
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
13 changes: 13 additions & 0 deletions lib/crewai/src/crewai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,19 @@ def _infer_provider_from_model(cls, model: str) -> str:
if model in AZURE_MODELS:
return "azure"

# Bedrock namespaces Anthropic models as "anthropic.claude-*", optionally
# region-prefixed ("us.anthropic.claude-*"). That form also satisfies the
# anthropic pattern below, so it has to be settled first.
if "anthropic." in model.lower():
return "bedrock"

# Only anthropic and gemini have prefixes unambiguous enough to infer from.
# Bedrock matches any model containing a dot (so "gpt-3.5-turbo") and Azure
# matches every OpenAI prefix, so both would steal models from openai here.
for provider in ("anthropic", "gemini"):
if cls._matches_provider_pattern(model, provider):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return provider

return "openai"

@classmethod
Expand Down
16 changes: 13 additions & 3 deletions lib/crewai/src/crewai/llms/providers/anthropic/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ def _default_max_tokens_for_model(model: str) -> int:

NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
tuple[
Literal["claude-fable-5"],
Literal["claude-opus-5"],
Literal["claude-sonnet-5"],
Literal["claude-opus-4-8"],
Literal["claude-opus-4.8"],
Literal["claude-sonnet-4-5"],
Literal["claude-sonnet-4.5"],
Literal["claude-opus-4-5"],
Expand All @@ -89,6 +94,11 @@ def _default_max_tokens_for_model(model: str) -> int:
Literal["claude-haiku-4.5"],
]
] = (
"claude-fable-5",
"claude-opus-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4.8",
"claude-sonnet-4-5",
"claude-sonnet-4.5",
"claude-opus-4-5",
Expand All @@ -101,9 +111,9 @@ def _default_max_tokens_for_model(model: str) -> int:
def _supports_native_structured_outputs(model: str) -> bool:
"""Check if the model supports native structured outputs.

Native structured outputs are only available for Claude 4.5 models
(Sonnet 4.5, Opus 4.5, Haiku 4.5).
Other models require the tool-based fallback approach.
Covers Claude Fable 5, Opus 5, Sonnet 5, Opus 4.8 and the 4.5-era models
(Sonnet 4.5, Opus 4.5, Haiku 4.5). Other models require the tool-based
fallback approach.

Args:
model: The model name/identifier.
Expand Down
194 changes: 194 additions & 0 deletions lib/crewai/tests/llms/anthropic/test_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import types
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from pydantic import BaseModel

from crewai.llm import CONTEXT_WINDOW_USAGE_RATIO, LLM
from crewai.crew import Crew
Expand Down Expand Up @@ -1761,3 +1762,196 @@ def test_anthropic_missing_cache_fields_default_to_zero():
usage = llm._extract_anthropic_token_usage(mock_response)
assert usage["cached_prompt_tokens"] == 0
assert usage["cache_creation_tokens"] == 0


# --- Native structured outputs: model gate -----------------------------------

NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST = [
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-opus-4-8",
"claude-haiku-4-5",
]


class _Answer(BaseModel):
answer: str


_ANSWER_JSON = '{"answer": "42"}'

_WEATHER_TOOL = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}


def _structured_text_response():
"""A response whose single text block holds the structured JSON payload."""
from anthropic.types.beta import BetaTextBlock

mock_response = MagicMock()
mock_response.content = [
BetaTextBlock(type="text", text=_ANSWER_JSON, citations=None)
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
mock_response.stop_reason = "end_turn"
mock_response.id = "msg_structured"
return mock_response


def _structured_stream_events():
return [
types.SimpleNamespace(
type="content_block_delta",
index=0,
delta=types.SimpleNamespace(type="text_delta", text=_ANSWER_JSON),
)
]


@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
def test_native_structured_output_sync(model):
"""Current Claude models ask the API for JSON directly, not via a forced tool."""
from crewai.llms.providers.anthropic.completion import (
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
AnthropicCompletion,
)

llm = AnthropicCompletion(model=model)
mock_client = MagicMock()
mock_client.beta.messages.create.return_value = _structured_text_response()
llm._client = mock_client

result = llm.call("What is the answer?", response_model=_Answer)

assert result == _Answer(answer="42")
kwargs = mock_client.beta.messages.create.call_args.kwargs
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"
mock_client.messages.create.assert_not_called()


@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
@pytest.mark.asyncio
async def test_native_structured_output_async(model):
from crewai.llms.providers.anthropic.completion import (
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
AnthropicCompletion,
)

llm = AnthropicCompletion(model=model)
mock_client = MagicMock()
mock_client.beta.messages.create = AsyncMock(
return_value=_structured_text_response()
)
llm._async_client = mock_client

result = await llm.acall("What is the answer?", response_model=_Answer)

assert result == _Answer(answer="42")
kwargs = mock_client.beta.messages.create.call_args.kwargs
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"


@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
def test_native_structured_output_sync_streaming(model):
from crewai.llms.providers.anthropic.completion import (
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
AnthropicCompletion,
)

llm = AnthropicCompletion(model=model, stream=True)
mock_client = MagicMock()
mock_client.beta.messages.stream.return_value = _SyncAnthropicStream(
_structured_stream_events(), _structured_text_response()
)
llm._client = mock_client
llm._emit_stream_chunk_event = MagicMock()

result = llm.call("What is the answer?", response_model=_Answer)

assert result == _Answer(answer="42")
kwargs = mock_client.beta.messages.stream.call_args.kwargs
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"


@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
@pytest.mark.asyncio
async def test_native_structured_output_async_streaming(model):
from crewai.llms.providers.anthropic.completion import (
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
AnthropicCompletion,
)

llm = AnthropicCompletion(model=model, stream=True)
mock_client = MagicMock()
mock_client.beta.messages.stream.return_value = _AsyncAnthropicStream(
_structured_stream_events(), _structured_text_response()
)
llm._async_client = mock_client
llm._emit_stream_chunk_event = MagicMock()

result = await llm.acall("What is the answer?", response_model=_Answer)

assert result == _Answer(answer="42")
kwargs = mock_client.beta.messages.stream.call_args.kwargs
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"


def test_native_structured_output_keeps_caller_tools():
"""The native path leaves the caller's tools in place; the fallback replaces them."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion

llm = AnthropicCompletion(model="claude-opus-5")
mock_client = MagicMock()
mock_client.beta.messages.create.return_value = _structured_text_response()
llm._client = mock_client

llm.call("What is the answer?", tools=[_WEATHER_TOOL], response_model=_Answer)

sent_tools = mock_client.beta.messages.create.call_args.kwargs["tools"]
assert [tool["name"] for tool in sent_tools] == ["get_weather"]


def test_tool_fallback_still_used_for_models_without_native_support():
"""Models outside the supported set keep the forced-tool-call behavior."""
from crewai.llms.providers.anthropic.completion import AnthropicCompletion

llm = AnthropicCompletion(model="claude-3-5-haiku-20241022")
mock_response = MagicMock()
mock_response.content = [
{
"type": "tool_use",
"id": "toolu_1",
"name": "structured_output",
"input": {"answer": "42"},
}
]
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
mock_response.stop_reason = "tool_use"
mock_response.id = "msg_fallback"

mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
llm._client = mock_client

result = llm.call("What is the answer?", response_model=_Answer)

assert result == _Answer(answer="42")
kwargs = mock_client.messages.create.call_args.kwargs
assert kwargs["tool_choice"] == {"type": "tool", "name": "structured_output"}
assert "betas" not in kwargs
mock_client.beta.messages.create.assert_not_called()
65 changes: 65 additions & 0 deletions lib/crewai/tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,71 @@ def test_unprefixed_models_use_native_sdk():
assert llm3.provider == "gemini"


@pytest.mark.parametrize(
"model",
["claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-opus-4-8"],
)
def test_current_claude_models_route_to_anthropic(model):
"""Current Claude models are in the constants list and use the Anthropic SDK."""
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
llm = LLM(model=model, is_litellm=False)
assert llm.provider == "anthropic"


def test_claude_model_newer_than_constants_routes_to_anthropic():
"""A Claude release we have not listed yet must not fall through to OpenAI."""
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
llm = LLM(model="claude-opus-6-20990101", is_litellm=False)
assert llm.provider == "anthropic"


def test_gemini_model_newer_than_constants_routes_to_gemini():
with patch.dict(os.environ, {"GOOGLE_API_KEY": "test-key"}):
llm = LLM(model="gemini-9-pro-preview", is_litellm=False)
assert llm.provider == "gemini"


@pytest.mark.parametrize(
"model",
[
"anthropic.claude-opus-9-20990101-v1:0",
"us.anthropic.claude-opus-9-20990101-v1:0",
"eu.anthropic.claude-sonnet-9-20990101-v1:0",
],
)
def test_unlisted_bedrock_anthropic_ids_route_to_bedrock(model):
"""Bedrock names Anthropic models "anthropic.claude-*"; that is not the direct API."""
with patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_DEFAULT_REGION": "us-east-1",
},
):
llm = LLM(model=model, is_litellm=False)
assert llm.provider == "bedrock"


@pytest.mark.parametrize(
("model", "expected_provider"),
[
# Bedrock's pattern is `"." in model` and Azure's covers every OpenAI
# prefix, so these pin that pattern inference did not steal them.
("gpt-3.5-turbo", "openai"),
("gpt-4.1", "openai"),
("gpt-4o", "openai"),
("gpt-4o-mini", "openai"),
("o1", "openai"),
("some-unknown-model", "openai"),
],
)
def test_non_claude_models_keep_their_inferred_provider(model, expected_provider):
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
llm = LLM(model=model, is_litellm=False)
assert llm.provider == expected_provider


def test_explicit_provider_kwarg_takes_priority():
"""Test that explicit provider kwarg takes priority over model name inference."""
# Explicit provider=openai should use OpenAI even if model name suggests otherwise
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T
# qdrant-client -> httpx[http2].
# torch <=2.12.1 has GHSA-rrmf-rvhw-rf47 (CVE-2025-3000): memory corruption in
# torch.jit.script; fixed in 2.13.0. Transitive via docling/unstructured extras.
# snowflake-connector-python >=4.0.0,<4.7.1 has GHSA-5cc2-282f-jjq2 (CVE-2026-15925):
# TLS hostnames are not verified, so a network attacker can impersonate the endpoint;
# fixed in 4.7.1. Declared as crewai-tools[snowflake] "snowflake-connector-python>=3.12.4",
# which the lock resolved to 4.6.0.
Comment thread
joaomdmoura marked this conversation as resolved.
# Keep OpenAI on the SDK range required by CrewAI when transitive dependencies
# loosen or pin their own lower versions.
override-dependencies = [
Expand Down Expand Up @@ -263,6 +267,7 @@ override-dependencies = [
"nltk>=3.10.3",
"h2>=4.4.1",
"torch>=2.13.0",
"snowflake-connector-python>=4.7.1",
]

[tool.uv.workspace]
Expand Down
Loading
Loading