mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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:
parent
0cbc0e8e54
commit
c400f90810
2 changed files with 34 additions and 1 deletions
|
|
@ -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
|
# chat/responses and Anthropic messages all build their upstream URL
|
||||||
# here), so mark the request for provider relabeling downstream.
|
# here), so mark the request for provider relabeling downstream.
|
||||||
mark_request_routed_to_copilot()
|
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:]
|
normalized_path = normalized_path[3:]
|
||||||
else:
|
else:
|
||||||
reset_request_routed_to_copilot()
|
reset_request_routed_to_copilot()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def test_build_copilot_upstream_url_strips_v1_for_ghe_copilot_hosts() -> None:
|
||||||
assert (
|
assert (
|
||||||
copilot_auth.build_copilot_upstream_url(
|
copilot_auth.build_copilot_upstream_url(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue