Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions libs/partners/openrouter/langchain_openrouter/chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,23 @@ def model(self) -> str:
plugins: list[dict[str, Any]] | None = None
"""Plugins configuration for OpenRouter."""

default_headers: dict[str, str] | None = None
"""Additional HTTP headers to include on every request to OpenRouter.

Headers set here are merged into the underlying httpx client's default
headers and forwarded by OpenRouter to the upstream provider. Useful for
upstream provider features that require custom headers — for example,
xAI's ``x-grok-conv-id`` for sticky-routing prompt cache hits, or
provider-specific authentication, region routing, or A/B test bucketing
headers.

Example: ``{"x-grok-conv-id": "session-abc123"}``

Headers set via this field are merged with the OpenRouter app-attribution
headers (``HTTP-Referer``, ``X-Title``, ``X-OpenRouter-Categories``) — if a
key collides, the value from ``default_headers`` takes precedence.
"""

model_config = ConfigDict(populate_by_name=True)

@model_validator(mode="before")
Expand Down Expand Up @@ -348,6 +365,10 @@ def _build_client(self) -> Any:
extra_headers["X-Title"] = self.app_title
if self.app_categories:
extra_headers["X-OpenRouter-Categories"] = ",".join(self.app_categories)
# User-supplied headers are merged last so they take precedence over the
# built-in app-attribution headers if a key collides.
if self.default_headers:
extra_headers.update(self.default_headers)
if extra_headers:
import httpx # noqa: PLC0415

Expand Down
79 changes: 79 additions & 0 deletions libs/partners/openrouter/tests/unit_tests/test_chat_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,85 @@ def test_no_attribution_no_custom_clients(self) -> None:
assert "client" not in call_kwargs
assert "async_client" not in call_kwargs

def test_default_headers_passed_to_client(self) -> None:
"""Test that default_headers are forwarded to the underlying httpx clients.

Without this support, user-supplied headers like xAI's
``x-grok-conv-id`` (used for sticky-routing prompt cache hits) had no
way to reach the upstream provider — they were silently absorbed into
``model_kwargs`` by the ``build_extra`` validator.
"""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
default_headers={"x-grok-conv-id": "session-abc-123"},
)
call_kwargs = mock_cls.call_args[1]
# Custom httpx clients are created and the header is set on both.
assert "client" in call_kwargs
assert "async_client" in call_kwargs
sync_headers = call_kwargs["client"].headers
assert sync_headers["x-grok-conv-id"] == "session-abc-123"
async_headers = call_kwargs["async_client"].headers
assert async_headers["x-grok-conv-id"] == "session-abc-123"

def test_default_headers_coexist_with_app_attribution(self) -> None:
"""Test that default_headers merges with built-in attribution headers."""
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",
default_headers={
"x-grok-conv-id": "session-xyz",
"x-custom-trace-id": "trace-001",
},
)
call_kwargs = mock_cls.call_args[1]
sync_headers = call_kwargs["client"].headers
# Built-in attribution preserved
assert sync_headers["HTTP-Referer"] == "https://myapp.com"
assert sync_headers["X-Title"] == "My App"
# User-supplied headers also present
assert sync_headers["x-grok-conv-id"] == "session-xyz"
assert sync_headers["x-custom-trace-id"] == "trace-001"

def test_default_headers_override_app_attribution(self) -> None:
"""Test that default_headers takes precedence over collidng built-in keys."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_title="Default Title",
default_headers={"X-Title": "Override Title"},
)
call_kwargs = mock_cls.call_args[1]
sync_headers = call_kwargs["client"].headers
# default_headers wins over the built-in app_title-derived value
assert sync_headers["X-Title"] == "Override Title"

def test_default_headers_none_no_custom_headers(self) -> None:
"""Test that default_headers=None doesn't interfere with default behavior."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
default_headers=None,
)
call_kwargs = mock_cls.call_args[1]
# Default app-attribution headers still present
sync_headers = call_kwargs["client"].headers
assert sync_headers["HTTP-Referer"] == "https://docs.langchain.com"
assert sync_headers["X-Title"] == "LangChain"
# No spurious extra headers
assert "x-grok-conv-id" not in sync_headers

def test_reasoning_in_params(self) -> None:
"""Test that `reasoning` is included in default params."""
model = _make_model(reasoning={"effort": "high"})
Expand Down
Loading