Skip to content

Commit e264e05

Browse files
authored
fix: preserve provider on LiteLLM-routed models (#6849)
* fix: preserve provider on LiteLLM-routed models LiteLLM construction computed the real provider in `__new__` but never passed it into init, so `BaseLLM` silently defaulted every shared-path model to `openai`. Infer the provider from a `provider/model` prefix when none is supplied so groq, cohere, mistral, and the rest report themselves correctly to callers like the policy engine. * fix: avoid double-prefixing instructor model strings With LiteLLM models now carrying a real `provider` while `model` keeps its `provider/name` form, `InternalInstructor` was building `groq/groq/...` for `instructor.from_provider`. Skip the prefix when the model string is already qualified. * fix: format LiteLLM multimodal content as OpenAI-shaped blocks Preserving the real provider on the LiteLLM path made `format_multimodal_content` emit Anthropic-native blocks for `anthropic/...` models, which LiteLLM rejects. Keep `provider` as the model identity for policies, but format multimodal blocks with the OpenAI chat schema when `is_litellm` is set. Expose the formatter helper on `BaseLLM` so native OpenAI/Azure completions share the same API.
1 parent 34230a0 commit e264e05

6 files changed

Lines changed: 77 additions & 10 deletions

File tree

lib/crewai/src/crewai/llm.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2225,14 +2225,14 @@ def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]
22252225
)
22262226
return messages
22272227

2228-
provider = self.provider or self.model
2228+
formatter = self._multimodal_formatter_name()
22292229

22302230
for msg in messages:
22312231
files = msg.get("files")
22322232
if not files:
22332233
continue
22342234

2235-
content_blocks = format_multimodal_content(files, provider)
2235+
content_blocks = format_multimodal_content(files, formatter)
22362236
if not content_blocks:
22372237
msg.pop("files", None)
22382238
continue
@@ -2250,6 +2250,13 @@ def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]
22502250

22512251
return messages
22522252

2253+
def _multimodal_formatter_name(self) -> str:
2254+
# Identity (`self.provider`) stays e.g. anthropic. LiteLLM's completion()
2255+
# API is OpenAI-shaped and translates blocks to the vendor on the wire.
2256+
if self.is_litellm:
2257+
return "openai"
2258+
return self.provider or self.model
2259+
22532260
async def _aprocess_message_files(
22542261
self, messages: list[LLMMessage]
22552262
) -> list[LLMMessage]:
@@ -2276,14 +2283,14 @@ async def _aprocess_message_files(
22762283
)
22772284
return messages
22782285

2279-
provider = self.provider or self.model
2286+
formatter = self._multimodal_formatter_name()
22802287

22812288
for msg in messages:
22822289
files = msg.get("files")
22832290
if not files:
22842291
continue
22852292

2286-
content_blocks = await aformat_multimodal_content(files, provider)
2293+
content_blocks = await aformat_multimodal_content(files, formatter)
22872294
if not content_blocks:
22882295
msg.pop("files", None)
22892296
continue

lib/crewai/src/crewai/llms/base_llm.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,10 @@ def _validate_init_fields(cls, data: Any) -> Any:
274274
data["stop"] = list(stop)
275275

276276
if not data.get("provider"):
277-
data["provider"] = "openai"
277+
model = data.get("model") or ""
278+
data["provider"] = (
279+
cls._extract_provider(model) if isinstance(model, str) else "openai"
280+
)
278281

279282
known_fields = set(cls.model_fields.keys())
280283
extras = {k: v for k, v in data.items() if k not in known_fields}
@@ -507,6 +510,10 @@ def supports_multimodal(self) -> bool:
507510
"""
508511
return False
509512

513+
def _multimodal_formatter_name(self) -> str:
514+
# Content-block schema key for crewai_files. Identity stays on self.provider.
515+
return self.provider or self.model
516+
510517
def format_text_content(self, text: str) -> dict[str, Any]:
511518
"""Format text as a content block for the LLM.
512519
@@ -866,7 +873,7 @@ def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]
866873
)
867874
return messages
868875

869-
provider = getattr(self, "provider", None) or getattr(self, "model", "openai")
876+
formatter = self._multimodal_formatter_name()
870877
api = getattr(self, "api", None)
871878

872879
for msg in messages:
@@ -878,7 +885,7 @@ def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]
878885
text = existing_content if isinstance(existing_content, str) else None
879886

880887
content_blocks = format_multimodal_content(
881-
files, provider, api=api, prefer_upload=self.prefer_upload, text=text
888+
files, formatter, api=api, prefer_upload=self.prefer_upload, text=text
882889
)
883890
if not content_blocks:
884891
msg.pop("files", None)

lib/crewai/src/crewai/utilities/internal_instructor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,12 @@ def _create_instructor_client(self) -> Any:
105105
if value is not None:
106106
extra_kwargs[attr] = value
107107

108-
return instructor.from_provider(f"{provider}/{model_string}", **extra_kwargs)
108+
qualified_model = (
109+
model_string
110+
if not provider or model_string.startswith(f"{provider}/")
111+
else f"{provider}/{model_string}"
112+
)
113+
return instructor.from_provider(qualified_model, **extra_kwargs)
109114

110115
def _extract_provider(self) -> str:
111116
"""Extract provider from LLM model name.

lib/crewai/tests/llms/test_multimodal_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ def test_audio_bytes() -> bytes:
7777

7878
def _build_multimodal_message(llm: LLM, prompt: str, files: dict) -> list[dict]:
7979
"""Build a multimodal message with text and file content."""
80-
provider = getattr(llm, "provider", None) or llm.model
81-
content_blocks = format_multimodal_content(files, provider)
80+
formatter = llm._multimodal_formatter_name()
81+
content_blocks = format_multimodal_content(files, formatter)
8282
return [
8383
{
8484
"role": "user",

lib/crewai/tests/test_llm.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,16 +860,19 @@ def test_prefixed_models_with_invalid_constants_use_litellm():
860860
llm = LLM(model="openai/gemini-2.5-flash", is_litellm=False)
861861
assert llm.is_litellm is True
862862
assert llm.model == "openai/gemini-2.5-flash"
863+
assert llm.provider == "openai"
863864

864865
# Test openai/ prefix with model that doesn't match patterns (e.g. no gpt- prefix) → LiteLLM
865866
llm2 = LLM(model="openai/custom-finetune-model", is_litellm=False)
866867
assert llm2.is_litellm is True
867868
assert llm2.model == "openai/custom-finetune-model"
869+
assert llm2.provider == "openai"
868870

869871
# Test anthropic/ prefix with non-Anthropic model → LiteLLM
870872
llm3 = LLM(model="anthropic/gpt-4o", is_litellm=False)
871873
assert llm3.is_litellm is True
872874
assert llm3.model == "anthropic/gpt-4o"
875+
assert llm3.provider == "anthropic"
873876

874877

875878
def test_prefixed_models_with_valid_patterns_use_native_sdk():
@@ -893,11 +896,38 @@ def test_prefixed_models_with_non_native_providers_use_litellm():
893896
llm = LLM(model="groq/llama-3.3-70b", is_litellm=False)
894897
assert llm.is_litellm is True
895898
assert llm.model == "groq/llama-3.3-70b"
899+
assert llm.provider == "groq"
896900

897901
# Test together/ prefix (not a native provider) → LiteLLM
898902
llm2 = LLM(model="together/qwen-2.5-72b", is_litellm=False)
899903
assert llm2.is_litellm is True
900904
assert llm2.model == "together/qwen-2.5-72b"
905+
assert llm2.provider == "together"
906+
907+
908+
@pytest.mark.parametrize(
909+
("model", "expected_provider"),
910+
[
911+
("groq/llama-3.3-70b", "groq"),
912+
("cohere/command-r", "cohere"),
913+
("sambanova/Meta-Llama-3.1-70B-Instruct", "sambanova"),
914+
("mistral/mistral-large", "mistral"),
915+
("vertex_ai/gemini-1.5-pro", "vertex_ai"),
916+
("openai/custom-finetune-model", "openai"),
917+
("anthropic/gpt-4o", "anthropic"),
918+
],
919+
)
920+
def test_litellm_path_preserves_provider_from_model_prefix(model, expected_provider):
921+
llm = LLM(model=model, is_litellm=False)
922+
assert llm.is_litellm is True
923+
assert llm.provider == expected_provider
924+
assert llm.model == model
925+
926+
927+
def test_litellm_keeps_provider_but_formats_multimodal_as_openai_schema():
928+
llm = LLM(model="anthropic/claude-3-5-haiku-20241022", is_litellm=True)
929+
assert llm.provider == "anthropic"
930+
assert llm._multimodal_formatter_name() == "openai"
901931

902932

903933
def test_unprefixed_models_use_native_sdk():

lib/crewai/tests/utilities/test_converter.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,3 +1004,21 @@ def test_internal_instructor_omits_unset_base_url_and_api_key() -> None:
10041004
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
10051005

10061006
mock_from_provider.assert_called_once_with("openai/gpt-4o")
1007+
1008+
1009+
def test_internal_instructor_does_not_double_prefix_qualified_models() -> None:
1010+
from crewai.utilities.internal_instructor import InternalInstructor
1011+
1012+
mock_llm = Mock()
1013+
mock_llm.is_litellm = False
1014+
mock_llm.model = "groq/llama-3.3-70b"
1015+
mock_llm.provider = "groq"
1016+
mock_llm.base_url = None
1017+
mock_llm.api_key = None
1018+
1019+
with patch("instructor.from_provider") as mock_from_provider:
1020+
mock_from_provider.return_value = Mock()
1021+
1022+
InternalInstructor(content="x", model=SimpleModel, llm=mock_llm)
1023+
1024+
mock_from_provider.assert_called_once_with("groq/llama-3.3-70b")

0 commit comments

Comments
 (0)