diff --git a/.upstreamer/upstreamer.md b/.upstreamer/upstreamer.md index a4e9cd5..2fb8f79 100644 --- a/.upstreamer/upstreamer.md +++ b/.upstreamer/upstreamer.md @@ -196,6 +196,11 @@ Deliberate and permanent. Do not converge these toward TypeScript. matching symbol (e.g. `BeforeCreateRequestContext` / `BeforeCreateRequestHook` until the generated SDK exposes them natively). Bind to real generated symbols as soon as they exist. +7. **User-agent wrapper identity is language-specific.** Python appends the + PyPI distribution name (`openrouter-agent-sdk`), while TypeScript appends the + npm package name (`@openrouter/agent`) and Go appends the module path + (`github.com/OpenRouterTeam/go-agent`). Porting must not convert this token + to the TypeScript literal. ## Output Shape diff --git a/src/openrouter_agent/openrouter.py b/src/openrouter_agent/openrouter.py index 01e0948..b799025 100644 --- a/src/openrouter_agent/openrouter.py +++ b/src/openrouter_agent/openrouter.py @@ -1,10 +1,12 @@ from __future__ import annotations import inspect -from typing import Any, Mapping, Optional, Protocol +from importlib import metadata +from typing import Any, Callable, Mapping, Optional, Protocol from openrouter import OpenRouter as _SDKOpenRouter from openrouter._hooks import SDKHooks +from openrouter._version import __user_agent__ from .call_model import call_model @@ -17,9 +19,30 @@ def sdk_init(self, configuration: Any) -> Any: ... OpenRouterOptions = Mapping[str, object] SDKOptions = Mapping[str, object] +AGENT_PACKAGE_NAME = "openrouter-agent-sdk" + + +def _agent_user_agent_token( + version_getter: Optional[Callable[[str], str]] = None, +) -> str: + try: + version = (version_getter or metadata.version)(AGENT_PACKAGE_NAME) + except metadata.PackageNotFoundError: + return AGENT_PACKAGE_NAME + return f"{AGENT_PACKAGE_NAME}/{version}" + + +def _append_agent_user_agent(user_agent: str, agent_token: str) -> str: + if agent_token in user_agent.split(): + return user_agent + if not user_agent: + return agent_token + return f"{user_agent} {agent_token}" + class OpenRouter(_SDKOpenRouter): # type: ignore[misc, valid-type] def __init__(self, *args: Any, hooks: Any = None, **kwargs: Any) -> None: + explicit_user_agent = kwargs.pop("user_agent", None) normalized_hooks = self._normalize_hooks(hooks) sdk_accepts_hooks = "hooks" in inspect.signature(_SDKOpenRouter.__init__).parameters 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: self.sdk_configuration.__dict__["_hooks"] = normalized_hooks self.sdk_configuration = normalized_hooks.sdk_init(self.sdk_configuration) self.agent_hooks = normalized_hooks + if explicit_user_agent is not None: + self.sdk_configuration.user_agent = explicit_user_agent + elif self.sdk_configuration.user_agent == __user_agent__: + self.sdk_configuration.user_agent = _append_agent_user_agent( + self.sdk_configuration.user_agent, + _agent_user_agent_token(), + ) @staticmethod def _normalize_hooks(hooks: Any) -> Any: diff --git a/tests/unit/test_user_agent.py b/tests/unit/test_user_agent.py new file mode 100644 index 0000000..b14c8f7 --- /dev/null +++ b/tests/unit/test_user_agent.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import importlib.metadata + +import httpx + +from openrouter._version import __user_agent__ +from openrouter.errors.responsevalidationerror import ResponseValidationError +from openrouter_agent import OpenRouter, call_model +from openrouter_agent.openrouter import _agent_user_agent_token, _append_agent_user_agent + + +def test_constructed_client_appends_agent_user_agent() -> None: + client = OpenRouter(api_key="test") + + assert client.sdk_configuration.user_agent == ( + f"{__user_agent__} openrouter-agent-sdk/{importlib.metadata.version('openrouter-agent-sdk')}" + ) + + +def test_agent_user_agent_append_is_idempotent() -> None: + client_one = OpenRouter(api_key="test") + client_two = OpenRouter(api_key="test") + expected = client_one.sdk_configuration.user_agent + + assert client_two.sdk_configuration.user_agent == expected + assert _append_agent_user_agent(expected, _agent_user_agent_token()) == expected + + +def test_explicit_user_agent_is_preserved() -> None: + class Hook: + def before_request(self, ctx, request): + return request + + client = OpenRouter(api_key="test", user_agent="custom/1.0", hooks=Hook()) + + assert client.sdk_configuration.user_agent == "custom/1.0" + assert client.agent_hooks is not None + + +def test_user_agent_changed_by_hook_is_preserved() -> None: + class Hook: + def sdk_init(self, configuration): + configuration.user_agent = "hook/1.0" + return configuration + + client = OpenRouter(api_key="test", hooks=Hook()) + + assert client.sdk_configuration.user_agent == "hook/1.0" + + +def test_package_not_found_uses_bare_agent_package_name(monkeypatch) -> None: + def package_version(_name: str) -> str: + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(importlib.metadata, "version", package_version) + + client = OpenRouter(api_key="test") + + assert client.sdk_configuration.user_agent == f"{__user_agent__} openrouter-agent-sdk" + + +async def test_call_model_sends_agent_user_agent_and_call_model_marker() -> None: + seen_headers: httpx.Headers | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_headers + seen_headers = request.headers + return httpx.Response( + 200, + json={ + "id": "resp_1", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "test/model", + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + }, + ) + + async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = OpenRouter(api_key="test", server_url="https://mock.local", async_client=async_client) + try: + try: + await call_model(client, {"model": "test/model", "input": "hello"}).get_response() + except ResponseValidationError: + pass + finally: + await async_client.aclose() + + assert seen_headers is not None + assert seen_headers["user-agent"] == client.sdk_configuration.user_agent + assert seen_headers["x-openrouter-callmodel"] == "true"