Compare commits

..

3 Commits

Author SHA1 Message Date
lorenzejay
e72b2039f9 Refactor OpenAI completion module test to restore original module state
- Added logic to save and restore the original OpenAI completion module during the test to prevent issues with class re-imports affecting subsequent tests.
- Ensured that the test checks for the presence of the module and its attributes only after the module is properly reloaded.
- Improved test reliability by avoiding potential failures due to module state changes across tests.
2026-07-08 17:25:45 -07:00
lorenzejay
8307d4ba14 Add custom OpenAI-compatible endpoint support 2026-07-08 16:09:42 -07:00
lorenzejay
c4ff2c4ebd Support legacy OpenAI base URL env var 2026-07-08 16:01:16 -07:00
5 changed files with 145 additions and 11 deletions

View File

@@ -144,6 +144,18 @@ In this section, you'll find detailed examples that help you select, configure,
)
```
**Custom OpenAI-Compatible Endpoint:**
```python Code
from crewai import LLM
llm = LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
base_url="https://your-gateway.example.com/v1",
api_key="your-gateway-api-key",
)
```
**Advanced Configuration:**
```python Code
from crewai import LLM

View File

@@ -240,14 +240,15 @@ from crewai import LLM
# After (OpenAI-compatible mode, no LiteLLM needed):
llm = LLM(
model="openai/llama3",
model="llama3",
custom_openai=True,
base_url="http://localhost:11434/v1",
api_key="ollama" # Ollama doesn't require a real API key
)
```
<Tip>
Many local inference servers (Ollama, vLLM, LM Studio, llama.cpp) expose an OpenAI-compatible API. You can use the `openai/` prefix with a custom `base_url` to connect to any of them natively.
Many local inference servers (Ollama, vLLM, LM Studio, llama.cpp) expose an OpenAI-compatible API. You can use `custom_openai=True` with a custom `base_url` to connect to any of them natively while keeping the model ID your gateway expects.
</Tip>
### Step 4: Update your YAML configs

View File

@@ -404,9 +404,20 @@ class LLM(BaseLLM):
if not model or not isinstance(model, str):
raise ValueError("Model must be a non-empty string")
custom_openai = bool(kwargs.pop("custom_openai", False))
custom_openai_route = custom_openai
explicit_provider = kwargs.get("provider")
if explicit_provider:
if custom_openai:
if not cls._has_custom_openai_base_url(kwargs):
raise ValueError(
"custom_openai=True requires base_url, api_base, "
"OPENAI_BASE_URL, or OPENAI_API_BASE"
)
provider = "openai"
use_native = True
model_string = model
elif explicit_provider:
provider = explicit_provider
use_native = True
model_string = model
@@ -435,9 +446,17 @@ class LLM(BaseLLM):
canonical_provider = provider_mapping.get(prefix.lower())
if canonical_provider and cls._validate_model_in_constants(
model_part, canonical_provider
):
valid_native_model = bool(
canonical_provider
and cls._validate_model_in_constants(model_part, canonical_provider)
)
custom_openai_route = bool(
canonical_provider == "openai"
and not valid_native_model
and cls._has_custom_openai_base_url(kwargs)
)
if canonical_provider and (valid_native_model or custom_openai_route):
provider = canonical_provider
use_native = True
model_string = model_part
@@ -455,6 +474,8 @@ class LLM(BaseLLM):
try:
# Remove 'provider' from kwargs if it exists to avoid duplicate keyword argument
kwargs_copy = {k: v for k, v in kwargs.items() if k != "provider"}
if custom_openai_route:
kwargs_copy["custom_openai"] = True
return cast(
Self,
native_class(model=model_string, provider=provider, **kwargs_copy),
@@ -590,6 +611,15 @@ class LLM(BaseLLM):
return cls._matches_provider_pattern(model, provider)
@staticmethod
def _has_custom_openai_base_url(kwargs: dict[str, Any]) -> bool:
return bool(
kwargs.get("base_url")
or kwargs.get("api_base")
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
)
@classmethod
def _infer_provider_from_model(cls, model: str) -> str:
"""Infer the provider from the model name.

View File

@@ -232,6 +232,7 @@ class OpenAICompletion(BaseLLM):
auto_chain: bool = False
auto_chain_reasoning: bool = False
api_base: str | None = None
custom_openai: bool = False
is_o1_model: bool = False
is_gpt4_model: bool = False
@@ -355,6 +356,9 @@ class OpenAICompletion(BaseLLM):
config["seed"] = self.seed
if self.reasoning_effort is not None:
config["reasoning_effort"] = self.reasoning_effort
if self.custom_openai:
config["model"] = self.model
config["custom_openai"] = True
return config
def _get_client_params(self) -> dict[str, Any]:
@@ -372,6 +376,7 @@ class OpenAICompletion(BaseLLM):
"base_url": self.base_url
or self.api_base
or os.getenv("OPENAI_BASE_URL")
or os.getenv("OPENAI_API_BASE")
or None,
"timeout": self.timeout,
"max_retries": self.max_retries,

View File

@@ -30,10 +30,84 @@ def test_openai_completion_is_used_when_no_provider_prefix():
llm = LLM(model="gpt-4o")
from crewai.llms.providers.openai.completion import OpenAICompletion
assert isinstance(llm, OpenAICompletion)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.provider == "openai"
assert llm.model == "gpt-4o"
def test_custom_openai_flag_uses_native_openai_without_provider_prefix():
"""Custom OpenAI-compatible endpoints can serve arbitrary model ids."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
llm = LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
base_url="https://asimov.example/v1",
is_litellm=False,
)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.base_url == "https://asimov.example/v1"
assert llm.custom_openai is True
assert "custom_openai" not in llm.additional_params
config = llm.to_config_dict()
assert config["model"] == "anthropic/claude-sonnet-4-6"
assert config["custom_openai"] is True
assert config["base_url"] == "https://asimov.example/v1"
def test_custom_openai_flag_requires_custom_base_url():
"""Avoid routing arbitrary custom model ids to api.openai.com by mistake."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
with pytest.raises(ValueError, match="custom_openai=True requires"):
LLM(
model="anthropic/claude-sonnet-4-6",
custom_openai=True,
is_litellm=False,
)
def test_openai_prefixed_custom_endpoint_uses_native_sdk_for_nested_model_id():
"""Custom OpenAI-compatible endpoints may serve non-OpenAI model ids."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=False):
llm = LLM(
model="openai/anthropic/claude-sonnet-4-6",
base_url="https://asimov.example/v1",
is_litellm=False,
)
assert llm.__class__.__name__ == "OpenAICompletion"
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.custom_openai is True
assert llm.base_url == "https://asimov.example/v1"
def test_openai_prefixed_custom_endpoint_uses_legacy_api_base_env_var():
"""Legacy OPENAI_API_BASE still marks the endpoint as OpenAI-compatible."""
with patch.dict(
os.environ,
{
"OPENAI_API_KEY": "test-key",
"OPENAI_API_BASE": "https://asimov.example/v1",
},
clear=False,
):
os.environ.pop("OPENAI_BASE_URL", None)
llm = LLM(
model="openai/anthropic/claude-sonnet-4-6",
is_litellm=False,
)
assert isinstance(llm, OpenAICompletion)
assert llm.is_litellm is False
assert llm.provider == "openai"
assert llm.model == "anthropic/claude-sonnet-4-6"
assert llm.custom_openai is True
@pytest.mark.vcr()
def test_openai_is_default_provider_without_explicit_llm_set_on_agent():
"""
@@ -60,14 +134,13 @@ def test_openai_is_default_provider_without_explicit_llm_set_on_agent():
def test_openai_completion_module_is_imported():
def test_openai_completion_module_is_imported(monkeypatch):
"""
Test that the completion module is properly imported when using OpenAI provider
"""
module_name = "crewai.llms.providers.openai.completion"
if module_name in sys.modules:
del sys.modules[module_name]
monkeypatch.delitem(sys.modules, module_name, raising=False)
LLM(model="gpt-4o")
@@ -421,12 +494,25 @@ def test_openai_get_client_params_with_env_var():
client_params = llm._get_client_params()
assert client_params["base_url"] == "https://env.openai.com/v1"
def test_openai_get_client_params_with_legacy_api_base_env_var():
"""
Test that _get_client_params uses OPENAI_API_BASE when OPENAI_BASE_URL is absent.
"""
with patch.dict(os.environ, {
"OPENAI_API_BASE": "https://legacy-env.openai.com/v1",
}, clear=False):
os.environ.pop("OPENAI_BASE_URL", None)
llm = OpenAICompletion(model="gpt-4o")
client_params = llm._get_client_params()
assert client_params["base_url"] == "https://legacy-env.openai.com/v1"
def test_openai_get_client_params_priority_order():
"""
Test the priority order: base_url > api_base > OPENAI_BASE_URL env var
Test the priority order: base_url > api_base > OPENAI_BASE_URL > OPENAI_API_BASE
"""
with patch.dict(os.environ, {
"OPENAI_BASE_URL": "https://env.openai.com/v1",
"OPENAI_API_BASE": "https://legacy-env.openai.com/v1",
}):
llm1 = OpenAICompletion(
model="gpt-4o",