fix(copilot): preserve /v1 for the Anthropic /v1/messages endpoint (#2409) (#2414)

## 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.
This commit is contained in:
Abhay Singh 2026-07-20 10:49:32 +05:30 committed by GitHub
parent 0cbc0e8e54
commit c400f90810
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 34 additions and 1 deletions

View file

@ -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()

View file

@ -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(