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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user