feat(openrouter): session_id and trace fields (#37137)

Add first-class `session_id` and `trace` constructor fields on
`ChatOpenRouter`, plumbed into the request payload alongside the
existing `route` / `plugins` / `openrouter_provider` knobs. Both
correspond to the OpenRouter
[broadcast](https://openrouter.ai/docs/guides/features/broadcast/overview)
feature for grouping requests under one logical workflow and attaching
per-request observability metadata. Previously these were only reachable
by stuffing them through `model_kwargs` or `.bind()`, neither of which
is discoverable.

[Docs](https://github.com/langchain-ai/docs/pull/3840)

## Changes
- New `session_id: str | None` field with a
`from_env("OPENROUTER_SESSION_ID", default=None)` factory, so a process
can group all requests via env var without threading the value through
application code. Truthy-guarded in `_default_params` so an explicit or
env-sourced empty string is treated as unset.
- New `trace: dict[str, Any] | None` field for per-request trace
metadata (`trace_id`, `trace_name`, `span_name`, `generation_name`,
`parent_span_id`, plus arbitrary extras forwarded as custom metadata).
No env fallback — set per-call or on the constructor.
- Per-call override (`model.invoke(..., session_id=..., trace=...)`)
works through the existing `**kwargs` flow into `_generate` / `_stream`,
with the constructor value preserved across calls.
- Updated the "Key init args — client params" docstring table on
`ChatOpenRouter` to surface both fields.
This commit is contained in:
Mason Daugherty
2026-05-01 14:14:04 -04:00
committed by GitHub
parent 8640de8031
commit 1e38d59d41
3 changed files with 157 additions and 1 deletions

View File

@@ -119,6 +119,8 @@ class ChatOpenRouter(BaseChatModel):
| `app_url` | `str | None` | App URL for attribution. |
| `app_title` | `str | None` | App title for attribution. |
| `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. |
| `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. |
??? info "Instantiate"
@@ -292,6 +294,42 @@ class ChatOpenRouter(BaseChatModel):
plugins: list[dict[str, Any]] | None = None
"""Plugins configuration for OpenRouter."""
session_id: str | None = Field(
default_factory=from_env("OPENROUTER_SESSION_ID", default=None),
)
"""Identifier used by OpenRouter to group related requests together.
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
characters.
Falls back to the `OPENROUTER_SESSION_ID` environment variable when
unset, so callers can group all requests from a process without
threading the value through application code. Empty strings are
treated as unset.
Example: `"conv-2026-04-30-abc"`
See https://openrouter.ai/docs/guides/features/broadcast/overview
"""
trace: dict[str, Any] | None = None
"""Trace metadata for observability tools (e.g. Langfuse, LangSmith).
Forwarded by OpenRouter to configured broadcast destinations. Common
keys include `trace_id`, `trace_name`, `span_name`, `generation_name`,
and `parent_span_id`; see the OpenRouter broadcast docs for the
current full set. Unknown keys are forwarded as custom metadata.
No environment-variable fallback — set per-call or on the constructor.
Example: `{"trace_id": "abc-123", "span_name": "summarize"}`
See https://openrouter.ai/docs/guides/features/broadcast/overview
"""
model_config = ConfigDict(populate_by_name=True)
@model_validator(mode="before")
@@ -703,6 +741,10 @@ class ChatOpenRouter(BaseChatModel):
params["route"] = self.route
if self.plugins is not None:
params["plugins"] = self.plugins
if self.session_id:
params["session_id"] = self.session_id
if self.trace is not None:
params["trace"] = self.trace
return params
def _create_message_dicts(

View File

@@ -757,6 +757,11 @@ class TestMockedGenerate:
class TestRequestPayload:
"""Tests verifying the exact dict sent to the SDK."""
@pytest.fixture(autouse=True)
def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Clear env vars that would otherwise leak into tests via `from_env`."""
monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)
def test_message_format_in_payload(self) -> None:
"""Test that messages are formatted correctly in the SDK call."""
model = _make_model(temperature=0)
@@ -826,6 +831,115 @@ class TestRequestPayload:
assert call_kwargs["provider"] == {"order": ["Anthropic"]}
assert call_kwargs["route"] == "fallback"
def test_session_id_and_trace_in_payload(self) -> None:
"""Test that session_id and trace are forwarded to the SDK."""
model = _make_model(
session_id="session-abc",
trace={"trace_id": "trace-1", "span_name": "summarize"},
)
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi")
call_kwargs = model.client.chat.send.call_args[1]
assert call_kwargs["session_id"] == "session-abc"
assert call_kwargs["trace"] == {
"trace_id": "trace-1",
"span_name": "summarize",
}
def test_session_id_and_trace_omitted_when_unset(self) -> None:
"""Test that session_id and trace are omitted when not configured."""
model = _make_model()
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi")
call_kwargs = model.client.chat.send.call_args[1]
assert "session_id" not in call_kwargs
assert "trace" not in call_kwargs
def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test that session_id falls back to OPENROUTER_SESSION_ID env var."""
monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")
model = _make_model()
assert model.session_id == "env-session-xyz"
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi")
call_kwargs = model.client.chat.send.call_args[1]
assert call_kwargs["session_id"] == "env-session-xyz"
def test_session_id_constructor_overrides_env(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that an explicit session_id wins over the env var."""
monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")
model = _make_model(session_id="explicit-session")
assert model.session_id == "explicit-session"
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi")
call_kwargs = model.client.chat.send.call_args[1]
assert call_kwargs["session_id"] == "explicit-session"
def test_session_id_per_call_override(self) -> None:
"""Test that a per-call session_id kwarg overrides the constructor value."""
model = _make_model(session_id="constructor-session")
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi", session_id="call-session")
first_call_kwargs = model.client.chat.send.call_args[1]
assert first_call_kwargs["session_id"] == "call-session"
# Per-call override must not mutate the constructor value, and the next
# call without the kwarg should fall back to the constructor's value.
assert model.session_id == "constructor-session"
model.invoke("Hi")
second_call_kwargs = model.client.chat.send.call_args[1]
assert second_call_kwargs["session_id"] == "constructor-session"
def test_trace_per_call_override(self) -> None:
"""Test that a per-call trace kwarg overrides the constructor value."""
constructor_trace = {"trace_id": "constructor-trace"}
call_trace = {"trace_id": "call-trace", "span_name": "summarize"}
model = _make_model(trace=constructor_trace)
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi", trace=call_trace)
first_call_kwargs = model.client.chat.send.call_args[1]
assert first_call_kwargs["trace"] == call_trace
assert model.trace == constructor_trace
model.invoke("Hi")
second_call_kwargs = model.client.chat.send.call_args[1]
assert second_call_kwargs["trace"] == constructor_trace
def test_empty_session_id_treated_as_unset(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test that empty `session_id` (constructor or env) is not forwarded."""
# Explicit empty string on the constructor.
model = _make_model(session_id="")
model.client = MagicMock()
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
model.invoke("Hi")
assert "session_id" not in model.client.chat.send.call_args[1]
# Empty string sourced from the env var.
monkeypatch.setenv("OPENROUTER_SESSION_ID", "")
env_model = _make_model()
env_model.client = MagicMock()
env_model.client.chat.send.return_value = _make_sdk_response(
_SIMPLE_RESPONSE_DICT
)
env_model.invoke("Hi")
assert "session_id" not in env_model.client.chat.send.call_args[1]
# ===========================================================================
# bind_tools tests

View File

@@ -470,7 +470,7 @@ wheels = [
[[package]]
name = "langchain-tests"
version = "1.1.6"
version = "1.1.7"
source = { editable = "../../standard-tests" }
dependencies = [
{ name = "httpx" },