Checked other resources
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
Checked other resources
Package
langchain-openrouterBug summary
ChatOpenRouterdoes not declare adefault_headersfield and provides no way to inject arbitrary HTTP headers through its public API. Worse, attempting to passdefault_headers={...}silently corrupts the value: thebuild_extravalidator (mode="before") strips the unrecognized field intomodel_kwargswith 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-idheader, 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 againstapi.x.ai). With the bug, there's no way to deliver that header throughChatOpenRoutereven when going BYOK to xAI through OpenRouter.Reproducer
Expected behavior
ChatOpenRouter(default_headers={...})should accept the field cleanly and forward those headers on every request, the same wayChatOpenAIand other OpenAI-compat chat models do viadefault_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
ChatOpenRouterinherits fromBaseChatModel(notBaseChatOpenAI), so it doesn't get OpenAI-compat constructor fields likedefault_headersfor free. Itsbuild_extravalidator (langchain_openrouter/chat_models.py:297-325) catches any kwarg not in the declared field set and shoves it intomodel_kwargs:So unless
default_headersis a declared field, this validator quietly turns it into a body field. The only way to inject custom headers today is to pre-build anopenrouter.OpenRouterSDK client with a customhttpx.AsyncClientand pass it via theclientfield — 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 genericdefault_headersfield to merge into that same dict.Proposed fix
Add a
default_headers: dict[str, str] | None = Nonefield and merge it into the existingextra_headersdict 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, withmake format/make lint/make testall 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