From 80d5668646bc3654a89ad1518258bc939e62fb2b Mon Sep 17 00:00:00 2001 From: Houyun Sun <18763177732@139.com> Date: Thu, 29 Oct 2026 20:28:56 +0800 Subject: [PATCH] fix(openai): merge client headers into extra_headers instead of replacing them Any request that reached the OpenAI SDK handler with a non-empty headers kwarg lost optional_params["extra_headers"] wholesale, because the handler assigned over the dict rather than merging into it. For github_copilot that dict is where main.py puts the whole editor-version / copilot-integration-id set the provider authenticates with, so a single forwarded client header (x-foo, or the proxy's own x-litellm-session-id under forward_client_headers_to_llm_api) turned every chat request into a 400 "missing Editor-Version header for IDE auth". The generic http handler had the mirror image of the same problem: it only passed headers= to validate_environment and ignored optional_params["extra_headers"], so providers routed through it dropped whatever main.py had collected there, and litellm.completion(..., extra_headers={...}) never applied to them at all. Both now merge, caller values winning on a name clash, which keeps an explicit Editor-Version override working. Tests cover the copilot request on the wire with headers= and with a second extra_headers= entry, an explicit Editor-Version override, the SDK handler's merge at the unit level, and the http handler passing extra_headers through to validate_environment. --- litellm/llms/custom_httpx/llm_http_handler.py | 9 +- litellm/llms/openai/openai.py | 10 +- .../test_chat_completion_headers.py | 172 ++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 tests/unit/llms/github_copilot/test_chat_completion_headers.py diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 9e125db4211..216368a5fb8 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -830,11 +830,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, diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index af2e85c5f20..a45d48c918b 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -790,7 +790,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") diff --git a/tests/unit/llms/github_copilot/test_chat_completion_headers.py b/tests/unit/llms/github_copilot/test_chat_completion_headers.py new file mode 100644 index 00000000000..271784c1dae --- /dev/null +++ b/tests/unit/llms/github_copilot/test_chat_completion_headers.py @@ -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", + }