mirror of
https://github.com/hwchase17/langchain.git
synced 2026-04-03 19:04:23 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
2
libs/partners/openrouter/uv.lock
generated
2
libs/partners/openrouter/uv.lock
generated
@@ -334,7 +334,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.22"
|
||||
version = "1.2.23"
|
||||
source = { editable = "../../core" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
||||
Reference in New Issue
Block a user