mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-11 05:39:39 +00:00
Merge branch 'main' into fix/native-tool-call-responses-api-shape
This commit is contained in:
@@ -2225,14 +2225,14 @@ class LLM(BaseLLM):
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = self.provider or self.model
|
||||
formatter = self._multimodal_formatter_name()
|
||||
|
||||
for msg in messages:
|
||||
files = msg.get("files")
|
||||
if not files:
|
||||
continue
|
||||
|
||||
content_blocks = format_multimodal_content(files, provider)
|
||||
content_blocks = format_multimodal_content(files, formatter)
|
||||
if not content_blocks:
|
||||
msg.pop("files", None)
|
||||
continue
|
||||
@@ -2250,6 +2250,13 @@ class LLM(BaseLLM):
|
||||
|
||||
return messages
|
||||
|
||||
def _multimodal_formatter_name(self) -> str:
|
||||
# Identity (`self.provider`) stays e.g. anthropic. LiteLLM's completion()
|
||||
# API is OpenAI-shaped and translates blocks to the vendor on the wire.
|
||||
if self.is_litellm:
|
||||
return "openai"
|
||||
return self.provider or self.model
|
||||
|
||||
async def _aprocess_message_files(
|
||||
self, messages: list[LLMMessage]
|
||||
) -> list[LLMMessage]:
|
||||
@@ -2276,14 +2283,14 @@ class LLM(BaseLLM):
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = self.provider or self.model
|
||||
formatter = self._multimodal_formatter_name()
|
||||
|
||||
for msg in messages:
|
||||
files = msg.get("files")
|
||||
if not files:
|
||||
continue
|
||||
|
||||
content_blocks = await aformat_multimodal_content(files, provider)
|
||||
content_blocks = await aformat_multimodal_content(files, formatter)
|
||||
if not content_blocks:
|
||||
msg.pop("files", None)
|
||||
continue
|
||||
|
||||
@@ -274,7 +274,10 @@ class BaseLLM(BaseModel, ABC):
|
||||
data["stop"] = list(stop)
|
||||
|
||||
if not data.get("provider"):
|
||||
data["provider"] = "openai"
|
||||
model = data.get("model") or ""
|
||||
data["provider"] = (
|
||||
cls._extract_provider(model) if isinstance(model, str) else "openai"
|
||||
)
|
||||
|
||||
known_fields = set(cls.model_fields.keys())
|
||||
extras = {k: v for k, v in data.items() if k not in known_fields}
|
||||
@@ -507,6 +510,10 @@ class BaseLLM(BaseModel, ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def _multimodal_formatter_name(self) -> str:
|
||||
# Content-block schema key for crewai_files. Identity stays on self.provider.
|
||||
return self.provider or self.model
|
||||
|
||||
def format_text_content(self, text: str) -> dict[str, Any]:
|
||||
"""Format text as a content block for the LLM.
|
||||
|
||||
@@ -866,7 +873,7 @@ class BaseLLM(BaseModel, ABC):
|
||||
)
|
||||
return messages
|
||||
|
||||
provider = getattr(self, "provider", None) or getattr(self, "model", "openai")
|
||||
formatter = self._multimodal_formatter_name()
|
||||
api = getattr(self, "api", None)
|
||||
|
||||
for msg in messages:
|
||||
@@ -878,7 +885,7 @@ class BaseLLM(BaseModel, ABC):
|
||||
text = existing_content if isinstance(existing_content, str) else None
|
||||
|
||||
content_blocks = format_multimodal_content(
|
||||
files, provider, api=api, prefer_upload=self.prefer_upload, text=text
|
||||
files, formatter, api=api, prefer_upload=self.prefer_upload, text=text
|
||||
)
|
||||
if not content_blocks:
|
||||
msg.pop("files", None)
|
||||
|
||||
@@ -12,6 +12,7 @@ from crewai.llms.base_llm import BaseLLM, JsonResponseFormat, llm_call_context
|
||||
from crewai.llms.hooks.base import BaseInterceptor
|
||||
from crewai.llms.hooks.transport import AsyncHTTPTransport, HTTPTransport
|
||||
from crewai.llms.providers.utils.common import safe_tool_conversion
|
||||
from crewai.types.usage_metrics import _coerce_int
|
||||
from crewai.utilities.agent_utils import is_context_length_exceeded
|
||||
from crewai.utilities.exceptions.context_window_exceeding_exception import (
|
||||
LLMContextLengthExceededError,
|
||||
@@ -1965,12 +1966,15 @@ class AnthropicCompletion(BaseLLM):
|
||||
"""Extract token usage and response metadata from Anthropic response."""
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage = response.usage
|
||||
input_tokens = getattr(usage, "input_tokens", 0)
|
||||
output_tokens = getattr(usage, "output_tokens", 0)
|
||||
cache_read_tokens = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
cache_creation_tokens = (
|
||||
getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
input_tokens = _coerce_int(getattr(usage, "input_tokens", 0))
|
||||
output_tokens = _coerce_int(getattr(usage, "output_tokens", 0))
|
||||
cache_read_tokens = _coerce_int(
|
||||
getattr(usage, "cache_read_input_tokens", 0)
|
||||
)
|
||||
cache_creation_tokens = _coerce_int(
|
||||
getattr(usage, "cache_creation_input_tokens", 0)
|
||||
)
|
||||
input_tokens = input_tokens + cache_read_tokens + cache_creation_tokens
|
||||
result: dict[str, Any] = {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
|
||||
@@ -108,6 +108,37 @@ class UsageMetrics(BaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_unreconciled_anthropic_cache_keys(usage_data: dict[str, Any]) -> bool:
|
||||
"""Detect raw Anthropic usage that still splits cache from ``input_tokens``.
|
||||
|
||||
The native ``AnthropicCompletion`` provider folds cache read/creation
|
||||
counters into ``input_tokens`` before usage reaches this normalizer.
|
||||
LiteLLM and flow-level event aggregation can still deliver the raw
|
||||
Anthropic API shape, where ``input_tokens`` is only the uncached
|
||||
portion and cache counters arrive as separate keys. Without
|
||||
reconciling here, ``prompt_tokens`` and ``total_tokens`` undercount
|
||||
billed usage on cached Anthropic workloads.
|
||||
"""
|
||||
return "input_tokens" in usage_data and (
|
||||
"cache_read_input_tokens" in usage_data
|
||||
or "cache_creation_input_tokens" in usage_data
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_billed_prompt_tokens(usage_data: dict[str, Any]) -> int:
|
||||
"""Return the full billed prompt/input token count for a usage dict."""
|
||||
if UsageMetrics._has_unreconciled_anthropic_cache_keys(usage_data):
|
||||
return (
|
||||
_coerce_int(usage_data.get("input_tokens"))
|
||||
+ _coerce_int(usage_data.get("cache_read_input_tokens"))
|
||||
+ _coerce_int(usage_data.get("cache_creation_input_tokens"))
|
||||
)
|
||||
|
||||
return _first_int(
|
||||
usage_data, "prompt_tokens", "prompt_token_count", "input_tokens"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_provider_dict(cls, usage_data: dict[str, Any] | None) -> Self | None:
|
||||
"""Normalize a provider's raw usage dict into a ``UsageMetrics``.
|
||||
@@ -125,9 +156,7 @@ class UsageMetrics(BaseModel):
|
||||
if not usage_data:
|
||||
return None
|
||||
|
||||
prompt_tokens = _first_int(
|
||||
usage_data, "prompt_tokens", "prompt_token_count", "input_tokens"
|
||||
)
|
||||
prompt_tokens = cls._resolve_billed_prompt_tokens(usage_data)
|
||||
completion_tokens = _first_int(
|
||||
usage_data,
|
||||
"completion_tokens",
|
||||
@@ -145,12 +174,16 @@ class UsageMetrics(BaseModel):
|
||||
if isinstance(details, dict):
|
||||
cached_prompt_tokens = _coerce_int(details.get("cached_tokens"))
|
||||
|
||||
cache_creation_tokens = _coerce_int(
|
||||
usage_data.get("cache_creation_tokens")
|
||||
) or _coerce_int(usage_data.get("cache_creation_input_tokens"))
|
||||
|
||||
return cls(
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
cached_prompt_tokens=cached_prompt_tokens,
|
||||
reasoning_tokens=_coerce_int(usage_data.get("reasoning_tokens")),
|
||||
cache_creation_tokens=_coerce_int(usage_data.get("cache_creation_tokens")),
|
||||
cache_creation_tokens=cache_creation_tokens,
|
||||
successful_requests=1,
|
||||
)
|
||||
|
||||
@@ -105,7 +105,12 @@ class InternalInstructor(Generic[T]):
|
||||
if value is not None:
|
||||
extra_kwargs[attr] = value
|
||||
|
||||
return instructor.from_provider(f"{provider}/{model_string}", **extra_kwargs)
|
||||
qualified_model = (
|
||||
model_string
|
||||
if not provider or model_string.startswith(f"{provider}/")
|
||||
else f"{provider}/{model_string}"
|
||||
)
|
||||
return instructor.from_provider(qualified_model, **extra_kwargs)
|
||||
|
||||
def _extract_provider(self) -> str:
|
||||
"""Extract provider from LLM model name.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: '{"messages": [{"role": "user", "content": "Hello, how are you?"}], "model":
|
||||
"gpt-4o-mini", "stop": []}'
|
||||
headers:
|
||||
accept:
|
||||
- application/json
|
||||
accept-encoding:
|
||||
- gzip, deflate
|
||||
connection:
|
||||
- keep-alive
|
||||
content-length:
|
||||
- '102'
|
||||
content-type:
|
||||
- application/json
|
||||
cookie:
|
||||
- _cfuvid=IY8ppO70AMHr2skDSUsGh71zqHHdCQCZ3OvkPi26NBc-1740424913267-0.0.1.1-604800000;
|
||||
__cf_bm=fU6K5KZoDmgcEuF8_yWAYKUO5fKHh6q5.wDPnna393g-1740424913-1.0.1.1-2iOaq3JVGWs439V0HxJee0IC9HdJm7dPkeJorD.AGw0YwkngRPM8rrTzn_7ht1BkbOauEezj.wPKcBz18gIYUg
|
||||
host:
|
||||
- api.openai.com
|
||||
user-agent:
|
||||
- OpenAI/Python 1.61.0
|
||||
x-stainless-arch:
|
||||
- arm64
|
||||
x-stainless-async:
|
||||
- 'false'
|
||||
x-stainless-lang:
|
||||
- python
|
||||
x-stainless-os:
|
||||
- MacOS
|
||||
x-stainless-package-version:
|
||||
- 1.61.0
|
||||
x-stainless-raw-response:
|
||||
- 'true'
|
||||
x-stainless-retry-count:
|
||||
- '0'
|
||||
x-stainless-runtime:
|
||||
- CPython
|
||||
x-stainless-runtime-version:
|
||||
- 3.12.8
|
||||
method: POST
|
||||
uri: https://api.openai.com/v1/chat/completions
|
||||
response:
|
||||
body:
|
||||
string: "{\n \"id\": \"chatcmpl-B4YLA2SrC2rwdVQ3U87G5a0P5lsLw\",\n \"object\"\
|
||||
: \"chat.completion\",\n \"created\": 1740425016,\n \"model\": \"gpt-4o-mini-2024-07-18\"\
|
||||
,\n \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \
|
||||
\ \"role\": \"assistant\",\n \"content\": \"Hello! I'm just a\
|
||||
\ computer program, so I don't have feelings, but I'm here and ready to help\
|
||||
\ you. How can I assist you today?\",\n \"refusal\": null\n },\n\
|
||||
\ \"logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n ],\n\
|
||||
\ \"usage\": {\n \"prompt_tokens\": 13,\n \"completion_tokens\": 30,\n\
|
||||
\ \"total_tokens\": 43,\n \"prompt_tokens_details\": {\n \"cached_tokens\"\
|
||||
: 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\"\
|
||||
: {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"\
|
||||
accepted_prediction_tokens\": 0,\n \"rejected_prediction_tokens\": 0\n\
|
||||
\ }\n },\n \"service_tier\": \"default\",\n \"system_fingerprint\":\
|
||||
\ \"fp_709714d124\"\n}\n"
|
||||
headers:
|
||||
CF-RAY:
|
||||
- 9171d4c0ed44236e-SJC
|
||||
Connection:
|
||||
- keep-alive
|
||||
Content-Type:
|
||||
- application/json
|
||||
Date:
|
||||
- Mon, 24 Feb 2025 19:23:38 GMT
|
||||
Server:
|
||||
- cloudflare
|
||||
Transfer-Encoding:
|
||||
- chunked
|
||||
X-Content-Type-Options:
|
||||
- nosniff
|
||||
access-control-expose-headers:
|
||||
- X-Request-ID
|
||||
alt-svc:
|
||||
- h3=":443"; ma=86400
|
||||
cf-cache-status:
|
||||
- DYNAMIC
|
||||
openai-organization:
|
||||
- crewai-iuxna1
|
||||
openai-processing-ms:
|
||||
- '1954'
|
||||
openai-version:
|
||||
- '2020-10-01'
|
||||
strict-transport-security:
|
||||
- max-age=31536000; includeSubDomains; preload
|
||||
x-ratelimit-limit-requests:
|
||||
- '30000'
|
||||
x-ratelimit-limit-tokens:
|
||||
- '150000000'
|
||||
x-ratelimit-remaining-requests:
|
||||
- '29999'
|
||||
x-ratelimit-remaining-tokens:
|
||||
- '149999978'
|
||||
x-ratelimit-reset-requests:
|
||||
- 2ms
|
||||
x-ratelimit-reset-tokens:
|
||||
- 0s
|
||||
x-request-id:
|
||||
- req_ea2703502b8827e4297cd2a7bae9d9c8
|
||||
status:
|
||||
code: 200
|
||||
message: OK
|
||||
version: 1
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.llm_events import (
|
||||
LLMCallCompletedEvent,
|
||||
LLMCallStartedEvent,
|
||||
@@ -31,7 +31,7 @@ class _StubLLM(BaseLLM):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_emit():
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock:
|
||||
with patch.object(crewai_event_bus, "emit") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.types.llm_events import LLMCallCompletedEvent, LLMCallType
|
||||
from crewai.llm import LLM
|
||||
from crewai.llms.base_llm import BaseLLM
|
||||
@@ -203,7 +202,9 @@ class _StubLLM(BaseLLM):
|
||||
class TestEmitCallCompletedEventPassesUsage:
|
||||
@pytest.fixture
|
||||
def mock_emit(self):
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
|
||||
with patch.object(crewai_event_bus, "emit") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture
|
||||
@@ -297,3 +298,128 @@ class TestUsageMetricsNewFields:
|
||||
dumped = metrics.model_dump()
|
||||
assert dumped["reasoning_tokens"] == 10
|
||||
assert dumped["cache_creation_tokens"] == 5
|
||||
|
||||
|
||||
class TestFromProviderDictAnthropicCacheTokens:
|
||||
def test_cache_read_tokens_included_in_prompt_and_total(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 44,
|
||||
"cache_read_input_tokens": 2061,
|
||||
}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 2064
|
||||
assert metrics.completion_tokens == 44
|
||||
assert metrics.total_tokens == 2108
|
||||
assert metrics.cached_prompt_tokens == 2061
|
||||
|
||||
def test_cache_creation_tokens_included_in_prompt_and_total(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_creation_input_tokens": 20,
|
||||
}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 120
|
||||
assert metrics.total_tokens == 170
|
||||
assert metrics.cache_creation_tokens == 20
|
||||
|
||||
def test_cache_read_and_creation_tokens_both_included(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 30,
|
||||
"cache_creation_input_tokens": 20,
|
||||
}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 150
|
||||
assert metrics.total_tokens == 200
|
||||
assert metrics.cached_prompt_tokens == 30
|
||||
assert metrics.cache_creation_tokens == 20
|
||||
|
||||
def test_missing_cache_fields_preserve_non_cached_totals(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{"input_tokens": 100, "output_tokens": 50}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 100
|
||||
assert metrics.total_tokens == 150
|
||||
assert metrics.cached_prompt_tokens == 0
|
||||
assert metrics.cache_creation_tokens == 0
|
||||
|
||||
def test_reconciled_native_dict_is_not_double_counted(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 50,
|
||||
"cached_prompt_tokens": 30,
|
||||
"cache_creation_tokens": 20,
|
||||
}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 150
|
||||
assert metrics.total_tokens == 200
|
||||
assert metrics.cache_creation_tokens == 20
|
||||
|
||||
def test_openai_cached_prompt_tokens_are_not_added_twice(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
metrics = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {"cached_tokens": 30},
|
||||
}
|
||||
)
|
||||
|
||||
assert metrics is not None
|
||||
assert metrics.prompt_tokens == 100
|
||||
assert metrics.total_tokens == 150
|
||||
assert metrics.cached_prompt_tokens == 30
|
||||
|
||||
def test_cumulative_usage_via_add_usage_metrics(self):
|
||||
from crewai.types.usage_metrics import UsageMetrics
|
||||
|
||||
first = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"cache_read_input_tokens": 30,
|
||||
}
|
||||
)
|
||||
second = UsageMetrics.from_provider_dict(
|
||||
{
|
||||
"input_tokens": 40,
|
||||
"output_tokens": 20,
|
||||
}
|
||||
)
|
||||
|
||||
assert first is not None and second is not None
|
||||
first.add_usage_metrics(second)
|
||||
|
||||
assert first.prompt_tokens == 170
|
||||
assert first.completion_tokens == 70
|
||||
assert first.total_tokens == 240
|
||||
assert first.cached_prompt_tokens == 30
|
||||
assert first.successful_requests == 2
|
||||
|
||||
@@ -549,7 +549,12 @@ def test_anthropic_token_usage_tracking():
|
||||
with patch.object(llm._client.messages, 'create') as mock_create:
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [MagicMock(text="test response")]
|
||||
mock_response.usage = MagicMock(input_tokens=50, output_tokens=25)
|
||||
mock_response.usage = MagicMock(
|
||||
input_tokens=50,
|
||||
output_tokens=25,
|
||||
cache_read_input_tokens=0,
|
||||
cache_creation_input_tokens=0,
|
||||
)
|
||||
mock_create.return_value = mock_response
|
||||
|
||||
result = llm.call("Hello")
|
||||
@@ -1653,9 +1658,9 @@ def test_anthropic_cache_creation_tokens_extraction():
|
||||
mock_response.model = None
|
||||
|
||||
usage = llm._extract_anthropic_token_usage(mock_response)
|
||||
assert usage["input_tokens"] == 100
|
||||
assert usage["input_tokens"] == 150
|
||||
assert usage["output_tokens"] == 50
|
||||
assert usage["total_tokens"] == 150
|
||||
assert usage["total_tokens"] == 200
|
||||
assert usage["cached_prompt_tokens"] == 30
|
||||
assert usage["cache_creation_tokens"] == 20
|
||||
|
||||
|
||||
@@ -123,12 +123,12 @@ def test_gemini_completion_initialization_parameters():
|
||||
|
||||
|
||||
def test_gemini_started_event_surfaces_max_output_tokens():
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.llm_events import LLMCallStartedEvent
|
||||
|
||||
llm = LLM(model="google/gemini-2.0-flash-001", max_output_tokens=2000, api_key="test-key")
|
||||
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock_emit:
|
||||
with patch.object(crewai_event_bus, "emit") as mock_emit:
|
||||
llm._emit_call_started_event(messages="hi")
|
||||
|
||||
event = mock_emit.call_args[1]["event"]
|
||||
|
||||
@@ -77,8 +77,8 @@ startxref
|
||||
|
||||
def _build_multimodal_message(llm: LLM, prompt: str, files: dict) -> list[dict]:
|
||||
"""Build a multimodal message with text and file content."""
|
||||
provider = getattr(llm, "provider", None) or llm.model
|
||||
content_blocks = format_multimodal_content(files, provider)
|
||||
formatter = llm._multimodal_formatter_name()
|
||||
content_blocks = format_multimodal_content(files, formatter)
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -38,18 +38,10 @@ def get_temperature_tool_schema() -> dict[str, Any]:
|
||||
|
||||
@pytest.fixture
|
||||
def mock_emit() -> MagicMock:
|
||||
"""Mock the singleton event bus emit used by LLM providers.
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
|
||||
Patch the singleton instance (not only the class) so a leftover
|
||||
instance-level ``emit`` from other tests cannot shadow the mock.
|
||||
"""
|
||||
from crewai.events.event_bus import CrewAIEventsBus, crewai_event_bus
|
||||
|
||||
with (
|
||||
patch.object(CrewAIEventsBus, "emit") as class_mock,
|
||||
patch.object(crewai_event_bus, "emit", new=class_mock),
|
||||
):
|
||||
yield class_mock
|
||||
with patch.object(crewai_event_bus, "emit") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _event_from_emit_call(call: Any) -> Any:
|
||||
|
||||
@@ -534,9 +534,9 @@ def assert_event_count(
|
||||
|
||||
@pytest.fixture
|
||||
def mock_emit() -> MagicMock:
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock_emit:
|
||||
with patch.object(crewai_event_bus, "emit") as mock_emit:
|
||||
yield mock_emit
|
||||
|
||||
|
||||
@@ -860,16 +860,19 @@ def test_prefixed_models_with_invalid_constants_use_litellm():
|
||||
llm = LLM(model="openai/gemini-2.5-flash", is_litellm=False)
|
||||
assert llm.is_litellm is True
|
||||
assert llm.model == "openai/gemini-2.5-flash"
|
||||
assert llm.provider == "openai"
|
||||
|
||||
# Test openai/ prefix with model that doesn't match patterns (e.g. no gpt- prefix) → LiteLLM
|
||||
llm2 = LLM(model="openai/custom-finetune-model", is_litellm=False)
|
||||
assert llm2.is_litellm is True
|
||||
assert llm2.model == "openai/custom-finetune-model"
|
||||
assert llm2.provider == "openai"
|
||||
|
||||
# Test anthropic/ prefix with non-Anthropic model → LiteLLM
|
||||
llm3 = LLM(model="anthropic/gpt-4o", is_litellm=False)
|
||||
assert llm3.is_litellm is True
|
||||
assert llm3.model == "anthropic/gpt-4o"
|
||||
assert llm3.provider == "anthropic"
|
||||
|
||||
|
||||
def test_prefixed_models_with_valid_patterns_use_native_sdk():
|
||||
@@ -893,11 +896,38 @@ def test_prefixed_models_with_non_native_providers_use_litellm():
|
||||
llm = LLM(model="groq/llama-3.3-70b", is_litellm=False)
|
||||
assert llm.is_litellm is True
|
||||
assert llm.model == "groq/llama-3.3-70b"
|
||||
assert llm.provider == "groq"
|
||||
|
||||
# Test together/ prefix (not a native provider) → LiteLLM
|
||||
llm2 = LLM(model="together/qwen-2.5-72b", is_litellm=False)
|
||||
assert llm2.is_litellm is True
|
||||
assert llm2.model == "together/qwen-2.5-72b"
|
||||
assert llm2.provider == "together"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected_provider"),
|
||||
[
|
||||
("groq/llama-3.3-70b", "groq"),
|
||||
("cohere/command-r", "cohere"),
|
||||
("sambanova/Meta-Llama-3.1-70B-Instruct", "sambanova"),
|
||||
("mistral/mistral-large", "mistral"),
|
||||
("vertex_ai/gemini-1.5-pro", "vertex_ai"),
|
||||
("openai/custom-finetune-model", "openai"),
|
||||
("anthropic/gpt-4o", "anthropic"),
|
||||
],
|
||||
)
|
||||
def test_litellm_path_preserves_provider_from_model_prefix(model, expected_provider):
|
||||
llm = LLM(model=model, is_litellm=False)
|
||||
assert llm.is_litellm is True
|
||||
assert llm.provider == expected_provider
|
||||
assert llm.model == model
|
||||
|
||||
|
||||
def test_litellm_keeps_provider_but_formats_multimodal_as_openai_schema():
|
||||
llm = LLM(model="anthropic/claude-3-5-haiku-20241022", is_litellm=True)
|
||||
assert llm.provider == "anthropic"
|
||||
assert llm._multimodal_formatter_name() == "openai"
|
||||
|
||||
|
||||
def test_unprefixed_models_use_native_sdk():
|
||||
|
||||
@@ -11,14 +11,14 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.events.event_bus import CrewAIEventsBus
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.llm_events import LLMCallCompletedEvent
|
||||
from crewai.llm import LLM
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_emit():
|
||||
with patch.object(CrewAIEventsBus, "emit") as mock:
|
||||
with patch.object(crewai_event_bus, "emit") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
|
||||
@@ -1004,3 +1004,21 @@ def test_internal_instructor_omits_unset_base_url_and_api_key() -> None:
|
||||
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
|
||||
|
||||
mock_from_provider.assert_called_once_with("openai/gpt-4o")
|
||||
|
||||
|
||||
def test_internal_instructor_does_not_double_prefix_qualified_models() -> None:
|
||||
from crewai.utilities.internal_instructor import InternalInstructor
|
||||
|
||||
mock_llm = Mock()
|
||||
mock_llm.is_litellm = False
|
||||
mock_llm.model = "groq/llama-3.3-70b"
|
||||
mock_llm.provider = "groq"
|
||||
mock_llm.base_url = None
|
||||
mock_llm.api_key = None
|
||||
|
||||
with patch("instructor.from_provider") as mock_from_provider:
|
||||
mock_from_provider.return_value = Mock()
|
||||
|
||||
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
|
||||
|
||||
mock_from_provider.assert_called_once_with("groq/llama-3.3-70b")
|
||||
|
||||
@@ -1244,7 +1244,6 @@ def test_llm_completed_event_includes_usage():
|
||||
assert event.usage.get("total_tokens", 0) > 0
|
||||
|
||||
|
||||
@pytest.mark.vcr()
|
||||
def test_llm_emits_call_failed_event():
|
||||
received_events = []
|
||||
event_received = threading.Event()
|
||||
@@ -1256,12 +1255,10 @@ def test_llm_emits_call_failed_event():
|
||||
|
||||
error_message = "OpenAI API call failed: Simulated API failure"
|
||||
|
||||
with patch(
|
||||
"crewai.llms.providers.openai.completion.OpenAICompletion._handle_completion"
|
||||
) as mock_handle_completion:
|
||||
mock_handle_completion.side_effect = Exception("Simulated API failure")
|
||||
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
llm = LLM(model="gpt-4o-mini")
|
||||
with patch.object(
|
||||
llm, "_handle_completion", side_effect=Exception("Simulated API failure")
|
||||
):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
llm.call("Hello, how are you?")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user