Skip to content

ChatOpenRouter silently swallows default_headers #36581

@untilhamza

Description

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

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions