diff --git a/CHANGELOG.md b/CHANGELOG.md index 9941337f3..0663943a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/` 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 diff --git a/features/provider_registry_coverage.feature b/features/provider_registry_coverage.feature index 393b4c982..99526caef 100644 --- a/features/provider_registry_coverage.feature +++ b/features/provider_registry_coverage.feature @@ -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 diff --git a/features/steps/provider_registry_steps.py b/features/steps/provider_registry_steps.py index 4f97c32d7..c6736fbb8 100644 --- a/features/steps/provider_registry_steps.py +++ b/features/steps/provider_registry_steps.py @@ -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: diff --git a/src/cleveragents/providers/registry.py b/src/cleveragents/providers/registry.py index 45943375c..39dea639b 100644 --- a/src/cleveragents/providers/registry.py +++ b/src/cleveragents/providers/registry.py @@ -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(