fix(openrouter): pass attribution headers via httpx default_headers (#36347)

Fixes #36339

---

The `openrouter` SDK v0.8.0 renamed `x_title` to `x_open_router_title`,
breaking `ChatOpenRouter` instantiation with the default `app_title`.

Rather than chasing SDK parameter renames across versions, all three
attribution headers are now injected via httpx `default_headers` —
version-agnostic and consistent with how `app_categories` was already
handled.

## Changes
- Pass `HTTP-Referer`, `X-Title`, and `X-OpenRouter-Categories` as httpx
client default headers in `_build_client` instead of SDK constructor
kwargs (`http_referer`, `x_title`), making the integration compatible
across `openrouter>=0.7.11,<1.0.0`
- Move `_build_client()` inside the `try/except ImportError` in
`validate_environment` so a version-mismatch `ImportError` from
`openrouter.utils` gets the friendly install message instead of a raw
traceback
- Add `warnings.warn` in `_wrap_messages_for_sdk` for two previously
silent fallbacks: failed `openrouter.components` import (file blocks
sent as raw dicts) and unknown message roles passed through to the API
- Clarify `max_retries` docstring to explain the ~150s-per-unit backoff
mapping; drop stale `(v0.6.0)` version reference in
`_wrap_messages_for_sdk`
This commit is contained in:
Mason Daugherty
2026-03-28 17:48:55 -04:00
committed by GitHub
parent 7421768d6f
commit 4d9842da67
3 changed files with 83 additions and 34 deletions

View File

@@ -202,7 +202,8 @@ class ChatOpenRouter(BaseChatModel):
max_retries: int = 2
"""Maximum number of retries.
Controls the retry backoff window via the SDK's `max_elapsed_time`.
Each unit adds ~150 seconds to the backoff window via the SDK's
`max_elapsed_time` (e.g. `max_retries=2` allows up to ~300 s).
Set to `0` to disable retries.
"""
@@ -340,21 +341,16 @@ class ChatOpenRouter(BaseChatModel):
}
if self.openrouter_api_base:
client_kwargs["server_url"] = self.openrouter_api_base
extra_headers: dict[str, str] = {}
if self.app_url:
client_kwargs["http_referer"] = self.app_url
extra_headers["HTTP-Referer"] = self.app_url
if self.app_title:
client_kwargs["x_title"] = self.app_title
extra_headers["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.
extra_headers["X-OpenRouter-Categories"] = ",".join(self.app_categories)
if extra_headers:
import httpx # noqa: PLC0415
extra_headers = {
"X-OpenRouter-Categories": ",".join(self.app_categories),
}
client_kwargs["client"] = httpx.Client(
headers=extra_headers, follow_redirects=True
)
@@ -389,13 +385,14 @@ class ChatOpenRouter(BaseChatModel):
if not self.client:
try:
import openrouter # noqa: PLC0415, F401
self.client = self._build_client()
except ImportError as e:
msg = (
"Could not import the `openrouter` Python SDK. "
"Please install it with: pip install openrouter"
)
raise ImportError(msg) from e
self.client = self._build_client()
return self
def _resolve_model_profile(self) -> ModelProfile | None:
@@ -974,7 +971,7 @@ def _wrap_messages_for_sdk(
) -> list[dict[str, Any]] | list[Any]:
"""Wrap message dicts as SDK Pydantic models when file blocks are present.
The OpenRouter Python SDK (v0.6.0) does not include `file` in its
The OpenRouter Python SDK does not include `file` in its
`ChatMessageContentItem` discriminated union, so Pydantic validation
rejects file content blocks even though the OpenRouter **API** supports
them. Using `model_construct` on the SDK's message classes bypasses
@@ -996,6 +993,11 @@ def _wrap_messages_for_sdk(
try:
from openrouter import components # noqa: PLC0415
except ImportError:
warnings.warn(
"Could not import openrouter.components; file content blocks "
"will be sent as raw dicts which may cause validation errors.",
stacklevel=2,
)
return message_dicts
role_to_model: dict[str, type[BaseModel]] = {
@@ -1010,7 +1012,11 @@ def _wrap_messages_for_sdk(
for msg in message_dicts:
model_cls = role_to_model.get(msg.get("role", ""))
if model_cls is None:
# Unknown role — pass dict through and hope for the best.
warnings.warn(
f"Unknown message role {msg.get('role')!r} encountered during "
f"SDK wrapping; passing raw dict to the API.",
stacklevel=2,
)
wrapped.append(msg)
continue
wrapped.append(model_cls.model_construct(**msg))

View File

@@ -288,7 +288,7 @@ class TestChatOpenRouterInstantiation:
assert model.client is client_1
def test_app_url_passed_to_client(self) -> None:
"""Test that app_url is passed as http_referer to the SDK client."""
"""Test that app_url is passed as HTTP-Referer header via httpx clients."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
@@ -297,10 +297,10 @@ class TestChatOpenRouterInstantiation:
app_url="https://myapp.com",
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["http_referer"] == "https://myapp.com"
assert call_kwargs["client"].headers["HTTP-Referer"] == "https://myapp.com"
def test_app_title_passed_to_client(self) -> None:
"""Test that app_title is passed as x_title to the SDK client."""
"""Test that app_title is passed as X-Title header via httpx clients."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
@@ -309,7 +309,7 @@ class TestChatOpenRouterInstantiation:
app_title="My App",
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["x_title"] == "My App"
assert call_kwargs["client"].headers["X-Title"] == "My App"
def test_default_attribution_headers(self) -> None:
"""Test that default attribution headers are sent when not overridden."""
@@ -320,8 +320,9 @@ class TestChatOpenRouterInstantiation:
api_key=SecretStr("test-key"),
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["http_referer"] == ("https://docs.langchain.com")
assert call_kwargs["x_title"] == "LangChain"
sync_headers = call_kwargs["client"].headers
assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"
assert sync_headers["X-Title"] == "LangChain"
def test_user_attribution_overrides_defaults(self) -> None:
"""Test that user-supplied attribution overrides the defaults."""
@@ -334,8 +335,9 @@ class TestChatOpenRouterInstantiation:
app_title="My Custom App",
)
call_kwargs = mock_cls.call_args[1]
assert call_kwargs["http_referer"] == "https://my-custom-app.com"
assert call_kwargs["x_title"] == "My Custom App"
sync_headers = call_kwargs["client"].headers
assert sync_headers["HTTP-Referer"] == "https://my-custom-app.com"
assert sync_headers["X-Title"] == "My Custom App"
def test_app_categories_passed_to_client(self) -> None:
"""Test that app_categories injects custom httpx clients with header."""
@@ -360,8 +362,8 @@ class TestChatOpenRouterInstantiation:
"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."""
def test_app_categories_none_no_categories_header(self) -> None:
"""Test that no X-OpenRouter-Categories header when categories unset."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
@@ -369,11 +371,12 @@ class TestChatOpenRouterInstantiation:
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
# httpx clients still created for X-Title default
sync_headers = call_kwargs["client"].headers
assert "X-OpenRouter-Categories" not in sync_headers
def test_app_categories_empty_list_no_custom_clients(self) -> None:
"""Test that an empty list does not inject custom httpx clients."""
def test_app_categories_empty_list_no_categories_header(self) -> None:
"""Test that an empty list does not inject categories header."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
@@ -382,8 +385,8 @@ class TestChatOpenRouterInstantiation:
app_categories=[],
)
call_kwargs = mock_cls.call_args[1]
assert "client" not in call_kwargs
assert "async_client" not in call_kwargs
sync_headers = call_kwargs["client"].headers
assert "X-OpenRouter-Categories" not in sync_headers
def test_app_categories_with_other_attribution(self) -> None:
"""Test that app_categories coexists with app_url and app_title."""
@@ -397,12 +400,52 @@ class TestChatOpenRouterInstantiation:
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["HTTP-Referer"] == "https://myapp.com"
assert sync_headers["X-Title"] == "My App"
assert sync_headers["X-OpenRouter-Categories"] == "cli-agent"
def test_app_title_none_no_x_title_header(self) -> None:
"""Test that X-Title header is omitted when app_title is explicitly None."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_title=None,
)
call_kwargs = mock_cls.call_args[1]
sync_headers = call_kwargs["client"].headers
assert "X-Title" not in sync_headers
def test_app_url_none_no_referer_header(self) -> None:
"""Test that HTTP-Referer header is omitted when app_url is explicitly None."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_url=None,
)
call_kwargs = mock_cls.call_args[1]
sync_headers = call_kwargs["client"].headers
assert "HTTP-Referer" not in sync_headers
def test_no_attribution_no_custom_clients(self) -> None:
"""Test that no httpx clients are created when all attribution is None."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_url=None,
app_title=None,
app_categories=None,
)
call_kwargs = mock_cls.call_args[1]
assert "client" not in call_kwargs
assert "async_client" not in call_kwargs
def test_reasoning_in_params(self) -> None:
"""Test that `reasoning` is included in default params."""
model = _make_model(reasoning={"effort": "high"})

View File

@@ -334,7 +334,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.22"
version = "1.2.23"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },