fix(openrouter): support default_headers for custom HTTP header injection (#36582)

Fixes #36581

## Problem

`ChatOpenRouter` had no way to set custom HTTP headers on requests to
OpenRouter. Passing `default_headers` to the constructor silently
misfired: `build_extra` treated it as an unrecognized kwarg, emitted a
"transferred to model_kwargs" warning, and dumped the header into the
request body instead of the HTTP layer. This blocked any feature that
needs per-request header injection — for example xAI's `x-grok-conv-id`
for sticky-routing prompt cache hits.

## What changed

- `default_headers` is now a first-class field on `ChatOpenRouter`
(`Mapping[str, str] | None`). Because headers may carry credentials, the
field is excluded from serialization.
- User-supplied headers are merged with the built-in app-attribution
headers (`HTTP-Referer`, `X-Title`, `X-OpenRouter-Categories`). On
collision the user value wins; because HTTP header names are
case-insensitive, the merge drops any built-in whose name
case-insensitively matches a user header before applying, so
`http-referer` replaces `HTTP-Referer` rather than producing a doubled
header.
- Corrected the documented `session_id` length limit from 128 to 256
characters.

Example:

    ChatOpenRouter(
        model="x-ai/grok-4",
        default_headers={"x-grok-conv-id": "session-abc123"},
    )

---------

Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
Hamza Kyamanywa
2026-07-06 05:40:25 +09:00
committed by GitHub
parent c44a9c9fdf
commit cf08ef0a29
2 changed files with 237 additions and 1 deletions

View File

@@ -140,6 +140,7 @@ class ChatOpenRouter(BaseChatModel):
| `app_categories` | `list[str] | None` | Marketplace attribution categories. |
| `session_id` | `str | None` | Group related requests for observability. |
| `trace` | `dict[str, Any] | None` | Trace metadata for broadcasts. |
| `default_headers` | `Mapping[str, str] | None` | Extra request headers. |
| `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. |
??? info "Instantiate"
@@ -313,6 +314,28 @@ class ChatOpenRouter(BaseChatModel):
plugins: list[dict[str, Any]] | None = None
"""Plugins configuration for OpenRouter."""
default_headers: Mapping[str, str] | None = Field(default=None, exclude=True)
"""Additional HTTP headers to include on every request to OpenRouter.
Headers set here become the underlying httpx client's default headers, so
they are sent on every request to OpenRouter. Useful for upstream provider
features that require custom headers — for example, xAI's `x-grok-conv-id`
for sticky-routing prompt cache hits, region routing, A/B test bucketing
headers, or provider-specific authentication. Whether a given header is
forwarded to the upstream provider (versus consumed by OpenRouter itself)
is determined by OpenRouter; consult its docs for which headers propagate.
Because these headers may contain credentials, they are excluded from
LangChain serialization.
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`); on a
collision the value from `default_headers` takes precedence, matched
case-insensitively (HTTP header names are case-insensitive).
"""
session_id: str | None = Field(
default_factory=from_env("OPENROUTER_SESSION_ID", default=None),
)
@@ -321,7 +344,7 @@ class ChatOpenRouter(BaseChatModel):
Useful any time multiple requests should share an observability
grouping (e.g. a conversation, an agent workflow, a batch job, or a CI
run). Equivalent to setting the `x-session-id` HTTP header on the
underlying request. OpenRouter rejects values longer than 128
underlying request. OpenRouter rejects values longer than 256
characters.
Falls back to the `OPENROUTER_SESSION_ID` environment variable when
@@ -405,6 +428,19 @@ class ChatOpenRouter(BaseChatModel):
extra_headers["X-Title"] = self.app_title
if self.app_categories:
extra_headers["X-OpenRouter-Categories"] = ",".join(self.app_categories)
# User-supplied headers take precedence over the built-in
# app-attribution headers on collision. HTTP header names are
# case-insensitive, so drop any built-in whose name case-insensitively
# matches a user header before merging; a plain dict update would keep
# both spellings and httpx would then send a doubled header.
if self.default_headers:
user_header_names = {name.casefold() for name in self.default_headers}
extra_headers = {
name: value
for name, value in extra_headers.items()
if name.casefold() not in user_header_names
}
extra_headers.update(self.default_headers)
if extra_headers:
import httpx # noqa: PLC0415

View File

@@ -508,6 +508,179 @@ class TestChatOpenRouterInstantiation:
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 httpx clients.
Before this field existed, setting `default_headers` had no effect on
the HTTP layer: `build_extra` diverted the unrecognized parameter into
`model_kwargs` (with a "not default parameter" warning), so the header
never reached the outbound request.
"""
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 colliding 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_default_headers_sole_source_creates_client(self) -> None:
"""`default_headers` alone (no app attribution) still creates httpx clients.
Guards against a regression where the `if extra_headers:` check runs
before `default_headers` is merged in — the header would then be
dropped and no custom client created.
"""
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,
default_headers={"x-grok-conv-id": "session-solo"},
)
call_kwargs = mock_cls.call_args[1]
assert "client" in call_kwargs
assert "async_client" in call_kwargs
assert call_kwargs["client"].headers["x-grok-conv-id"] == "session-solo"
assert (
call_kwargs["async_client"].headers["x-grok-conv-id"] == "session-solo"
)
def test_default_headers_override_app_categories(self) -> None:
"""`default_headers` can override the built-in `X-OpenRouter-Categories`."""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_categories=["programming", "translation"],
default_headers={"X-OpenRouter-Categories": "custom-category"},
)
call_kwargs = mock_cls.call_args[1]
sync_headers = call_kwargs["client"].headers
assert sync_headers["X-OpenRouter-Categories"] == "custom-category"
def test_default_headers_empty_dict_no_custom_client(self) -> None:
"""An empty `default_headers` dict behaves like `None` (no custom client)."""
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,
default_headers={},
)
call_kwargs = mock_cls.call_args[1]
assert "client" not in call_kwargs
assert "async_client" not in call_kwargs
def test_default_headers_override_is_case_insensitive(self) -> None:
"""A case-variant user header overrides the built-in, not doubles it.
HTTP header names are case-insensitive, so `default_headers` keyed with
different casing than a built-in attribution header (`http-referer` vs
`HTTP-Referer`) must replace it rather than send both values.
"""
with patch("openrouter.OpenRouter") as mock_cls:
mock_cls.return_value = MagicMock()
ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
app_url="https://builtin.example",
default_headers={"http-referer": "https://override.example"},
)
sync_headers = mock_cls.call_args[1]["client"].headers
# httpx headers are case-insensitive: the override value wins under
# either spelling, with no comma-joined doubling.
assert sync_headers["HTTP-Referer"] == "https://override.example"
assert sync_headers["http-referer"] == "https://override.example"
assert "," not in sync_headers["HTTP-Referer"]
def test_default_headers_not_swept_into_model_kwargs(self) -> None:
"""`default_headers` is a first-class field, not extra `model_kwargs`.
Guards the motivating regression: `build_extra` must recognize
`default_headers`, leave it out of `model_kwargs`, and not emit the
"not default parameter" warning that unrecognized kwargs trigger.
"""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
model = ChatOpenRouter(
model=MODEL_NAME,
api_key=SecretStr("test-key"),
default_headers={"x-grok-conv-id": "session-abc-123"},
)
assert model.model_kwargs == {}
assert not any("not default parameter" in str(w.message) for w in caught)
def test_reasoning_in_params(self) -> None:
"""Test that `reasoning` is included in default params."""
model = _make_model(reasoning={"effort": "high"})
@@ -577,6 +750,33 @@ class TestSerialization:
serialized = dumps(model)
assert "super-secret-key" not in serialized
def test_dumpd_excludes_default_headers(self) -> None:
"""Test that default_headers are excluded from serialized kwargs."""
model = _make_model(
default_headers={
"Authorization": "Bearer provider-token",
"x-grok-conv-id": "session-abc-123",
}
)
serialized = dumpd(model)
assert "default_headers" not in serialized["kwargs"]
def test_dumps_does_not_leak_default_headers(self) -> None:
"""Test that dumps output does not contain default header values."""
model = _make_model(
default_headers={
"Authorization": "Bearer provider-token",
"x-grok-conv-id": "session-abc-123",
}
)
serialized = dumps(model)
assert "provider-token" not in serialized
assert "session-abc-123" not in serialized
# ===========================================================================
# Mocked generate / stream tests