langchain - ✅(Solved) Fix `ChatOpenRouter` silently swallows `default_headers` [1 pull requests, 1 comments, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
langchain-ai/langchain#36581Fetched 2026-04-08 03:00:57
View on GitHub
Comments
1
Participants
1
Timeline
5
Reactions
0
Participants
Assignees
Timeline (top)
assigned ×1commented ×1cross-referenced ×1labeled ×1

Error Message

warnings.warn(

Root Cause

ChatOpenRouter inherits from BaseChatModel (not BaseChatOpenAI), so it doesn't get OpenAI-compat constructor fields like default_headers for free. Its build_extra validator (langchain_openrouter/chat_models.py:297-325) catches any kwarg not in the declared field set and shoves it into model_kwargs:

@model_validator(mode=\"before\")
def build_extra(cls, values):
    all_required_field_names = get_pydantic_field_names(cls)
    extra = values.get(\"model_kwargs\", {})
    for field_name in list(values):
        if field_name not in all_required_field_names:
            warnings.warn(
                f\"WARNING! {field_name} is not default parameter. \"
                f\"{field_name} was transferred to model_kwargs...\"
            )
            extra[field_name] = values.pop(field_name)
    values[\"model_kwargs\"] = extra
    return values

So unless default_headers is a declared field, this validator quietly turns it into a body field. The only way to inject custom headers today is to pre-build an openrouter.OpenRouter SDK client with a custom httpx.AsyncClient and pass it via the client field — undocumented and brittle.

Notably, _build_client (chat_models.py:339-359) already uses the exact pattern needed: it pre-builds httpx clients with the app-attribution headers (HTTP-Referer, X-Title, X-OpenRouter-Categories). It just doesn't expose a generic default_headers field to merge into that same dict.

Fix Action

Fix / Workaround

  • This is a bug, not a usage question.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version.
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example.

Notably, _build_client (chat_models.py:339-359) already uses the exact pattern needed: it pre-builds httpx clients with the app-attribution headers (HTTP-Referer, X-Title, X-OpenRouter-Categories). It just doesn't expose a generic default_headers field to merge into that same dict.

PR fix notes

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

Description (problem / solution / changelog)

Fixes #36581

Adds a default_headers: dict[str, str] | None field to ChatOpenRouter and merges its values into the underlying httpx client headers in _build_client. Without this, attempting to set default_headers triggers build_extra's misleading "transferred to model_kwargs" warning and the header value ends up in the request body instead of being sent as an HTTP header — blocking any feature that needs per-request header injection (e.g. xAI's x-grok-conv-id for sticky-routing prompt cache hits).

Fix

Three changes in langchain_openrouter/chat_models.py:

  1. Declare default_headers: dict[str, str] | None = None as a recognized field on ChatOpenRouter. This stops build_extra from sweeping it into model_kwargs — the field becomes a real first-class option.
  2. In _build_client, merge self.default_headers into the existing extra_headers dict that's already used to inject the app-attribution headers (HTTP-Referer, X-Title, X-OpenRouter-Categories). User-supplied headers are merged last so they take precedence over built-in keys on collision.
  3. Docstring documenting the new field, the precedence rule, and the use case (xAI sticky routing as the motivating example).

Tests

Adds four unit tests in TestChatOpenRouterInstantiation:

  • test_default_headers_passed_to_client — header reaches both sync and async httpx clients
  • test_default_headers_coexist_with_app_attribution — works alongside app_url / app_title without breaking either
  • test_default_headers_override_app_attribution — user value wins on key collision
  • test_default_headers_none_no_custom_headers — backwards-compat sanity check (default behavior preserved)

Verified to fail before the fix with the exact "transferred to model_kwargs" warning, and pass after.

How verified

From libs/partners/openrouter:

  • make format — clean
  • make lint (ruff + mypy) — clean
  • make test224/224 passing (220 existing + 4 new)

Changed files

  • libs/partners/openrouter/langchain_openrouter/chat_models.py (modified, +21/-0)
  • libs/partners/openrouter/tests/unit_tests/test_chat_models.py (modified, +79/-0)

Code Example

from langchain_openrouter import ChatOpenRouter

llm = ChatOpenRouter(
    model=\"x-ai/grok-4.1-fast\",
    default_headers={\"x-grok-conv-id\": \"session-abc-123\"},
)
# Triggers a UserWarning:
#   WARNING! default_headers is not default parameter.
#   default_headers was transferred to model_kwargs.
#   Please confirm that default_headers is what you intended.
#
# And the header never reaches httpx — inspect the underlying client:
sdk_cfg = llm.client.sdk_configuration
print(dict(sdk_cfg.async_client.headers))
# {'accept': '*/*', ..., 'http-referer': '...', 'x-title': '...'}
# 'x-grok-conv-id' is missing entirely.

---

@model_validator(mode=\"before\")
def build_extra(cls, values):
    all_required_field_names = get_pydantic_field_names(cls)
    extra = values.get(\"model_kwargs\", {})
    for field_name in list(values):
        if field_name not in all_required_field_names:
            warnings.warn(
                f\"WARNING! {field_name} is not default parameter. \"
                f\"{field_name} was transferred to model_kwargs...\"
            )
            extra[field_name] = values.pop(field_name)
    values[\"model_kwargs\"] = extra
    return values
RAW_BUFFERClick to expand / collapse

Checked other resources

  • This is a bug, not a usage question.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version.
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example.

Package

langchain-openrouter

Bug summary

ChatOpenRouter does not declare a default_headers field and provides no way to inject arbitrary HTTP headers through its public API. Worse, attempting to pass default_headers={...} silently corrupts the value: the build_extra validator (mode="before") strips the unrecognized field into model_kwargs with a misleading warning, after which the supposed "headers" end up serialized into the request body instead of as HTTP headers.

This blocks any feature that depends on per-request HTTP header injection. The motivating case for me is xAI's x-grok-conv-id header, which routes consecutive requests with the same value to the same upstream xAI server so its prompt cache stays warm. Empirically this is worth a +45.9pp cache hit rate improvement on grok-4.1-fast (75.2% with header vs 29.3% without, measured over 12 interleaved request pairs against api.x.ai). With the bug, there's no way to deliver that header through ChatOpenRouter even when going BYOK to xAI through OpenRouter.

Reproducer

from langchain_openrouter import ChatOpenRouter

llm = ChatOpenRouter(
    model=\"x-ai/grok-4.1-fast\",
    default_headers={\"x-grok-conv-id\": \"session-abc-123\"},
)
# Triggers a UserWarning:
#   WARNING! default_headers is not default parameter.
#   default_headers was transferred to model_kwargs.
#   Please confirm that default_headers is what you intended.
#
# And the header never reaches httpx — inspect the underlying client:
sdk_cfg = llm.client.sdk_configuration
print(dict(sdk_cfg.async_client.headers))
# {'accept': '*/*', ..., 'http-referer': '...', 'x-title': '...'}
# 'x-grok-conv-id' is missing entirely.

Expected behavior

ChatOpenRouter(default_headers={...}) should accept the field cleanly and forward those headers on every request, the same way ChatOpenAI and other OpenAI-compat chat models do via default_headers.

Actual behavior

The field is silently absorbed into model_kwargs, the misleading warning is shown, and the header value is serialized as a body field on every request (where it does nothing). Inspection of the underlying httpx client shows the header is never set.

Root cause

ChatOpenRouter inherits from BaseChatModel (not BaseChatOpenAI), so it doesn't get OpenAI-compat constructor fields like default_headers for free. Its build_extra validator (langchain_openrouter/chat_models.py:297-325) catches any kwarg not in the declared field set and shoves it into model_kwargs:

@model_validator(mode=\"before\")
def build_extra(cls, values):
    all_required_field_names = get_pydantic_field_names(cls)
    extra = values.get(\"model_kwargs\", {})
    for field_name in list(values):
        if field_name not in all_required_field_names:
            warnings.warn(
                f\"WARNING! {field_name} is not default parameter. \"
                f\"{field_name} was transferred to model_kwargs...\"
            )
            extra[field_name] = values.pop(field_name)
    values[\"model_kwargs\"] = extra
    return values

So unless default_headers is a declared field, this validator quietly turns it into a body field. The only way to inject custom headers today is to pre-build an openrouter.OpenRouter SDK client with a custom httpx.AsyncClient and pass it via the client field — undocumented and brittle.

Notably, _build_client (chat_models.py:339-359) already uses the exact pattern needed: it pre-builds httpx clients with the app-attribution headers (HTTP-Referer, X-Title, X-OpenRouter-Categories). It just doesn't expose a generic default_headers field to merge into that same dict.

Proposed fix

Add a default_headers: dict[str, str] | None = None field and merge it into the existing extra_headers dict in _build_client. ~3 lines of code plus a docstring. User-supplied headers should take precedence over built-in attribution headers if a key collides.

I have a branch with this fix plus four unit tests in TestChatOpenRouterInstantiation (verified to fail before the fix with the exact "transferred to model_kwargs" warning, and pass after, with make format/make lint/make test all green and 224/224 tests passing):

https://github.com/untilhamza/langchain/tree/fix/openrouter-default-headers

Happy to open a PR once assigned.

System Info

  • langchain-openrouter: master
  • Python: 3.12
  • OS: macOS

extent analysis

TL;DR

Add a default_headers field to ChatOpenRouter and merge it into the existing extra_headers dict in _build_client to allow custom HTTP header injection.

Guidance

  • Identify the build_extra validator in langchain_openrouter/chat_models.py as the root cause of the issue, where it silently absorbs unrecognized fields into model_kwargs.
  • Modify the ChatOpenRouter class to include a default_headers field, allowing users to pass custom HTTP headers.
  • Update the _build_client method to merge the default_headers into the existing extra_headers dict, ensuring user-supplied headers take precedence over built-in attribution headers.
  • Verify the fix by checking the underlying httpx client's headers and ensuring the custom headers are correctly set.

Example

from langchain_openrouter import ChatOpenRouter

llm = ChatOpenRouter(
    model="x-ai/grok-4.1-fast",
    default_headers={"x-grok-conv-id": "session-abc-123"},
)
sdk_cfg = llm.client.sdk_configuration
print(dict(sdk_cfg.async_client.headers))  # Should include 'x-grok-conv-id'

Notes

  • The proposed fix requires modifying the ChatOpenRouter class and updating the _build_client method.
  • The fix should be verified through unit tests, such as those provided in the TestChatOpenRouterInstantiation test case.

Recommendation

Apply the proposed fix by adding a default_headers field to ChatOpenRouter and merging it into the existing extra_headers dict in _build_client, as this will allow custom HTTP header injection and resolve the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

ChatOpenRouter(default_headers={...}) should accept the field cleanly and forward those headers on every request, the same way ChatOpenAI and other OpenAI-compat chat models do via default_headers.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

langchain - ✅(Solved) Fix `ChatOpenRouter` silently swallows `default_headers` [1 pull requests, 1 comments, 1 participants]