From a65a40630196e2e0db64136e0401da177389d074 Mon Sep 17 00:00:00 2001 From: Ken Weiner Date: Tue, 23 Jun 2026 13:11:36 -0700 Subject: [PATCH] feat: send OpenRouter app-attribution headers on OpenAI-compatible clients (#805) * feat: send OpenRouter app-attribution headers on OpenAI-compatible clients Sets HTTP-Referer and X-Title on every AsyncOpenAI client constructed in src/llm/registry.py (default, override-cached, and module-level CLIENTS) and in the embedding client, so OpenRouter attributes Honcho's requests to the "Honcho" app in its dashboard/analytics. Other OpenAI-compatible providers ignore unrecognized headers, so this is safe to send unconditionally. * fix: scope OpenRouter attribution headers to OpenRouter base URL only Address review feedback on #805: - Only inject attribution headers when the configured base_url starts with https://openrouter.ai (via new _openrouter_headers() helper) - Rename X-Title to X-Openrouter-Title per OpenRouter docs recommendation Co-Authored-By: Claude Sonnet 4.6 * refactor: drive default headers from base-URL map, drop embedding path Replace the OpenRouter-specific _openrouter_headers helper with a generic _DEFAULT_HEADERS_BY_BASE_URL prefix map + _default_headers_for lookup, so OpenRouter always receives its attribution headers and another provider can be added with a single map entry. Revert the embedding-client change (OpenRouter has no embeddings endpoint, so that gate was dead code) and add a unit test for the lookup helper. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Sonnet 4.6 --- src/llm/registry.py | 29 ++++++++++++++++++++++++++++- tests/llm/test_registry.py | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/llm/test_registry.py diff --git a/src/llm/registry.py b/src/llm/registry.py index d89f088d..6dd5eecc 100644 --- a/src/llm/registry.py +++ b/src/llm/registry.py @@ -32,6 +32,27 @@ from .history_adapters import ( ) from .types import ProviderClient +# Client-level ``default_headers`` applied to OpenAI-compatible clients, keyed by +# base-URL prefix. Currently only OpenRouter, which uses them for app attribution +# (https://openrouter.ai/docs/app-attribution); add a prefix here to tag another +# provider. Other OpenAI-compatible backends ignore unrecognized headers. +_DEFAULT_HEADERS_BY_BASE_URL: dict[str, dict[str, str]] = { + "https://openrouter.ai": { + "HTTP-Referer": "https://honcho.dev", + "X-Openrouter-Title": "Honcho", + }, +} + + +def _default_headers_for(base_url: str | None) -> dict[str, str]: + """Default headers for ``base_url`` (prefix match); these merge under any + per-request ``extra_headers`` passthrough, which wins on key collision.""" + if base_url: + for prefix, headers in _DEFAULT_HEADERS_BY_BASE_URL.items(): + if base_url.startswith(prefix): + return headers + return {} + @lru_cache(maxsize=1) def get_anthropic_client() -> AsyncAnthropic: @@ -49,6 +70,7 @@ def get_openai_client() -> AsyncOpenAI: return AsyncOpenAI( api_key=settings.LLM.OPENAI_API_KEY, base_url=settings.LLM.OPENAI_BASE_URL, + default_headers=_default_headers_for(settings.LLM.OPENAI_BASE_URL), ) @@ -70,7 +92,11 @@ def get_openai_override_client( base_url: str | None, api_key: str | None ) -> AsyncOpenAI: """OpenAI client for a specific (base_url, api_key) pair. Cached by key.""" - return AsyncOpenAI(api_key=api_key, base_url=base_url) + return AsyncOpenAI( + api_key=api_key, + base_url=base_url, + default_headers=_default_headers_for(base_url), + ) @lru_cache(maxsize=128) @@ -106,6 +132,7 @@ if settings.LLM.OPENAI_API_KEY: CLIENTS["openai"] = AsyncOpenAI( api_key=settings.LLM.OPENAI_API_KEY, base_url=settings.LLM.OPENAI_BASE_URL, + default_headers=_default_headers_for(settings.LLM.OPENAI_BASE_URL), ) if settings.LLM.GEMINI_API_KEY: diff --git a/tests/llm/test_registry.py b/tests/llm/test_registry.py new file mode 100644 index 00000000..71f3302e --- /dev/null +++ b/tests/llm/test_registry.py @@ -0,0 +1,22 @@ +"""Tests for src.llm.registry helpers.""" + +from __future__ import annotations + +from src.llm.registry import _default_headers_for # pyright: ignore[reportPrivateUsage] + + +def test_default_headers_for_openrouter_base_url() -> None: + """OpenRouter base URLs get the app-attribution headers.""" + headers = _default_headers_for("https://openrouter.ai/api/v1") + assert headers["HTTP-Referer"] == "https://honcho.dev" + assert headers["X-Openrouter-Title"] == "Honcho" + + +def test_default_headers_for_non_openrouter_base_url() -> None: + """Other OpenAI-compatible providers get no extra headers.""" + assert _default_headers_for("https://api.openai.com/v1") == {} + + +def test_default_headers_for_none_base_url() -> None: + """A missing base URL (default OpenAI) gets no extra headers.""" + assert _default_headers_for(None) == {}