mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
dbdf555da2
commit
a7cf9c6c86
5 changed files with 267 additions and 5 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
210
tests/unit/llms/base_llm/chat/test_attribution_headers.py
Normal file
210
tests/unit/llms/base_llm/chat/test_attribution_headers.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue