diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 06bc6c9311e..997a8c9e637 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -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( diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index 0a52a1a198e..00d5cf9ac2f 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -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,111 @@ 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" + + 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` is not forwarded.""" + 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] + + 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 diff --git a/libs/partners/openrouter/uv.lock b/libs/partners/openrouter/uv.lock index 7c5838b46b2..3f41d624dd9 100644 --- a/libs/partners/openrouter/uv.lock +++ b/libs/partners/openrouter/uv.lock @@ -470,7 +470,7 @@ wheels = [ [[package]] name = "langchain-tests" -version = "1.1.6" +version = "1.1.7" source = { editable = "../../standard-tests" } dependencies = [ { name = "httpx" },