Skip to content

Commit a5251c4

Browse files
feat(agent): identify Python SDK traffic in user agent (#26)
Append the openrouter-agent-sdk distribution name and version to the generated SDK's User-Agent so agent traffic is distinguishable from plain openrouter-python traffic. Only appends when the SDK's user agent is still the generated default, so an explicit user_agent= or a hook that rewrites it during sdk_init keeps its value. Falls back to the bare distribution name when the package is not installed. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 1ed8611 commit a5251c4

3 files changed

Lines changed: 130 additions & 1 deletion

File tree

.upstreamer/upstreamer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ Deliberate and permanent. Do not converge these toward TypeScript.
196196
matching symbol (e.g. `BeforeCreateRequestContext` / `BeforeCreateRequestHook`
197197
until the generated SDK exposes them natively). Bind to real generated symbols
198198
as soon as they exist.
199+
7. **User-agent wrapper identity is language-specific.** Python appends the
200+
PyPI distribution name (`openrouter-agent-sdk`), while TypeScript appends the
201+
npm package name (`@openrouter/agent`) and Go appends the module path
202+
(`github.com/OpenRouterTeam/go-agent`). Porting must not convert this token
203+
to the TypeScript literal.
199204

200205
## Output Shape
201206

src/openrouter_agent/openrouter.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from __future__ import annotations
22

33
import inspect
4-
from typing import Any, Mapping, Optional, Protocol
4+
from importlib import metadata
5+
from typing import Any, Callable, Mapping, Optional, Protocol
56

67
from openrouter import OpenRouter as _SDKOpenRouter
78
from openrouter._hooks import SDKHooks
9+
from openrouter._version import __user_agent__
810

911

1012
from .call_model import call_model
@@ -17,9 +19,30 @@ def sdk_init(self, configuration: Any) -> Any: ...
1719
OpenRouterOptions = Mapping[str, object]
1820
SDKOptions = Mapping[str, object]
1921

22+
AGENT_PACKAGE_NAME = "openrouter-agent-sdk"
23+
24+
25+
def _agent_user_agent_token(
26+
version_getter: Optional[Callable[[str], str]] = None,
27+
) -> str:
28+
try:
29+
version = (version_getter or metadata.version)(AGENT_PACKAGE_NAME)
30+
except metadata.PackageNotFoundError:
31+
return AGENT_PACKAGE_NAME
32+
return f"{AGENT_PACKAGE_NAME}/{version}"
33+
34+
35+
def _append_agent_user_agent(user_agent: str, agent_token: str) -> str:
36+
if agent_token in user_agent.split():
37+
return user_agent
38+
if not user_agent:
39+
return agent_token
40+
return f"{user_agent} {agent_token}"
41+
2042

2143
class OpenRouter(_SDKOpenRouter): # type: ignore[misc, valid-type]
2244
def __init__(self, *args: Any, hooks: Any = None, **kwargs: Any) -> None:
45+
explicit_user_agent = kwargs.pop("user_agent", None)
2346
normalized_hooks = self._normalize_hooks(hooks)
2447
sdk_accepts_hooks = "hooks" in inspect.signature(_SDKOpenRouter.__init__).parameters
2548
if normalized_hooks is not None and sdk_accepts_hooks:
@@ -32,6 +55,13 @@ def __init__(self, *args: Any, hooks: Any = None, **kwargs: Any) -> None:
3255
self.sdk_configuration.__dict__["_hooks"] = normalized_hooks
3356
self.sdk_configuration = normalized_hooks.sdk_init(self.sdk_configuration)
3457
self.agent_hooks = normalized_hooks
58+
if explicit_user_agent is not None:
59+
self.sdk_configuration.user_agent = explicit_user_agent
60+
elif self.sdk_configuration.user_agent == __user_agent__:
61+
self.sdk_configuration.user_agent = _append_agent_user_agent(
62+
self.sdk_configuration.user_agent,
63+
_agent_user_agent_token(),
64+
)
3565

3666
@staticmethod
3767
def _normalize_hooks(hooks: Any) -> Any:

tests/unit/test_user_agent.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from __future__ import annotations
2+
3+
import importlib.metadata
4+
5+
import httpx
6+
7+
from openrouter._version import __user_agent__
8+
from openrouter.errors.responsevalidationerror import ResponseValidationError
9+
from openrouter_agent import OpenRouter, call_model
10+
from openrouter_agent.openrouter import _agent_user_agent_token, _append_agent_user_agent
11+
12+
13+
def test_constructed_client_appends_agent_user_agent() -> None:
14+
client = OpenRouter(api_key="test")
15+
16+
assert client.sdk_configuration.user_agent == (
17+
f"{__user_agent__} openrouter-agent-sdk/{importlib.metadata.version('openrouter-agent-sdk')}"
18+
)
19+
20+
21+
def test_agent_user_agent_append_is_idempotent() -> None:
22+
client_one = OpenRouter(api_key="test")
23+
client_two = OpenRouter(api_key="test")
24+
expected = client_one.sdk_configuration.user_agent
25+
26+
assert client_two.sdk_configuration.user_agent == expected
27+
assert _append_agent_user_agent(expected, _agent_user_agent_token()) == expected
28+
29+
30+
def test_explicit_user_agent_is_preserved() -> None:
31+
class Hook:
32+
def before_request(self, ctx, request):
33+
return request
34+
35+
client = OpenRouter(api_key="test", user_agent="custom/1.0", hooks=Hook())
36+
37+
assert client.sdk_configuration.user_agent == "custom/1.0"
38+
assert client.agent_hooks is not None
39+
40+
41+
def test_user_agent_changed_by_hook_is_preserved() -> None:
42+
class Hook:
43+
def sdk_init(self, configuration):
44+
configuration.user_agent = "hook/1.0"
45+
return configuration
46+
47+
client = OpenRouter(api_key="test", hooks=Hook())
48+
49+
assert client.sdk_configuration.user_agent == "hook/1.0"
50+
51+
52+
def test_package_not_found_uses_bare_agent_package_name(monkeypatch) -> None:
53+
def package_version(_name: str) -> str:
54+
raise importlib.metadata.PackageNotFoundError
55+
56+
monkeypatch.setattr(importlib.metadata, "version", package_version)
57+
58+
client = OpenRouter(api_key="test")
59+
60+
assert client.sdk_configuration.user_agent == f"{__user_agent__} openrouter-agent-sdk"
61+
62+
63+
async def test_call_model_sends_agent_user_agent_and_call_model_marker() -> None:
64+
seen_headers: httpx.Headers | None = None
65+
66+
def handler(request: httpx.Request) -> httpx.Response:
67+
nonlocal seen_headers
68+
seen_headers = request.headers
69+
return httpx.Response(
70+
200,
71+
json={
72+
"id": "resp_1",
73+
"object": "response",
74+
"created_at": 0,
75+
"status": "completed",
76+
"model": "test/model",
77+
"output": [],
78+
"usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
79+
},
80+
)
81+
82+
async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
83+
client = OpenRouter(api_key="test", server_url="https://mock.local", async_client=async_client)
84+
try:
85+
try:
86+
await call_model(client, {"model": "test/model", "input": "hello"}).get_response()
87+
except ResponseValidationError:
88+
pass
89+
finally:
90+
await async_client.aclose()
91+
92+
assert seen_headers is not None
93+
assert seen_headers["user-agent"] == client.sdk_configuration.user_agent
94+
assert seen_headers["x-openrouter-callmodel"] == "true"

0 commit comments

Comments
 (0)