fix(providers): add OpenRouter support to _create_provider_llm #10950
@@ -59,6 +59,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`create_llm()` raises `Unsupported provider type: openrouter`** (#10948): Fixed
|
||||
`ProviderRegistry._create_provider_llm()` missing an `OPENROUTER` branch, which
|
||||
caused `agents actor run openrouter/<model>` to fail with `ValueError`. Added a
|
||||
`ProviderType.OPENROUTER` branch that creates a `ChatOpenAI` instance configured
|
||||
with `openai_api_base="https://openrouter.ai/api/v1"` and the OpenRouter API key,
|
||||
matching the behavior of `create_ai_provider("openrouter")`. Supports optional
|
||||
`default_headers` kwarg with automatic string coercion for non-string keys/values.
|
||||
|
||||
- **LoadingThrobber Widget Restored** (#6357): Restored `LoadingThrobber` widget
|
||||
(`src/cleveragents/tui/widgets/throbber.py`) and its Robot Framework integration
|
||||
tests (`robot/tui_throbber.robot`) that were missing from master. Also restored
|
||||
|
||||
@@ -341,6 +341,72 @@ Feature: Provider Registry Coverage
|
||||
And the provider registry error should mention missing Azure endpoint
|
||||
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm builds OpenRouter client with ChatOpenAI
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
When I call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover"
|
||||
Then the stubbed openrouter client should be created with argument "model" value "claude-openrouter-cover"
|
||||
And the stubbed openrouter client should be created with argument "openai_api_base" value "https://openrouter.ai/api/v1"
|
||||
And the stubbed openrouter client should be created with argument "openai_api_key" value "sk-openrouter-test"
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm builds OpenRouter client with sanitized default headers
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
And the OpenRouter default headers include numeric entries
|
||||
When I call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover" and custom default headers
|
||||
Then the stubbed OpenRouter client headers should include "123" value "456"
|
||||
And the stubbed OpenRouter client headers should include "X-Debug" value "789"
|
||||
And the stubbed openrouter client should be created with argument "openai_api_key" value "sk-openrouter-test"
|
||||
And the stubbed OpenRouter client headers should not include key 123
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: create_llm succeeds for OpenRouter and delegates to _create_provider_llm
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And CLEVERAGENTS_DEFAULT_PROVIDER is not set
|
||||
And CLEVERAGENTS_DEFAULT_MODEL is not set
|
||||
And the provider registry LLM factory is stubbed
|
||||
When I call create_llm for provider "openrouter" and model "claude-openrouter"
|
||||
Then the stubbed LLM factory should be called for ProviderType.OPENROUTER with model "claude-openrouter"
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm adds organization headers for OpenRouter
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the OpenRouter organization setting is "my-org"
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
When I call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover"
|
||||
Then the stubbed OpenRouter client headers should include "HTTP-Referer" value "my-org"
|
||||
And the stubbed OpenRouter client headers should include "X-Title" value "my-org"
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm uses default model for OpenRouter when model_id is None
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
When I call _create_provider_llm for provider "openrouter" without specifying a model
|
||||
Then the stubbed openrouter client should be created with argument "model" value "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm treats empty default_headers as None for OpenRouter
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
When I call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover" and empty default headers
|
||||
Then the stubbed openrouter client should not have default_headers
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm forwards extra kwargs to ChatOpenAI for OpenRouter
|
||||
Given I have a ProviderRegistry with "openrouter" API key set
|
||||
And the "openrouter" LangChain client is stubbed
|
||||
When I call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover" and temperature 0.7
|
||||
Then the stubbed openrouter client should be created with argument "temperature" value "0.7"
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm raises error when OpenRouter API key is empty
|
||||
Given I have a ProviderRegistry with no API keys
|
||||
When I try to call _create_provider_llm for provider "openrouter" with model "claude-openrouter-cover"
|
||||
Then a provider registry ValueError should be raised
|
||||
And the provider registry error should mention missing "OPENROUTER_API_KEY" environment variable
|
||||
|
||||
@unit @providers @registry
|
||||
Scenario: _create_provider_llm raises error for unsupported provider
|
||||
Given I have a ProviderRegistry instance
|
||||
|
||||
@@ -520,6 +520,39 @@ def step_impl_call_private_llm_with_headers(
|
||||
)
|
||||
|
||||
|
||||
@when('I call _create_provider_llm for provider "{value}" without specifying a model')
|
||||
def step_impl_call_private_llm_no_model(context: Any, value: str) -> None:
|
||||
provider_enum = _ensure_provider_type(context)
|
||||
provider_type = getattr(provider_enum, value.upper())
|
||||
context.created_llm = context.registry._create_provider_llm(provider_type, None)
|
||||
|
||||
|
||||
@when(
|
||||
'I call _create_provider_llm for provider "{value}" with model "{model}" and empty default headers'
|
||||
)
|
||||
def step_impl_call_private_llm_empty_headers(
|
||||
context: Any, value: str, model: str
|
||||
) -> None:
|
||||
provider_enum = _ensure_provider_type(context)
|
||||
provider_type = getattr(provider_enum, value.upper())
|
||||
context.created_llm = context.registry._create_provider_llm(
|
||||
provider_type, model, default_headers={}
|
||||
)
|
||||
|
||||
|
||||
@when(
|
||||
'I call _create_provider_llm for provider "{value}" with model "{model}" and temperature {temperature}'
|
||||
)
|
||||
def step_impl_call_private_llm_with_temperature(
|
||||
context: Any, value: str, model: str, temperature: str
|
||||
) -> None:
|
||||
provider_enum = _ensure_provider_type(context)
|
||||
provider_type = getattr(provider_enum, value.upper())
|
||||
context.created_llm = context.registry._create_provider_llm(
|
||||
provider_type, model, temperature=float(temperature)
|
||||
)
|
||||
|
||||
|
||||
@when('I try to call _create_provider_llm for provider "{value}" with model "{model}"')
|
||||
def step_impl_try_private_llm(context: Any, value: str, model: str) -> None:
|
||||
provider_enum = _ensure_provider_type(context)
|
||||
@@ -859,9 +892,17 @@ def step_impl_stubbed_client_argument(
|
||||
calls = client_data["calls"]
|
||||
assert calls, f"Expected {provider_name} client to be instantiated"
|
||||
last_call = calls[-1]
|
||||
assert last_call["kwargs"].get(arg_name) == expected, (
|
||||
f"Expected {arg_name}={expected!r}, got {last_call['kwargs'].get(arg_name)!r}"
|
||||
)
|
||||
actual = last_call["kwargs"].get(arg_name)
|
||||
if actual != expected:
|
||||
try:
|
||||
expected_num = float(expected)
|
||||
assert actual == expected_num, (
|
||||
f"Expected {arg_name}={expected!r} or {expected_num!r}, got {actual!r}"
|
||||
)
|
||||
except ValueError:
|
||||
raise AssertionError(
|
||||
f"Expected {arg_name}={expected!r}, got {actual!r}"
|
||||
) from None
|
||||
assert isinstance(context.created_llm, client_data["class"])
|
||||
|
||||
|
||||
@@ -883,6 +924,45 @@ def step_impl_stubbed_openrouter_headers(
|
||||
)
|
||||
|
||||
|
||||
@then("the stubbed OpenRouter client headers should not include key {key}")
|
||||
def step_impl_stubbed_openrouter_headers_not_include_key(
|
||||
context: Any, key: str
|
||||
) -> None:
|
||||
stubbed_clients = getattr(context, "stubbed_clients", {})
|
||||
client_data = stubbed_clients.get("openrouter")
|
||||
assert client_data, "No stubbed client recorded for OpenRouter"
|
||||
calls = client_data["calls"]
|
||||
assert calls, "Expected OpenRouter client to be instantiated"
|
||||
headers = calls[-1]["kwargs"].get("default_headers")
|
||||
assert isinstance(headers, dict), "Expected default_headers to be a dict"
|
||||
# Check both integer and string representations of the key
|
||||
try:
|
||||
key_int = int(key)
|
||||
except ValueError:
|
||||
key_int = key
|
||||
assert key_int not in headers, (
|
||||
f"Expected key {key_int!r} to be absent from headers, but it was present"
|
||||
)
|
||||
|
||||
|
||||
@then("the stubbed {provider_name} client should not have default_headers")
|
||||
def step_impl_stubbed_client_no_default_headers(
|
||||
context: Any, provider_name: str
|
||||
) -> None:
|
||||
provider_key = provider_name.lower()
|
||||
stubbed_clients = getattr(context, "stubbed_clients", {})
|
||||
assert provider_key in stubbed_clients, (
|
||||
f"No stubbed client recorded for {provider_name}"
|
||||
)
|
||||
client_data = stubbed_clients[provider_key]
|
||||
calls = client_data["calls"]
|
||||
assert calls, f"Expected {provider_name} client to be instantiated"
|
||||
last_call = calls[-1]
|
||||
assert "default_headers" not in last_call["kwargs"], (
|
||||
f"Expected default_headers to be absent, got {last_call['kwargs'].get('default_headers')!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the instance should be frozen")
|
||||
def step_impl_instance_frozen(context: Any) -> None:
|
||||
try:
|
||||
|
||||
@@ -549,6 +549,38 @@ class ProviderRegistry:
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if provider_type == ProviderType.OPENROUTER:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
api_key = self._settings.openrouter_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"Provider openrouter requires OPENROUTER_API_KEY to be set."
|
||||
)
|
||||
raw_headers = kwargs.pop("default_headers", None)
|
||||
sanitized_headers: dict[str, str] | None = (
|
||||
{str(k): str(v) for k, v in raw_headers.items()}
|
||||
if raw_headers
|
||||
else None
|
||||
)
|
||||
org_value = self._settings.openrouter_organization
|
||||
if org_value:
|
||||
org_value = org_value.strip()
|
||||
if org_value:
|
||||
if sanitized_headers is None:
|
||||
sanitized_headers = {}
|
||||
sanitized_headers.setdefault("HTTP-Referer", org_value)
|
||||
sanitized_headers.setdefault("X-Title", org_value)
|
||||
openrouter_kwargs: dict[str, Any] = {
|
||||
"model": model_id or "anthropic/claude-sonnet-4-20250514",
|
||||
"openai_api_base": "https://openrouter.ai/api/v1",
|
||||
"openai_api_key": api_key,
|
||||
}
|
||||
if sanitized_headers:
|
||||
openrouter_kwargs["default_headers"] = sanitized_headers
|
||||
openrouter_kwargs.update(kwargs)
|
||||
return ChatOpenAI(**openrouter_kwargs)
|
||||
|
||||
raise ValueError(f"Unsupported provider type: {provider_type}")
|
||||
|
||||
def create_ai_provider(
|
||||
|
||||
Reference in New Issue
Block a user