From c400f9081052f633e4e64ad70b95a0230dc6fb3d Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 20 Jul 2026 10:49:32 +0530 Subject: [PATCH] fix(copilot): preserve /v1 for the Anthropic /v1/messages endpoint (#2409) (#2414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes #2409. GitHub Copilot Claude requests routed through Headroom return `404 page not found`. Copilot serves Claude models at `/v1/messages`, but Headroom forwards them to `/messages`, so the upstream 404s (observed on OpenCode's GitHub Copilot provider for `claude-haiku-4.5` / `claude-sonnet-4.6`, Headroom 0.32.0). ## Root cause `build_copilot_upstream_url` strips the `/v1` prefix from every Copilot path: ```python if normalized_path.startswith("/v1/"): normalized_path = normalized_path[3:] ``` That is correct for Copilot's **OpenAI-compatible** surface, which has no `/v1` (`/chat/completions`, `/responses`, `/embeddings`). But Copilot's **Anthropic** surface for Claude models is `/v1/messages` — with the `/v1`. Stripping it produces `https://api.githubcopilot.com/messages`, which 404s. Confirmed against the current code: ```text build_copilot_upstream_url("https://api.githubcopilot.com", "/v1/messages") -> "https://api.githubcopilot.com/messages" # 404 ``` ## Fix Keep `/v1` for the messages endpoint; still strip it for the OpenAI paths: ```python if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"): normalized_path = normalized_path[3:] ``` Now `/v1/messages` (and `/v1/messages/batches`) route to `.../v1/messages`, while `/v1/chat/completions` -> `/chat/completions` and `/v1/responses` -> `/responses` are unchanged, on both the public and GHE Copilot hosts. Non-Copilot upstreams are untouched. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/copilot_auth.py`: exclude `/v1/messages` from the `/v1`-strip in `build_copilot_upstream_url`. - `tests/test_copilot_auth.py`: assert `/v1/messages` (+ batches, + GHE host) keep `/v1` while the OpenAI paths still strip it. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uvx ruff@0.15.17 check headroom/copilot_auth.py tests/test_copilot_auth.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/copilot_auth.py Success: no issues found in 1 source file # copilot_auth is import-light, so I ran the real function in the project venv # (uv sync): /v1/messages -> .../v1/messages, /v1/chat/completions -> /chat/completions. ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`. - Exact command / steps: called the real `build_copilot_upstream_url` before and after the change for `/v1/messages`, `/v1/messages/batches`, `/v1/chat/completions`, `/v1/responses`, `/v1/embeddings`, and a non-Copilot host. - Observed result: before, `/v1/messages` -> `.../messages` (the 404); after, `.../v1/messages`. Batches keep `/v1` too; the OpenAI paths still strip `/v1` (`/chat/completions`, `/responses`, `/embeddings`); `https://api.anthropic.com/v1/messages` is unchanged. Ran against the actual module. - Not tested: a live OpenCode -> Copilot Claude round trip; the added unit tests assert the URL construction directly. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes `copilot_auth` is a light module, so I verified the fix against the real function in the venv (output above) in addition to the unit tests. Scope is limited to the messages endpoint (the reported 404); every other Copilot path is byte-identical to before. --- headroom/copilot_auth.py | 7 ++++++- tests/test_copilot_auth.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/headroom/copilot_auth.py b/headroom/copilot_auth.py index d8c779e44..a1aed76a8 100644 --- a/headroom/copilot_auth.py +++ b/headroom/copilot_auth.py @@ -1007,7 +1007,12 @@ def build_copilot_upstream_url(base_url: str, path: str) -> str: # chat/responses and Anthropic messages all build their upstream URL # here), so mark the request for provider relabeling downstream. mark_request_routed_to_copilot() - if normalized_path.startswith("/v1/"): + # Copilot serves its OpenAI-compatible surface WITHOUT a ``/v1`` prefix + # (``/chat/completions``, ``/responses``, ...), so strip it there. But its + # Anthropic surface for Claude models IS ``/v1/messages`` (with the + # ``/v1``); stripping it forwarded ``/messages`` and Copilot returned 404 + # for claude-* models (#2409). Keep ``/v1`` for the messages endpoint. + if normalized_path.startswith("/v1/") and not normalized_path.startswith("/v1/messages"): normalized_path = normalized_path[3:] else: reset_request_routed_to_copilot() diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index 68f7fea97..e1af9a11b 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -612,6 +612,34 @@ def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None: ) +def test_build_copilot_upstream_url_preserves_v1_messages_for_copilot() -> None: + # Copilot's Anthropic surface for Claude models is /v1/messages (with the + # /v1); stripping it forwarded /messages and Copilot 404'd (#2409). + assert ( + copilot_auth.build_copilot_upstream_url( + "https://api.githubcopilot.com", + "/v1/messages", + ) + == "https://api.githubcopilot.com/v1/messages" + ) + # Batches under the messages endpoint keep /v1 too. + assert ( + copilot_auth.build_copilot_upstream_url( + "https://api.githubcopilot.com", + "/v1/messages/batches", + ) + == "https://api.githubcopilot.com/v1/messages/batches" + ) + # A GHE Copilot host keeps /v1/messages as well. + assert ( + copilot_auth.build_copilot_upstream_url( + "https://copilot-api.acme.ghe.com", + "/v1/messages", + ) + == "https://copilot-api.acme.ghe.com/v1/messages" + ) + + def test_build_copilot_upstream_url_strips_v1_for_ghe_copilot_hosts() -> None: assert ( copilot_auth.build_copilot_upstream_url(