fix(providers): add OpenRouter support to _create_provider_llm #10950

Merged
HAL9000 merged 1 commits from bugfix/m3-openrouter-create-llm into master 2026-05-03 01:37:50 +00:00
4 changed files with 189 additions and 3 deletions
+8
View File
@@ -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
+83 -3
View File
@@ -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:
+32
View File
@@ -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(