mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
f6398a6476
commit
1db6d88ab4
3 changed files with 77 additions and 1 deletions
|
|
@ -5090,8 +5090,11 @@ def copilot(
|
||||||
"automatic model selection."
|
"automatic model selection."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API")
|
||||||
effective_wire_api = wire_api or (
|
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"
|
env["COPILOT_PROVIDER_TYPE"] = "openai"
|
||||||
# Per-project savings: the Copilot CLI cannot send custom headers, so
|
# 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"]
|
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(
|
def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
|
||||||
runner: CliRunner,
|
runner: CliRunner,
|
||||||
wrap_modules: tuple[types.ModuleType, click.Group],
|
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.5", True),
|
||||||
("gpt-5-codex", True),
|
("gpt-5-codex", True),
|
||||||
|
("gpt-5.4", True),
|
||||||
("openai/gpt-5.4", True),
|
("openai/gpt-5.4", True),
|
||||||
("o1", True),
|
("o1", True),
|
||||||
("o3-mini", True),
|
("o3-mini", True),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue