diff --git a/lib/crewai/src/crewai/llm.py b/lib/crewai/src/crewai/llm.py index b0b5cd3a1..81807c920 100644 --- a/lib/crewai/src/crewai/llm.py +++ b/lib/crewai/src/crewai/llm.py @@ -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 diff --git a/lib/crewai/src/crewai/llms/base_llm.py b/lib/crewai/src/crewai/llms/base_llm.py index a71126f58..3b40601bc 100644 --- a/lib/crewai/src/crewai/llms/base_llm.py +++ b/lib/crewai/src/crewai/llms/base_llm.py @@ -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) diff --git a/lib/crewai/src/crewai/utilities/internal_instructor.py b/lib/crewai/src/crewai/utilities/internal_instructor.py index 36472482c..074f3a824 100644 --- a/lib/crewai/src/crewai/utilities/internal_instructor.py +++ b/lib/crewai/src/crewai/utilities/internal_instructor.py @@ -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. diff --git a/lib/crewai/tests/llms/test_multimodal_integration.py b/lib/crewai/tests/llms/test_multimodal_integration.py index 9957be8c1..eb9ca7914 100644 --- a/lib/crewai/tests/llms/test_multimodal_integration.py +++ b/lib/crewai/tests/llms/test_multimodal_integration.py @@ -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", diff --git a/lib/crewai/tests/test_llm.py b/lib/crewai/tests/test_llm.py index 7e60b18c4..08d80bbae 100644 --- a/lib/crewai/tests/test_llm.py +++ b/lib/crewai/tests/test_llm.py @@ -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(): diff --git a/lib/crewai/tests/utilities/test_converter.py b/lib/crewai/tests/utilities/test_converter.py index ed6429dac..f46cf8008 100644 --- a/lib/crewai/tests/utilities/test_converter.py +++ b/lib/crewai/tests/utilities/test_converter.py @@ -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")