Skip to content

Commit b111f10

Browse files
Merge remote-tracking branch 'origin/master' into v1.4
2 parents b05dbbd + 365315e commit b111f10

4 files changed

Lines changed: 159 additions & 3 deletions

File tree

libs/partners/openrouter/langchain_openrouter/chat_models.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class ChatOpenRouter(BaseChatModel):
119119
| `app_url` | `str | None` | App URL for attribution. |
120120
| `app_title` | `str | None` | App title for attribution. |
121121
| `app_categories` | `list[str] | None` | Marketplace attribution categories. |
122+
| `session_id` | `str | None` | Group related requests for observability. |
123+
| `trace` | `dict[str, Any] | None` | Trace metadata for broadcasts. |
122124
| `max_retries` | `int` | Max retries (default `2`). Set to `0` to disable. |
123125
124126
??? info "Instantiate"
@@ -292,6 +294,42 @@ def model(self) -> str:
292294
plugins: list[dict[str, Any]] | None = None
293295
"""Plugins configuration for OpenRouter."""
294296

297+
session_id: str | None = Field(
298+
default_factory=from_env("OPENROUTER_SESSION_ID", default=None),
299+
)
300+
"""Identifier used by OpenRouter to group related requests together.
301+
302+
Useful any time multiple requests should share an observability
303+
grouping (e.g. a conversation, an agent workflow, a batch job, or a CI
304+
run). Equivalent to setting the `x-session-id` HTTP header on the
305+
underlying request. OpenRouter rejects values longer than 128
306+
characters.
307+
308+
Falls back to the `OPENROUTER_SESSION_ID` environment variable when
309+
unset, so callers can group all requests from a process without
310+
threading the value through application code. Empty strings are
311+
treated as unset.
312+
313+
Example: `"conv-2026-04-30-abc"`
314+
315+
See https://openrouter.ai/docs/guides/features/broadcast/overview
316+
"""
317+
318+
trace: dict[str, Any] | None = None
319+
"""Trace metadata for observability tools (e.g. Langfuse, LangSmith).
320+
321+
Forwarded by OpenRouter to configured broadcast destinations. Common
322+
keys include `trace_id`, `trace_name`, `span_name`, `generation_name`,
323+
and `parent_span_id`; see the OpenRouter broadcast docs for the
324+
current full set. Unknown keys are forwarded as custom metadata.
325+
326+
No environment-variable fallback — set per-call or on the constructor.
327+
328+
Example: `{"trace_id": "abc-123", "span_name": "summarize"}`
329+
330+
See https://openrouter.ai/docs/guides/features/broadcast/overview
331+
"""
332+
295333
model_config = ConfigDict(populate_by_name=True)
296334

297335
@model_validator(mode="before")
@@ -703,6 +741,10 @@ def _default_params(self) -> dict[str, Any]: # noqa: C901, PLR0912
703741
params["route"] = self.route
704742
if self.plugins is not None:
705743
params["plugins"] = self.plugins
744+
if self.session_id:
745+
params["session_id"] = self.session_id
746+
if self.trace is not None:
747+
params["trace"] = self.trace
706748
return params
707749

708750
def _create_message_dicts(

libs/partners/openrouter/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ classifiers = [
2020
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2121
]
2222

23-
version = "0.2.1"
23+
version = "0.2.2"
2424
requires-python = ">=3.10.0,<4.0.0"
2525
dependencies = [
2626
"langchain-core>=1.3.2,<2.0.0",

libs/partners/openrouter/tests/unit_tests/test_chat_models.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,11 @@ async def test_astream_response_metadata_fields(self) -> None:
757757
class TestRequestPayload:
758758
"""Tests verifying the exact dict sent to the SDK."""
759759

760+
@pytest.fixture(autouse=True)
761+
def _clear_openrouter_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
762+
"""Clear env vars that would otherwise leak into tests via `from_env`."""
763+
monkeypatch.delenv("OPENROUTER_SESSION_ID", raising=False)
764+
760765
def test_message_format_in_payload(self) -> None:
761766
"""Test that messages are formatted correctly in the SDK call."""
762767
model = _make_model(temperature=0)
@@ -826,6 +831,115 @@ def test_openrouter_params_in_payload(self) -> None:
826831
assert call_kwargs["provider"] == {"order": ["Anthropic"]}
827832
assert call_kwargs["route"] == "fallback"
828833

834+
def test_session_id_and_trace_in_payload(self) -> None:
835+
"""Test that session_id and trace are forwarded to the SDK."""
836+
model = _make_model(
837+
session_id="session-abc",
838+
trace={"trace_id": "trace-1", "span_name": "summarize"},
839+
)
840+
model.client = MagicMock()
841+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
842+
843+
model.invoke("Hi")
844+
call_kwargs = model.client.chat.send.call_args[1]
845+
assert call_kwargs["session_id"] == "session-abc"
846+
assert call_kwargs["trace"] == {
847+
"trace_id": "trace-1",
848+
"span_name": "summarize",
849+
}
850+
851+
def test_session_id_and_trace_omitted_when_unset(self) -> None:
852+
"""Test that session_id and trace are omitted when not configured."""
853+
model = _make_model()
854+
model.client = MagicMock()
855+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
856+
857+
model.invoke("Hi")
858+
call_kwargs = model.client.chat.send.call_args[1]
859+
assert "session_id" not in call_kwargs
860+
assert "trace" not in call_kwargs
861+
862+
def test_session_id_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
863+
"""Test that session_id falls back to OPENROUTER_SESSION_ID env var."""
864+
monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session-xyz")
865+
model = _make_model()
866+
assert model.session_id == "env-session-xyz"
867+
868+
model.client = MagicMock()
869+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
870+
model.invoke("Hi")
871+
call_kwargs = model.client.chat.send.call_args[1]
872+
assert call_kwargs["session_id"] == "env-session-xyz"
873+
874+
def test_session_id_constructor_overrides_env(
875+
self, monkeypatch: pytest.MonkeyPatch
876+
) -> None:
877+
"""Test that an explicit session_id wins over the env var."""
878+
monkeypatch.setenv("OPENROUTER_SESSION_ID", "env-session")
879+
model = _make_model(session_id="explicit-session")
880+
assert model.session_id == "explicit-session"
881+
882+
model.client = MagicMock()
883+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
884+
model.invoke("Hi")
885+
call_kwargs = model.client.chat.send.call_args[1]
886+
assert call_kwargs["session_id"] == "explicit-session"
887+
888+
def test_session_id_per_call_override(self) -> None:
889+
"""Test that a per-call session_id kwarg overrides the constructor value."""
890+
model = _make_model(session_id="constructor-session")
891+
model.client = MagicMock()
892+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
893+
894+
model.invoke("Hi", session_id="call-session")
895+
first_call_kwargs = model.client.chat.send.call_args[1]
896+
assert first_call_kwargs["session_id"] == "call-session"
897+
898+
# Per-call override must not mutate the constructor value, and the next
899+
# call without the kwarg should fall back to the constructor's value.
900+
assert model.session_id == "constructor-session"
901+
model.invoke("Hi")
902+
second_call_kwargs = model.client.chat.send.call_args[1]
903+
assert second_call_kwargs["session_id"] == "constructor-session"
904+
905+
def test_trace_per_call_override(self) -> None:
906+
"""Test that a per-call trace kwarg overrides the constructor value."""
907+
constructor_trace = {"trace_id": "constructor-trace"}
908+
call_trace = {"trace_id": "call-trace", "span_name": "summarize"}
909+
model = _make_model(trace=constructor_trace)
910+
model.client = MagicMock()
911+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
912+
913+
model.invoke("Hi", trace=call_trace)
914+
first_call_kwargs = model.client.chat.send.call_args[1]
915+
assert first_call_kwargs["trace"] == call_trace
916+
917+
assert model.trace == constructor_trace
918+
model.invoke("Hi")
919+
second_call_kwargs = model.client.chat.send.call_args[1]
920+
assert second_call_kwargs["trace"] == constructor_trace
921+
922+
def test_empty_session_id_treated_as_unset(
923+
self, monkeypatch: pytest.MonkeyPatch
924+
) -> None:
925+
"""Test that empty `session_id` (constructor or env) is not forwarded."""
926+
# Explicit empty string on the constructor.
927+
model = _make_model(session_id="")
928+
model.client = MagicMock()
929+
model.client.chat.send.return_value = _make_sdk_response(_SIMPLE_RESPONSE_DICT)
930+
model.invoke("Hi")
931+
assert "session_id" not in model.client.chat.send.call_args[1]
932+
933+
# Empty string sourced from the env var.
934+
monkeypatch.setenv("OPENROUTER_SESSION_ID", "")
935+
env_model = _make_model()
936+
env_model.client = MagicMock()
937+
env_model.client.chat.send.return_value = _make_sdk_response(
938+
_SIMPLE_RESPONSE_DICT
939+
)
940+
env_model.invoke("Hi")
941+
assert "session_id" not in env_model.client.chat.send.call_args[1]
942+
829943

830944
# ===========================================================================
831945
# bind_tools tests

libs/partners/openrouter/uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)