From cf08ef0a290554b3d9003584abaf0608d7f0a84f Mon Sep 17 00:00:00 2001 From: Hamza Kyamanywa Date: Mon, 6 Jul 2026 05:40:25 +0900 Subject: [PATCH] fix(openrouter): support `default_headers` for custom HTTP header injection (#36582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../langchain_openrouter/chat_models.py | 38 +++- .../tests/unit_tests/test_chat_models.py | 200 ++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 3fe32a2812d..293c2d0a4c8 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -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 diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index 218a7b2e5ed..3d4c6aeaa8f 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -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