From ace3fd60499ed3d1929106fd7765b57ea5c3db1e Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 27 Mar 2026 11:40:23 +0000 Subject: [PATCH] feat: add default OpenRouter app attribution headers --- nanobot/providers/openai_compat_provider.py | 22 +++++++++--- tests/providers/test_litellm_kwargs.py | 39 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 07dd811e4..e9a6ad871 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -26,6 +26,11 @@ _ALNUM = string.ascii_letters + string.digits _STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"}) _STANDARD_FN_KEYS = frozenset({"name", "arguments"}) +_DEFAULT_OPENROUTER_HEADERS = { + "HTTP-Referer": "https://github.com/HKUDS/nanobot", + "X-OpenRouter-Title": "nanobot", + "X-OpenRouter-Categories": "cli-agent,personal-agent", +} def _short_tool_id() -> str: @@ -89,6 +94,13 @@ def _extract_tc_extras(tc: Any) -> tuple[ return extra_content, prov, fn_prov +def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | None) -> bool: + """Apply Nanobot attribution headers to OpenRouter requests by default.""" + if spec and spec.name == "openrouter": + return True + return bool(api_base and "openrouter" in api_base.lower()) + + class OpenAICompatProvider(LLMProvider): """Unified provider for all OpenAI-compatible APIs. @@ -113,14 +125,16 @@ class OpenAICompatProvider(LLMProvider): self._setup_env(api_key, api_base) effective_base = api_base or (spec.default_api_base if spec else None) or None + default_headers = {"x-session-affinity": uuid.uuid4().hex} + if _uses_openrouter_attribution(spec, effective_base): + default_headers.update(_DEFAULT_OPENROUTER_HEADERS) + if extra_headers: + default_headers.update(extra_headers) self._client = AsyncOpenAI( api_key=api_key or "no-key", base_url=effective_base, - default_headers={ - "x-session-affinity": uuid.uuid4().hex, - **(extra_headers or {}), - }, + default_headers=default_headers, ) def _setup_env(self, api_key: str, api_base: str | None) -> None: diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index b166cb026..62fb0a2cc 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -60,6 +60,45 @@ def test_openrouter_spec_is_gateway() -> None: assert spec.default_api_base == "https://openrouter.ai/api/v1" +def test_openrouter_sets_default_attribution_headers() -> None: + spec = find_by_name("openrouter") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: + OpenAICompatProvider( + api_key="sk-or-test-key", + api_base="https://openrouter.ai/api/v1", + default_model="anthropic/claude-sonnet-4-5", + spec=spec, + ) + + headers = MockClient.call_args.kwargs["default_headers"] + assert headers["HTTP-Referer"] == "https://github.com/HKUDS/nanobot" + assert headers["X-OpenRouter-Title"] == "nanobot" + assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" + assert "x-session-affinity" in headers + + +def test_openrouter_user_headers_override_default_attribution() -> None: + spec = find_by_name("openrouter") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: + OpenAICompatProvider( + api_key="sk-or-test-key", + api_base="https://openrouter.ai/api/v1", + default_model="anthropic/claude-sonnet-4-5", + extra_headers={ + "HTTP-Referer": "https://nanobot.ai", + "X-OpenRouter-Title": "Nanobot Pro", + "X-Custom-App": "enabled", + }, + spec=spec, + ) + + headers = MockClient.call_args.kwargs["default_headers"] + assert headers["HTTP-Referer"] == "https://nanobot.ai" + assert headers["X-OpenRouter-Title"] == "Nanobot Pro" + assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent" + assert headers["X-Custom-App"] == "enabled" + + @pytest.mark.asyncio async def test_openrouter_keeps_model_name_intact() -> None: """OpenRouter gateway keeps the full model name (gateway does its own routing)."""