mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-11 09:26:29 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user