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>
This commit is contained in:
ryan-crabbe-berri 2026-10-07 13:21:27 -07:00 • committed by GitHub
parent dbdf555da2
commit a7cf9c6c86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 267 additions and 5 deletions

View file

@ -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)

View file

@ -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

View file

@ -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]:

View file

@ -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

View file

@ -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