fix(wrap): honor Copilot OAuth wire-api override and model default (#2387)

## Description

`headroom wrap copilot -- --model gpt-5.4` fails with `400 The requested
model is not available for integrator "copilot-language-server"`, even
though the same GitHub account uses GPT-5.4 fine in the native `copilot`
CLI. On the hosted Copilot OAuth path (authenticated via `headroom
copilot-auth`, no `--subscription`), `headroom/cli/wrap.py` forced the
`completions` wire API for every non-`--subscription` launch and ignored
a caller-supplied `COPILOT_PROVIDER_WIRE_API`. GPT-5.x / o-series
reasoning models need the `responses` wire API.

The OAuth path now honors a valid inherited `COPILOT_PROVIDER_WIRE_API`
(`completions` or `responses`) and otherwise uses the model-aware
default via `_copilot_default_wire_api_for_model(selected_model)`, so
GPT-5.x routes to `responses` while GPT-4.1 stays on `completions`. The
`--subscription` path is unchanged.

This supersedes the earlier closed #2243, which mixed the fix with
unrelated proxy/CI changes and hit merge conflicts. This PR ships only
the `headroom/cli/wrap.py` wire-api selection plus regression tests,
rebased clean on `main`.

Closes #2222

## 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

- Hosted Copilot OAuth launch resolves the wire API from an explicit
`COPILOT_PROVIDER_WIRE_API` env value when it is
`completions`/`responses`, else from
`_copilot_default_wire_api_for_model(selected_model)` instead of a
hardcoded `completions`. The `subscription`-only gating on the
model-aware default is removed so OAuth and subscription paths pick the
same model-correct wire API.

## Testing

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ .venv/bin/python -m pytest tests/test_cli/test_wrap_copilot.py tests/test_provider_copilot_wrap.py -q
70 passed in 0.28s
```

New tests cover: the OAuth path honoring an inherited
`COPILOT_PROVIDER_WIRE_API`, the OAuth path resolving GPT-5.4 to
`responses`, and `default_wire_api_for_model("gpt-5.4")` returning
`responses`.

## Real Behavior Proof

- Environment: local checkout, Python 3.14, target tests only.
- Exact command / steps: `.venv/bin/python -m pytest
tests/test_cli/test_wrap_copilot.py tests/test_provider_copilot_wrap.py
-q`
- Observed result: 70 passed; the OAuth-path tests assert
`env["COPILOT_PROVIDER_WIRE_API"] == "responses"` for GPT-5.x and honor
an inherited override.
- Not tested: the end-to-end live Copilot `400` reproduction, which
needs a real Copilot OAuth session plus a GPT-5.x request. The wire-api
selection that caused the `400` is covered by the unit tests above.

## 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
- [ ] 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
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Screenshots (if applicable)

N/A — CLI behavior change covered by the unit tests above.

## Additional Notes

The change is scoped to the wire-api selection line; the
`--subscription` path and the existing explicit `--wire-api` CLI flag
are untouched.

AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-08-11 22:04:42 -07:00
committed by GitHub
parent f6398a6476
commit 1db6d88ab4
3 changed files with 77 additions and 1 deletions
+4 -1
View File
@@ -5090,8 +5090,11 @@ def copilot(
"automatic model selection."
)
env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API")
effective_wire_api = wire_api or (
_copilot_default_wire_api_for_model(selected_model) if subscription else "completions"
env_wire_api
if env_wire_api in {"completions", "responses"}
else _copilot_default_wire_api_for_model(selected_model)
)
env["COPILOT_PROVIDER_TYPE"] = "openai"
# Per-project savings: the Copilot CLI cannot send custom headers, so
+72
View File
@@ -279,6 +279,78 @@ def test_wrap_copilot_prefers_existing_oauth_session(
assert f"COPILOT_PROVIDER_API_URL={DEFAULT_API_URL}" in captured["env_vars_display"]
@pytest.mark.parametrize(
("model", "expected_wire_api"),
[
("gpt-5.4", "responses"),
("gpt-4.1", "completions"),
],
)
def test_wrap_copilot_oauth_defaults_wire_api_for_selected_model(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch,
model: str,
expected_wire_api: str,
) -> None:
"""OAuth sessions use the same model-aware wire API default as subscriptions."""
_wrap_cli, main = wrap_modules
_clear_copilot_env(monkeypatch)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_client_bearer_token", return_value="gho-existing"),
patch("headroom.cli.wrap.has_oauth_auth", return_value=True),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
["wrap", "copilot", "--no-rtk", "--", "--model", model],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_WIRE_API"] == expected_wire_api
assert f"COPILOT_PROVIDER_WIRE_API={expected_wire_api}" in captured["env_vars_display"]
@pytest.mark.parametrize("wire_api", ["completions", "responses"])
def test_wrap_copilot_oauth_honors_existing_wire_api(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch,
wire_api: str,
) -> None:
_wrap_cli, main = wrap_modules
_clear_copilot_env(monkeypatch)
monkeypatch.setenv("COPILOT_PROVIDER_WIRE_API", wire_api)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with (
patch("headroom.cli.wrap.shutil.which", return_value="copilot"),
patch("headroom.cli.wrap.resolve_client_bearer_token", return_value="gho-existing"),
patch("headroom.cli.wrap.has_oauth_auth", return_value=True),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
["wrap", "copilot", "--no-rtk", "--", "--model", "gpt-5.4"],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_WIRE_API"] == wire_api
def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
+1
View File
@@ -93,6 +93,7 @@ def test_validate_configuration_rejects_invalid_combinations() -> None:
[
("gpt-5.5", True),
("gpt-5-codex", True),
("gpt-5.4", True),
("openai/gpt-5.4", True),
("o1", True),
("o3-mini", True),