Add OpenRouter observability fields so broadcast metadata is discoverable on ChatOpenRouter.

Co-authored-by: Mason Daugherty <61371264+mdrxy@users.noreply.github.com>
This commit is contained in:
open-swe[bot]
2026-05-01 18:14:09 +00:00
parent 8640de8031
commit 30fcebab2e
3 changed files with 153 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,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

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" },