diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 962847423..24e72f684 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -44,8 +44,13 @@ if TYPE_CHECKING: import httpx from headroom.agent_savings import proxy_pipeline_kwargs -from headroom.copilot_auth import apply_copilot_api_auth, build_copilot_upstream_url +from headroom.copilot_auth import ( + apply_copilot_api_auth, + build_copilot_upstream_url, + is_copilot_api_url, +) from headroom.pipeline import PipelineStage, summarize_routing_markers +from headroom.providers.copilot import model_prefers_responses_api from headroom.proxy.auth_mode import ( classify_auth_mode, classify_client, @@ -168,6 +173,14 @@ def _resolve_openai_upstream_base(request_headers: dict[str, str]) -> str | None return normalized +def _resolve_openai_chat_handler_path(base_url: str, model: str | None) -> str: + """Return the upstream path suffix for an OpenAI chat-completions request.""" + + if is_copilot_api_url(base_url) and model_prefers_responses_api(model): + return _OPENAI_RESPONSES_PATH + return _OPENAI_CHAT_COMPLETIONS_PATH + + def _append_request_query(url: str, query: str) -> str: if not query: return url @@ -965,8 +978,7 @@ class OpenAIHandlerMixin: not just the generic passthrough route that already honors it. Falls back to the configured ``OPENAI_API_URL`` (``OPENAI_TARGET_API_URL``). """ - custom = request.headers.get("x-headroom-base-url", "").strip() - return custom or self.OPENAI_API_URL + return _resolve_openai_upstream_base(request.headers) or self.OPENAI_API_URL @staticmethod def _strict_previous_turn_frozen_count( @@ -1896,6 +1908,17 @@ class OpenAIHandlerMixin: model = body.get("model", "unknown") messages = body.get("messages", []) original_client_messages = copy.deepcopy(messages) + custom_upstream_base_url = _resolve_openai_upstream_base(request.headers) + upstream_base_url = self._resolve_openai_upstream(request) + handler_path_suffix = _resolve_openai_chat_handler_path( + upstream_base_url, + model, + ) + handler_path = ( + _resolve_openai_handler_path(request.headers, handler_path=handler_path_suffix) + if custom_upstream_base_url is not None + else f"/v1{handler_path_suffix}" + ) input_event = self.pipeline_extensions.emit( PipelineStage.INPUT_RECEIVED, operation="proxy.request", @@ -1904,7 +1927,7 @@ class OpenAIHandlerMixin: model=model, messages=messages, tools=body.get("tools"), - metadata={"path": "/v1/chat/completions", "stream": body.get("stream", False)}, + metadata={"path": handler_path, "stream": body.get("stream", False)}, ) if input_event.messages is not None: messages = input_event.messages @@ -2112,7 +2135,7 @@ class OpenAIHandlerMixin: provider="openai", model=model, messages=messages, - metadata={"cache_hit": True, "path": "/v1/chat/completions"}, + metadata={"cache_hit": True, "path": handler_path}, ) # Response-cache hit: same pattern as the anthropic # cache-hit site. ``from_response_cache=True`` is the @@ -2598,7 +2621,7 @@ class OpenAIHandlerMixin: messages=optimized_messages, tools=tools, headers=headers, - metadata={"path": "/v1/chat/completions", "stream": stream}, + metadata={"path": handler_path, "stream": stream}, ) if presend_event.messages is not None: optimized_messages = presend_event.messages @@ -2632,7 +2655,7 @@ class OpenAIHandlerMixin: model=model, messages=body["messages"], tools=tools, - metadata={"path": "/v1/chat/completions", "stream": True}, + metadata={"path": handler_path, "stream": True}, ) # Streaming: use stream_openai_message() → SSE events return await self._stream_openai_via_backend( @@ -2667,7 +2690,7 @@ class OpenAIHandlerMixin: tools=tools, response=backend_response.body, metadata={ - "path": "/v1/chat/completions", + "path": handler_path, "stream": False, "status_code": backend_response.status_code, }, @@ -2680,7 +2703,7 @@ class OpenAIHandlerMixin: model=model, response=backend_response.body, metadata={ - "path": "/v1/chat/completions", + "path": handler_path, "stream": False, "status_code": backend_response.status_code, }, @@ -2883,7 +2906,7 @@ class OpenAIHandlerMixin: model=model, messages=body["messages"], tools=tools, - metadata={"path": "/v1/chat/completions", "stream": True}, + metadata={"path": handler_path, "stream": True}, ) return await self._stream_response( url, @@ -2915,7 +2938,7 @@ class OpenAIHandlerMixin: tools=tools, response=response, metadata={ - "path": "/v1/chat/completions", + "path": handler_path, "stream": False, "status_code": response.status_code, }, @@ -2928,7 +2951,7 @@ class OpenAIHandlerMixin: model=model, response=response, metadata={ - "path": "/v1/chat/completions", + "path": handler_path, "stream": False, "status_code": response.status_code, }, diff --git a/tests/test_proxy_copilot_auth_hooks.py b/tests/test_proxy_copilot_auth_hooks.py index 99761c08b..eb0eecd4a 100644 --- a/tests/test_proxy_copilot_auth_hooks.py +++ b/tests/test_proxy_copilot_auth_hooks.py @@ -46,6 +46,7 @@ def _load_handler_module(monkeypatch: pytest.MonkeyPatch, module_name: str, rela httpx_mod.ConnectError = type("ConnectError", (Exception,), {}) httpx_mod.ConnectTimeout = type("ConnectTimeout", (Exception,), {}) httpx_mod.PoolTimeout = type("PoolTimeout", (Exception,), {}) + httpx_mod.ReadTimeout = type("ReadTimeout", (Exception,), {}) monkeypatch.setitem(sys.modules, "httpx", httpx_mod) responses_mod = types.ModuleType("fastapi.responses") @@ -227,3 +228,42 @@ def test_streaming_response_applies_copilot_auth(monkeypatch: pytest.MonkeyPatch assert sent_headers["Authorization"] == "Bearer upstream-token" assert sent_headers["content-type"] == "application/json" assert response.status_code == 200 + + +def test_openai_chat_routes_copilot_requests_per_model(monkeypatch: pytest.MonkeyPatch) -> None: + openai_mod = _load_handler_module( + monkeypatch, + "tests.headroom_proxy_handlers_openai", + "headroom/proxy/handlers/openai.py", + ) + + copilot_base = "https://api.githubcopilot.com" + gpt54_mini_url = openai_mod.build_copilot_upstream_url( + copilot_base, + openai_mod._resolve_openai_handler_path( + {}, + handler_path=openai_mod._resolve_openai_chat_handler_path(copilot_base, "gpt-5.4-mini"), + ), + ) + claude_url = openai_mod.build_copilot_upstream_url( + copilot_base, + openai_mod._resolve_openai_handler_path( + {}, + handler_path=openai_mod._resolve_openai_chat_handler_path( + copilot_base, "claude-sonnet-5" + ), + ), + ) + openai_url = openai_mod.build_copilot_upstream_url( + "https://api.openai.com", + openai_mod._resolve_openai_handler_path( + {}, + handler_path=openai_mod._resolve_openai_chat_handler_path( + "https://api.openai.com", "gpt-5.4-mini" + ), + ), + ) + + assert gpt54_mini_url == "https://api.githubcopilot.com/responses" + assert claude_url == "https://api.githubcopilot.com/chat/completions" + assert openai_url == "https://api.openai.com/v1/chat/completions"