mirror of
https://github.com/hwchase17/langchain.git
synced 2026-04-03 19:04:23 +00:00
feat(openrouter): add app_categories field for marketplace attribution (#36205)
Add support for the `X-OpenRouter-Categories` header via a new `app_categories` field on `ChatOpenRouter`, and extract inline client construction into a dedicated `_build_client` method.
This commit is contained in:
@@ -118,6 +118,7 @@ class ChatOpenRouter(BaseChatModel):
|
||||
| `timeout` | `int | None` | Timeout in milliseconds. |
|
||||
| `app_url` | `str | None` | App URL for attribution. |
|
||||
| `app_title` | `str | None` | App title for attribution. |
|
||||
| `app_categories` | `list[str] | None` | Marketplace attribution categories. |
|
||||
| `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. |
|
||||
|
||||
??? info "Instantiate"
|
||||
@@ -174,12 +175,27 @@ class ChatOpenRouter(BaseChatModel):
|
||||
|
||||
Maps to `X-Title` header.
|
||||
|
||||
Defaults to `'LangChain'`. Set this to your app's URL to get attribution for
|
||||
API usage in the OpenRouter dashboard.
|
||||
Defaults to `'LangChain'`. Set this to your app's name to get attribution
|
||||
for API usage in the OpenRouter dashboard.
|
||||
|
||||
See https://openrouter.ai/docs/app-attribution for details.
|
||||
"""
|
||||
|
||||
app_categories: list[str] | None = Field(
|
||||
default=None,
|
||||
)
|
||||
"""Marketplace categories for OpenRouter attribution.
|
||||
|
||||
Maps to `X-OpenRouter-Categories` header. Pass a list of lowercase,
|
||||
hyphen-separated category strings (max 30 characters each),
|
||||
e.g. `['cli-agent', 'programming-app']`.
|
||||
|
||||
Only recognized categories are accepted (unrecognized values are silently
|
||||
dropped by OpenRouter).
|
||||
|
||||
See https://openrouter.ai/docs/app-attribution for recognized categories.
|
||||
"""
|
||||
|
||||
request_timeout: int | None = Field(default=None, alias="timeout")
|
||||
"""Timeout for requests in milliseconds. Maps to SDK `timeout_ms`."""
|
||||
|
||||
@@ -307,6 +323,59 @@ class ChatOpenRouter(BaseChatModel):
|
||||
values["model_kwargs"] = extra
|
||||
return values
|
||||
|
||||
def _build_client(self) -> Any:
|
||||
"""Build and return an `openrouter.OpenRouter` SDK client.
|
||||
|
||||
Returns:
|
||||
An `openrouter.OpenRouter` SDK client instance.
|
||||
"""
|
||||
import openrouter # noqa: PLC0415
|
||||
from openrouter.utils import ( # noqa: PLC0415
|
||||
BackoffStrategy,
|
||||
RetryConfig,
|
||||
)
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": self.openrouter_api_key.get_secret_value(), # type: ignore[union-attr]
|
||||
}
|
||||
if self.openrouter_api_base:
|
||||
client_kwargs["server_url"] = self.openrouter_api_base
|
||||
if self.app_url:
|
||||
client_kwargs["http_referer"] = self.app_url
|
||||
if self.app_title:
|
||||
client_kwargs["x_title"] = self.app_title
|
||||
if self.app_categories:
|
||||
# The SDK lacks a native constructor param for X-OpenRouter-Categories,
|
||||
# so inject the header via custom httpx clients. The SDK sets its own
|
||||
# headers (Authorization, HTTP-Referer, X-Title) per-request, and httpx
|
||||
# merges client-default headers with per-request headers, so nothing is
|
||||
# lost.
|
||||
import httpx # noqa: PLC0415
|
||||
|
||||
extra_headers = {
|
||||
"X-OpenRouter-Categories": ",".join(self.app_categories),
|
||||
}
|
||||
client_kwargs["client"] = httpx.Client(
|
||||
headers=extra_headers, follow_redirects=True
|
||||
)
|
||||
client_kwargs["async_client"] = httpx.AsyncClient(
|
||||
headers=extra_headers, follow_redirects=True
|
||||
)
|
||||
if self.request_timeout is not None:
|
||||
client_kwargs["timeout_ms"] = self.request_timeout
|
||||
if self.max_retries > 0:
|
||||
client_kwargs["retry_config"] = RetryConfig(
|
||||
strategy="backoff",
|
||||
backoff=BackoffStrategy(
|
||||
initial_interval=500,
|
||||
max_interval=60000,
|
||||
exponent=1.5,
|
||||
max_elapsed_time=self.max_retries * 150_000,
|
||||
),
|
||||
retry_connection_errors=True,
|
||||
)
|
||||
return openrouter.OpenRouter(**client_kwargs)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_environment(self) -> Self:
|
||||
"""Validate configuration and build the SDK client."""
|
||||
@@ -319,41 +388,14 @@ class ChatOpenRouter(BaseChatModel):
|
||||
|
||||
if not self.client:
|
||||
try:
|
||||
import openrouter # noqa: PLC0415
|
||||
from openrouter.utils import ( # noqa: PLC0415
|
||||
BackoffStrategy,
|
||||
RetryConfig,
|
||||
)
|
||||
import openrouter # noqa: PLC0415, F401
|
||||
except ImportError as e:
|
||||
msg = (
|
||||
"Could not import the `openrouter` Python SDK. "
|
||||
"Please install it with: pip install openrouter"
|
||||
)
|
||||
raise ImportError(msg) from e
|
||||
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"api_key": self.openrouter_api_key.get_secret_value(),
|
||||
}
|
||||
if self.openrouter_api_base:
|
||||
client_kwargs["server_url"] = self.openrouter_api_base
|
||||
if self.app_url:
|
||||
client_kwargs["http_referer"] = self.app_url
|
||||
if self.app_title:
|
||||
client_kwargs["x_title"] = self.app_title
|
||||
if self.request_timeout is not None:
|
||||
client_kwargs["timeout_ms"] = self.request_timeout
|
||||
if self.max_retries > 0:
|
||||
client_kwargs["retry_config"] = RetryConfig(
|
||||
strategy="backoff",
|
||||
backoff=BackoffStrategy(
|
||||
initial_interval=500,
|
||||
max_interval=60000,
|
||||
exponent=1.5,
|
||||
max_elapsed_time=self.max_retries * 150_000,
|
||||
),
|
||||
retry_connection_errors=True,
|
||||
)
|
||||
self.client = openrouter.OpenRouter(**client_kwargs)
|
||||
self.client = self._build_client()
|
||||
return self
|
||||
|
||||
def _resolve_model_profile(self) -> ModelProfile | None:
|
||||
|
||||
@@ -337,6 +337,72 @@ class TestChatOpenRouterInstantiation:
|
||||
assert call_kwargs["http_referer"] == "https://my-custom-app.com"
|
||||
assert call_kwargs["x_title"] == "My Custom App"
|
||||
|
||||
def test_app_categories_passed_to_client(self) -> None:
|
||||
"""Test that app_categories injects custom httpx clients with header."""
|
||||
with patch("openrouter.OpenRouter") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
ChatOpenRouter(
|
||||
model=MODEL_NAME,
|
||||
api_key=SecretStr("test-key"),
|
||||
app_categories=["cli-agent", "programming-app"],
|
||||
)
|
||||
call_kwargs = mock_cls.call_args[1]
|
||||
# Custom httpx clients should be created
|
||||
assert "client" in call_kwargs
|
||||
assert "async_client" in call_kwargs
|
||||
# Verify the header value is comma-joined
|
||||
sync_headers = call_kwargs["client"].headers
|
||||
assert sync_headers["X-OpenRouter-Categories"] == (
|
||||
"cli-agent,programming-app"
|
||||
)
|
||||
async_headers = call_kwargs["async_client"].headers
|
||||
assert async_headers["X-OpenRouter-Categories"] == (
|
||||
"cli-agent,programming-app"
|
||||
)
|
||||
|
||||
def test_app_categories_none_no_custom_clients(self) -> None:
|
||||
"""Test that no custom httpx clients are created when categories unset."""
|
||||
with patch("openrouter.OpenRouter") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
ChatOpenRouter(
|
||||
model=MODEL_NAME,
|
||||
api_key=SecretStr("test-key"),
|
||||
)
|
||||
call_kwargs = mock_cls.call_args[1]
|
||||
assert "client" not in call_kwargs
|
||||
assert "async_client" not in call_kwargs
|
||||
|
||||
def test_app_categories_empty_list_no_custom_clients(self) -> None:
|
||||
"""Test that an empty list does not inject custom httpx clients."""
|
||||
with patch("openrouter.OpenRouter") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
ChatOpenRouter(
|
||||
model=MODEL_NAME,
|
||||
api_key=SecretStr("test-key"),
|
||||
app_categories=[],
|
||||
)
|
||||
call_kwargs = mock_cls.call_args[1]
|
||||
assert "client" not in call_kwargs
|
||||
assert "async_client" not in call_kwargs
|
||||
|
||||
def test_app_categories_with_other_attribution(self) -> None:
|
||||
"""Test that app_categories coexists with app_url and app_title."""
|
||||
with patch("openrouter.OpenRouter") as mock_cls:
|
||||
mock_cls.return_value = MagicMock()
|
||||
ChatOpenRouter(
|
||||
model=MODEL_NAME,
|
||||
api_key=SecretStr("test-key"),
|
||||
app_url="https://myapp.com",
|
||||
app_title="My App",
|
||||
app_categories=["cli-agent"],
|
||||
)
|
||||
call_kwargs = mock_cls.call_args[1]
|
||||
assert call_kwargs["http_referer"] == "https://myapp.com"
|
||||
assert call_kwargs["x_title"] == "My App"
|
||||
assert "client" in call_kwargs
|
||||
sync_headers = call_kwargs["client"].headers
|
||||
assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"
|
||||
|
||||
def test_reasoning_in_params(self) -> None:
|
||||
"""Test that `reasoning` is included in default params."""
|
||||
model = _make_model(reasoning={"effort": "high"})
|
||||
|
||||
Reference in New Issue
Block a user