Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .upstreamer/upstreamer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 31 additions & 1 deletion src/openrouter_agent/openrouter.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/test_user_agent.py
Original file line number Diff line number Diff line change
@@ -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"
Loading