fix(providers): add OpenRouter support to _create_provider_llm
CI / lint (push) Successful in 55s
CI / quality (push) Successful in 1m8s
CI / build (push) Successful in 32s
CI / typecheck (push) Successful in 1m31s
CI / security (push) Successful in 1m31s
CI / helm (push) Successful in 26s
CI / push-validation (push) Successful in 19s
CI / integration_tests (push) Successful in 4m14s
CI / e2e_tests (push) Failing after 4m15s
CI / unit_tests (push) Successful in 6m11s
CI / docker (push) Successful in 1m34s
CI / coverage (push) Successful in 11m44s
CI / status-check (push) Failing after 3s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Successful in 1h30m44s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 52s
CI / quality (pull_request) Failing after 1m1s
CI / typecheck (pull_request) Failing after 1m3s
CI / lint (pull_request) Failing after 1m5s
CI / security (pull_request) Failing after 1m3s
CI / integration_tests (pull_request) Failing after 1m1s
CI / e2e_tests (pull_request) Failing after 1m1s
CI / build (pull_request) Failing after 1m1s
CI / push-validation (pull_request) Successful in 1m31s
CI / helm (pull_request) Successful in 1m5s
CI / unit_tests (pull_request) Failing after 1m0s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 6s

Add a ProviderType.OPENROUTER branch to ProviderRegistry._create_provider_llm()
so that create_llm("openrouter") returns a configured LangChain BaseLanguageModel
instead of raising ValueError("Unsupported provider type: openrouter").

The new branch creates a ChatOpenAI instance with openai_api_base set to the
OpenRouter gateway URL and openai_api_key from settings, matching the credentials
used by create_ai_provider(). Optional default_headers are sanitized to
dict[str, str] (string-coerced keys and values) via inline dict comprehension.

Also includes review fixes:
- M1: Added openrouter_organization header support (HTTP-Referer, X-Title)
- m1: Added explicit ValueError when openrouter_api_key is empty/missing
- M2: Added openai_api_key assertions to all OpenRouter BDD scenarios
- m2: Added default model fallback scenario for model_id=None
- m4: Added empty default_headers edge case scenario
- m5: Added extra kwargs forwarding scenario (temperature)
- m6: Added empty API key error scenario
- m7: Added negative assertion verifying original integer key 123 is absent
- m8: Added openai_api_key assertion to sanitized headers scenario

ISSUES CLOSED: #10948
This commit was merged in pull request #10950.
This commit is contained in:
2026-05-01 07:11:32 +00:00
committed by Forgejo
parent dde71cf8e2
commit 9b7a0543d0
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(