From a7cf9c6c860fe6be6caab9a968f143bb12d9e0f2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 7 Oct 2026 13:21:27 -0700 Subject: [PATCH] fix(openai-compat): send provider attribution headers on the default SDK path (+ Perplexity) (#44291) * fix(openai-compat): send provider attribution headers on the default SDK path Provider-specific headers set in validate_environment are never sent for OpenAI-compatible providers on the default OpenAI SDK path, which doesn't call it. Novita's X-Novita-Source has been silently missing as a result. Add BaseConfig.get_attribution_headers() and merge it into the outbound headers in _complete_custom_openai, which feeds both the SDK and the experimental http-handler paths. Caller headers win, case-insensitively. * feat(perplexity): send X-Pplx-Integration attribution header Ports the change from #38565 onto the attribution-header hook so it is sent on the default SDK path too. Co-authored-by: Saleh Alghusson <1331721+qirh@users.noreply.github.com> * test: capture attribution headers in-process instead of over a socket Address review: unit tests now drive litellm.completion into an httpx transport rather than a local HTTP server; type the header helper as dict[str, str]; bind the merged headers to a Final instead of rebinding headers in _complete_custom_openai. * style: drop explanatory comments flagged by review --------- Co-authored-by: Saleh Alghusson <1331721+qirh@users.noreply.github.com> --- litellm/llms/base_llm/chat/transformation.py | 29 +++ litellm/llms/novita/chat/transformation.py | 12 +- .../llms/perplexity/chat/transformation.py | 7 + litellm/main.py | 14 +- .../base_llm/chat/test_attribution_headers.py | 210 ++++++++++++++++++ 5 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 tests/unit/llms/base_llm/chat/test_attribution_headers.py diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index 50825720331..8275829765b 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -65,6 +65,26 @@ class BaseLLMException(Exception): super().__init__(self.message) # Call the base class constructor with the parameters it needs +_NO_ATTRIBUTION_HEADERS: Final[Mapping[str, str]] = types.MappingProxyType({}) + + +def with_attribution_headers( + attribution_headers: Mapping[str, str], + headers: dict[str, str] | None, # mutable-ok: returned as-is when there is nothing to add +) -> dict[str, str] | None: # mutable-ok: becomes the request's outbound headers + """ + `headers` plus any attribution header the caller didn't already set (names + compared case-insensitively). Builds a new dict; `headers` is never mutated. + """ + if not attribution_headers: + return headers + caller_names: Final = {name.lower() for name in headers or {}} + return { + **{name: value for name, value in attribution_headers.items() if name.lower() not in caller_names}, + **(headers or {}), + } + + class BaseConfig(ABC): def __init__(self): pass @@ -89,6 +109,15 @@ class BaseConfig(ABC): and not callable(v) # Filter out any callable objects including mocks } + def get_attribution_headers(self) -> Mapping[str, str]: + """ + Headers that tell the provider a request came through LiteLLM. + + Sent by default on every request; a caller header with the same name + (any casing) wins. Override in a provider config to opt in. + """ + return _NO_ATTRIBUTION_HEADERS + def get_json_schema_from_pydantic_object(self, response_format: type[BaseModel] | dict | None) -> dict | None: return type_to_response_format_param(response_format=response_format) diff --git a/litellm/llms/novita/chat/transformation.py b/litellm/llms/novita/chat/transformation.py index acdfa7e8790..f1e1e3c91b1 100644 --- a/litellm/llms/novita/chat/transformation.py +++ b/litellm/llms/novita/chat/transformation.py @@ -6,11 +6,20 @@ Calls done in OpenAI/openai.py as Novita AI is openai-compatible. Docs: https://novita.ai/docs/guides/llm-api """ +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + from ....types.llms.openai import AllMessageValues from ...openai.chat.gpt_transformation import OpenAIGPTConfig +_NOVITA_ATTRIBUTION_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"X-Novita-Source": "litellm"}) + class NovitaConfig(OpenAIGPTConfig): + def get_attribution_headers(self) -> Mapping[str, str]: + return _NOVITA_ATTRIBUTION_HEADERS + def validate_environment( self, headers: dict, @@ -27,5 +36,6 @@ class NovitaConfig(OpenAIGPTConfig): ) headers["Authorization"] = f"Bearer {api_key}" headers["Content-Type"] = "application/json" - headers["X-Novita-Source"] = "litellm" + if not any(name.lower() == "x-novita-source" for name in headers): + headers["X-Novita-Source"] = "litellm" return headers diff --git a/litellm/llms/perplexity/chat/transformation.py b/litellm/llms/perplexity/chat/transformation.py index 2894218da67..5b85e1e8ae6 100644 --- a/litellm/llms/perplexity/chat/transformation.py +++ b/litellm/llms/perplexity/chat/transformation.py @@ -2,6 +2,8 @@ Translate from OpenAI's `/v1/chat/completions` to Perplexity's `/v1/chat/completions` """ +from collections.abc import Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Final import httpx @@ -17,12 +19,17 @@ from litellm.types.utils import ModelResponse, PromptTokensDetailsWrapper, Usage if TYPE_CHECKING: from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer +_PERPLEXITY_ATTRIBUTION_HEADERS: Final[Mapping[str, str]] = MappingProxyType({"X-Pplx-Integration": "litellm"}) + class PerplexityChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> str | None: return "perplexity" + def get_attribution_headers(self) -> Mapping[str, str]: + return _PERPLEXITY_ATTRIBUTION_HEADERS + def _get_openai_compatible_provider_info( self, api_base: str | None, api_key: str | None ) -> tuple[str | None, str | None]: diff --git a/litellm/main.py b/litellm/main.py index 519b8aad0ef..37b290f51ba 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -118,6 +118,7 @@ from litellm.llms.base_llm import BaseConfig, BaseImageGenerationConfig from litellm.llms.base_llm.base_model_iterator import ( convert_model_response_to_streaming, ) +from litellm.llms.base_llm.chat.transformation import with_attribution_headers from litellm.llms.bedrock.common_utils import ( BedrockModelInfo, bedrock_route_for_request, @@ -2634,6 +2635,11 @@ def _complete_custom_openai( ) headers = headers or litellm.headers + outbound_headers: Final = ( + headers + if provider_config is None + else with_attribution_headers(provider_config.get_attribution_headers(), headers) + ) # Add GitHub Copilot headers (same as /responses endpoint does) if custom_llm_provider == "github_copilot": @@ -2685,7 +2691,7 @@ def _complete_custom_openai( acompletion=acompletion, stream=stream, api_key=api_key, - headers=headers, + headers=outbound_headers, client=client, provider_config=provider_config, ) @@ -2693,7 +2699,7 @@ def _complete_custom_openai( response = openai_chat_completions.completion( model=model, messages=messages, - headers=headers, + headers=outbound_headers, model_response=model_response, print_verbose=print_verbose, api_key=api_key, @@ -2716,7 +2722,7 @@ def _complete_custom_openai( input=messages, api_key=api_key, original_response=str(e), - additional_args={"headers": headers}, + additional_args={"headers": outbound_headers}, ) raise e @@ -2726,7 +2732,7 @@ def _complete_custom_openai( input=messages, api_key=api_key, original_response=response, - additional_args={"headers": headers}, + additional_args={"headers": outbound_headers}, ) return response # pyright: ignore[reportReturnType] # provider SDK return type is broader than the dispatch contract diff --git a/tests/unit/llms/base_llm/chat/test_attribution_headers.py b/tests/unit/llms/base_llm/chat/test_attribution_headers.py new file mode 100644 index 00000000000..f31b4e846bf --- /dev/null +++ b/tests/unit/llms/base_llm/chat/test_attribution_headers.py @@ -0,0 +1,210 @@ +""" +Provider attribution headers (`BaseConfig.get_attribution_headers`) must reach +the outbound request on every OpenAI-compatible chat path, and a caller header +with the same name must win. + +Requests go through a real `litellm.completion` into an in-process httpx +transport that records what would have been sent, because the default path +(OpenAI SDK) never calls `validate_environment`. +""" + +import json +from collections.abc import AsyncIterable, Iterable +from typing import Final, cast + +import httpx +import openai +import pytest + +import litellm +from litellm.llms.base_llm.chat.transformation import with_attribution_headers +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler + +_API_BASE: Final = "https://provider.invalid/v1" +_COMPLETION_BODY: Final = json.dumps( + { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "m", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } +).encode() +_STREAM_BODY: Final = ( + "data: " + + json.dumps( + { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + } + ) + + "\n\ndata: [DONE]\n\n" +).encode() + + +class _HeaderCapturingTransport(httpx.BaseTransport, httpx.AsyncBaseTransport): + """Records each outbound request's headers and answers like a chat completions server.""" + + def __init__(self) -> None: + self.sent: tuple[httpx.Headers, ...] = () + + def handle_request(self, request: httpx.Request) -> httpx.Response: + return self._respond(request, request.read()) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + return self._respond(request, await request.aread()) + + def _respond(self, request: httpx.Request, body: bytes) -> httpx.Response: + self.sent = (*self.sent, request.headers) + if json.loads(body).get("stream"): + return httpx.Response(200, content=_STREAM_BODY, headers={"content-type": "text/event-stream"}) + return httpx.Response(200, content=_COMPLETION_BODY, headers={"content-type": "application/json"}) + + def last(self, header: str) -> list[str]: + return self.sent[-1].get_list(header) + + +def _client(transport: _HeaderCapturingTransport, path: str, is_async: bool) -> object: + if path == "sdk": + if is_async: + return openai.AsyncOpenAI( + api_key="k", base_url=_API_BASE, http_client=httpx.AsyncClient(transport=transport) + ) + return openai.OpenAI(api_key="k", base_url=_API_BASE, http_client=httpx.Client(transport=transport)) + if is_async: + return AsyncHTTPHandler(transport=transport) + return HTTPHandler(client=httpx.Client(transport=transport)) + + +@pytest.fixture +def transport() -> _HeaderCapturingTransport: + return _HeaderCapturingTransport() + + +@pytest.fixture(params=["sdk", "http_handler"]) +def handler_path(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str: + if request.param == "http_handler": + monkeypatch.setenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", "true") + else: + monkeypatch.delenv("EXPERIMENTAL_OPENAI_BASE_LLM_HTTP_HANDLER", raising=False) + return request.param + + +_NOVITA_MODEL: Final = "novita/meta-llama/llama-3.3-70b-instruct" + +_ATTRIBUTED: Final = [ + pytest.param(_NOVITA_MODEL, "x-novita-source", id="novita"), + pytest.param("perplexity/sonar", "x-pplx-integration", id="perplexity"), +] + + +def _drain(response: object) -> None: + for _ in cast(Iterable[object], response): + pass + + +async def _adrain(response: object) -> None: + async for _ in cast(AsyncIterable[object], response): + pass + + +@pytest.mark.parametrize(("model", "header"), _ATTRIBUTED) +@pytest.mark.parametrize("stream", [False, True]) +def test_attribution_header_sent_sync( + transport: _HeaderCapturingTransport, handler_path: str, model: str, header: str, stream: bool +) -> None: + response: Final = litellm.completion( + model=model, + messages=[{"role": "user", "content": "hi"}], + api_base=_API_BASE, + api_key="k", + stream=stream, + client=_client(transport, handler_path, is_async=False), + ) + if stream: + _drain(response) + + assert transport.last(header) == ["litellm"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("model", "header"), _ATTRIBUTED) +@pytest.mark.parametrize("stream", [False, True]) +async def test_attribution_header_sent_async( + transport: _HeaderCapturingTransport, handler_path: str, model: str, header: str, stream: bool +) -> None: + response: Final = await litellm.acompletion( + model=model, + messages=[{"role": "user", "content": "hi"}], + api_base=_API_BASE, + api_key="k", + stream=stream, + client=_client(transport, handler_path, is_async=True), + ) + if stream: + await _adrain(response) + + assert transport.last(header) == ["litellm"] + + +@pytest.mark.parametrize(("model", "header"), _ATTRIBUTED) +@pytest.mark.parametrize("header_kwarg", ["headers", "extra_headers"]) +def test_caller_header_overrides_attribution_any_casing( + transport: _HeaderCapturingTransport, handler_path: str, model: str, header: str, header_kwarg: str +) -> None: + caller_headers: Final = {header.upper(): "my-app"} + + litellm.completion( + model=model, + messages=[{"role": "user", "content": "hi"}], + api_base=_API_BASE, + api_key="k", + client=_client(transport, handler_path, is_async=False), + **{header_kwarg: caller_headers}, + ) + + assert transport.last(header) == ["my-app"] + assert caller_headers == {header.upper(): "my-app"} + + +def test_provider_without_attribution_sends_none(transport: _HeaderCapturingTransport, handler_path: str) -> None: + litellm.completion( + model="deepinfra/meta-llama/Meta-Llama-3-8B-Instruct", + messages=[{"role": "user", "content": "hi"}], + api_base=_API_BASE, + api_key="k", + client=_client(transport, handler_path, is_async=False), + ) + + assert transport.last("x-novita-source") == [] + assert transport.last("x-pplx-integration") == [] + + +def test_global_litellm_headers_still_apply_and_are_not_mutated( + transport: _HeaderCapturingTransport, handler_path: str, monkeypatch: pytest.MonkeyPatch +) -> None: + global_headers: Final = {"X-Global": "1"} + monkeypatch.setattr(litellm, "headers", global_headers) + + litellm.completion( + model=_NOVITA_MODEL, + messages=[{"role": "user", "content": "hi"}], + api_base=_API_BASE, + api_key="k", + client=_client(transport, handler_path, is_async=False), + ) + + assert transport.last("x-global") == ["1"] + assert transport.last("x-novita-source") == ["litellm"] + assert global_headers == {"X-Global": "1"} + + +def test_with_attribution_headers_returns_headers_unchanged_when_nothing_to_add() -> None: + headers: Final = {"A": "1"} + + assert with_attribution_headers({}, headers) is headers + assert with_attribution_headers({}, None) is None