mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
Merge 80d5668646 into add7dea9aa
This commit is contained in:
commit
f4d167b835
3 changed files with 189 additions and 2 deletions
|
|
@ -895,11 +895,18 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
|
||||
# get config from model, custom llm provider
|
||||
#
|
||||
# optional_params["extra_headers"] is a second source of request headers
|
||||
# here: providers that need headers of their own (github_copilot and its
|
||||
# editor-version, for one) put them there in main.py, and so do callers
|
||||
# using the extra_headers= kwarg. The caller's own headers= win on a
|
||||
# clash, same as everywhere else.
|
||||
_config_headers: Final = optional_params.get("extra_headers")
|
||||
request_headers: Final = cast( # cast-ok: validate_environment is declared as a bare dict
|
||||
"dict[str, object]",
|
||||
provider_config.validate_environment(
|
||||
api_key=api_key,
|
||||
headers=headers or {},
|
||||
headers={**(_config_headers if isinstance(_config_headers, dict) else {}), **(headers or {})},
|
||||
model=model,
|
||||
messages=messages,
|
||||
optional_params=optional_params,
|
||||
|
|
|
|||
|
|
@ -794,7 +794,15 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
|
|||
)
|
||||
|
||||
if headers:
|
||||
inference_params["extra_headers"] = headers
|
||||
# merge, don't replace: optional_params["extra_headers"] already
|
||||
# holds the provider's own headers (github_copilot puts its
|
||||
# editor-version set there) plus whatever the caller passed as
|
||||
# extra_headers=. Letting headers= overwrite the dict drops all of
|
||||
# that, and the caller's values win on a genuine clash.
|
||||
inference_params["extra_headers"] = {
|
||||
**cast("dict[str, str]", inference_params.get("extra_headers") or {}),
|
||||
**headers,
|
||||
}
|
||||
if model is None or messages is None:
|
||||
raise OpenAIError(status_code=422, message="Missing model or messages")
|
||||
|
||||
|
|
|
|||
172
tests/unit/llms/github_copilot/test_chat_completion_headers.py
Normal file
172
tests/unit/llms/github_copilot/test_chat_completion_headers.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Regression tests for #49062: caller headers must not wipe the provider's own
|
||||
request headers on the chat completions path.
|
||||
|
||||
Two ways the headers got lost, both covered here:
|
||||
|
||||
* the copilot editor-version set that main.py folds into
|
||||
optional_params["extra_headers"] never reached the wire when the caller also
|
||||
sent headers=, because the openai SDK handler assigned over the merged dict
|
||||
instead of merging. That takes the copilot headers out as request headers and
|
||||
as the provider's auth set, so github answers 400 "missing Editor-Version"
|
||||
* the generic http handler ignored optional_params["extra_headers"] entirely and
|
||||
only looked at headers=, so every provider routed through it lost the
|
||||
provider-specific headers main.py had collected
|
||||
|
||||
No sockets are opened; respx intercepts the outbound request.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
|
||||
API_BASE = "https://api.githubcopilot.com"
|
||||
|
||||
CHAT_COMPLETION_BODY = {
|
||||
"id": "chatcmpl-headers",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "claude-sonnet-4",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hi"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
}
|
||||
|
||||
COPILOT_HEADERS = {"editor-version": "vscode/1.95.0", "copilot-integration-id": "vscode-chat"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def copilot_endpoint():
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
yield mock.post(url__regex=rf"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json=CHAT_COMPLETION_BODY)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_copilot_login(monkeypatch):
|
||||
"""Skip the on-disk copilot login; the headers are what is under test."""
|
||||
from litellm.llms.github_copilot.authenticator import Authenticator
|
||||
|
||||
monkeypatch.setattr(Authenticator, "get_api_key", lambda self: "ghu_fake_token")
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_extra_headers_survive_a_forwarded_client_header(copilot_endpoint, no_copilot_login):
|
||||
"""headers= is what the proxy fills in when it forwards client headers."""
|
||||
litellm.completion(
|
||||
model="github_copilot/claude-sonnet-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=8,
|
||||
api_key="ghu_fake_token",
|
||||
api_base=API_BASE,
|
||||
headers={"x-litellm-session-id": "sess_123"},
|
||||
)
|
||||
|
||||
sent = copilot_endpoint.calls.last.request.headers
|
||||
assert sent["editor-version"] == "vscode/1.95.0"
|
||||
assert sent["copilot-integration-id"] == "vscode-chat"
|
||||
assert sent["x-litellm-session-id"] == "sess_123"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_extra_headers_survive_a_second_extra_header(copilot_endpoint, no_copilot_login):
|
||||
"""A caller adding one header of its own keeps the provider's set intact."""
|
||||
litellm.completion(
|
||||
model="github_copilot/claude-sonnet-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=8,
|
||||
api_key="ghu_fake_token",
|
||||
api_base=API_BASE,
|
||||
extra_headers={"x-corp-llm-call-id": "abc-123"},
|
||||
)
|
||||
|
||||
sent = copilot_endpoint.calls.last.request.headers
|
||||
assert sent["editor-version"] == "vscode/1.95.0"
|
||||
assert sent["x-corp-llm-call-id"] == "abc-123"
|
||||
|
||||
|
||||
@respx.mock
|
||||
def test_caller_can_still_override_editor_version(copilot_endpoint, no_copilot_login):
|
||||
litellm.completion(
|
||||
model="github_copilot/claude-sonnet-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=8,
|
||||
api_key="ghu_fake_token",
|
||||
api_base=API_BASE,
|
||||
extra_headers={"Editor-Version": "vscode/9.9.9"},
|
||||
)
|
||||
|
||||
assert copilot_endpoint.calls.last.request.headers["editor-version"] == "vscode/9.9.9"
|
||||
|
||||
|
||||
@patch("litellm.llms.openai.openai.OpenAIChatCompletion.make_sync_openai_chat_completion_request")
|
||||
@patch("litellm.llms.openai.openai.OpenAIChatCompletion._get_openai_client")
|
||||
def test_sdk_path_merges_instead_of_replacing(mock_get_client, mock_request):
|
||||
"""Same bug at the unit level: headers= must not drop extra_headers=."""
|
||||
from litellm.llms.openai.openai import OpenAIChatCompletion
|
||||
|
||||
parsed_response = MagicMock()
|
||||
parsed_response.model_dump.return_value = CHAT_COMPLETION_BODY
|
||||
mock_get_client.return_value = MagicMock()
|
||||
mock_request.return_value = ({}, parsed_response)
|
||||
|
||||
OpenAIChatCompletion().completion(
|
||||
model="claude-sonnet-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model_response=litellm.ModelResponse(),
|
||||
timeout=60.0,
|
||||
optional_params={"extra_headers": dict(COPILOT_HEADERS)},
|
||||
litellm_params={},
|
||||
logging_obj=MagicMock(),
|
||||
custom_llm_provider="github_copilot",
|
||||
api_key="ghu_fake_token",
|
||||
headers={"x-forwarded-by-client": "1"},
|
||||
acompletion=False,
|
||||
)
|
||||
|
||||
sent_data = mock_request.call_args.kwargs["data"]
|
||||
assert sent_data["extra_headers"] == {**COPILOT_HEADERS, "x-forwarded-by-client": "1"}
|
||||
|
||||
|
||||
def test_http_handler_applies_extra_headers_from_optional_params():
|
||||
"""The generic handler serves most non-OpenAI providers; it used to drop these."""
|
||||
from litellm.llms.base_llm.chat.transformation import BaseConfig
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
|
||||
config = MagicMock(spec=BaseConfig)
|
||||
config.validate_environment.return_value = {"Authorization": "Bearer k"}
|
||||
config.get_complete_url.return_value = "https://example.com/v1/chat/completions"
|
||||
config.should_fake_stream.return_value = False
|
||||
config.max_retry_on_unprocessable_entity_error = 1
|
||||
|
||||
response = httpx.Response(200, json=CHAT_COMPLETION_BODY)
|
||||
with patch.object(BaseLLMHTTPHandler, "_make_common_sync_call", return_value=response):
|
||||
BaseLLMHTTPHandler().completion(
|
||||
model="m",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
api_base="https://example.com/v1",
|
||||
custom_llm_provider="openai_like",
|
||||
model_response=litellm.ModelResponse(),
|
||||
encoding=None,
|
||||
logging_obj=MagicMock(),
|
||||
optional_params={"extra_headers": {"x-provider-header": "1"}},
|
||||
timeout=5.0,
|
||||
litellm_params={},
|
||||
acompletion=False,
|
||||
headers={"x-caller-header": "2"},
|
||||
provider_config=config,
|
||||
)
|
||||
|
||||
assert config.validate_environment.call_args.kwargs["headers"] == {
|
||||
"x-provider-header": "1",
|
||||
"x-caller-header": "2",
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue