fix(copilot): use responses API for subscription reasoning models (#647)

Fixes #644

## Summary
- default `headroom wrap copilot --subscription` to the responses wire
API when the selected Copilot model is GPT-5/o1/o3-family
- normalize `--subscription` to the OpenAI-compatible provider mode
before validating `--wire-api responses`
- add provider and CLI regressions for model-derived defaults and
explicit `--wire-api responses`

## Tests
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
pytest tests/test_provider_copilot_wrap.py
tests/test_cli/test_wrap_copilot.py -q`
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
ruff check headroom/providers/copilot/wrap.py
headroom/providers/copilot/__init__.py headroom/cli/wrap.py
tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py`
- `UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev python -m
compileall -q headroom/providers/copilot/wrap.py
headroom/providers/copilot/__init__.py headroom/cli/wrap.py
tests/test_provider_copilot_wrap.py tests/test_cli/test_wrap_copilot.py`

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kumario 2026-06-10 20:54:39 -05:00 committed by GitHub
parent 028efabb4e
commit 84ac332d14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 221 additions and 6 deletions

View file

@ -50,6 +50,12 @@ from headroom.providers.codex import build_launch_env as _build_codex_launch_env
from headroom.providers.copilot import (
build_launch_env as _build_copilot_launch_env,
)
from headroom.providers.copilot import (
copilot_model_from_args as _copilot_model_from_args_impl,
)
from headroom.providers.copilot import (
default_wire_api_for_model as _copilot_default_wire_api_for_model_impl,
)
from headroom.providers.copilot import (
detect_running_proxy_backend as _copilot_detect_running_proxy_backend,
)
@ -1718,6 +1724,16 @@ def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]
return _copilot_model_configured_impl(copilot_args, env)
def _copilot_model_from_args(copilot_args: tuple[str, ...], env: dict[str, str]) -> str | None:
"""Resolve the Copilot model from command-line args or environment."""
return _copilot_model_from_args_impl(copilot_args, env)
def _copilot_default_wire_api_for_model(model: str | None) -> str:
"""Return the default OpenAI-compatible wire API for a Copilot model."""
return _copilot_default_wire_api_for_model_impl(model)
def _should_use_copilot_oauth(
*,
backend: str | None,
@ -2674,11 +2690,6 @@ def copilot(
effective_backend = running_backend or effective_backend
effective_provider_type = _resolve_copilot_provider_type(effective_backend, provider_type)
_validate_copilot_configuration(
provider_type=effective_provider_type,
wire_api=wire_api,
backend=effective_backend,
)
if subscription:
if effective_backend not in (None, "", "anthropic"):
raise click.ClickException(
@ -2690,6 +2701,12 @@ def copilot(
"--subscription uses Copilot's OpenAI-compatible hosted API path; "
"do not combine it with --provider-type anthropic."
)
effective_provider_type = "openai"
_validate_copilot_configuration(
provider_type=effective_provider_type,
wire_api=wire_api,
backend=effective_backend,
)
if not no_rtk:
if _selected_context_tool() == _CONTEXT_TOOL_LEAN_CTX:
@ -2721,7 +2738,10 @@ def copilot(
"GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN."
)
effective_wire_api = wire_api or "completions"
selected_model = _copilot_model_from_args(copilot_args, env)
effective_wire_api = wire_api or (
_copilot_default_wire_api_for_model(selected_model) if subscription else "completions"
)
env["COPILOT_PROVIDER_TYPE"] = "openai"
env["COPILOT_PROVIDER_BASE_URL"] = f"http://127.0.0.1:{port}/v1"
env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api

View file

@ -2,8 +2,11 @@
from .wrap import (
build_launch_env,
copilot_model_from_args,
default_wire_api_for_model,
detect_running_proxy_backend,
model_configured,
model_prefers_responses_api,
provider_key_source,
query_proxy_config,
resolve_provider_type,
@ -12,7 +15,10 @@ from .wrap import (
__all__ = [
"build_launch_env",
"copilot_model_from_args",
"default_wire_api_for_model",
"detect_running_proxy_backend",
"model_prefers_responses_api",
"model_configured",
"provider_key_source",
"query_proxy_config",

View file

@ -65,6 +65,43 @@ def validate_configuration(
)
def _normalized_model_name(model: str | None) -> str:
"""Return a lowercase model name without provider/path prefixes."""
if not model:
return ""
value = model.strip().lower()
for separator in ("/", ":"):
if separator in value:
value = value.rsplit(separator, 1)[-1]
return value
def model_prefers_responses_api(model: str | None) -> bool:
"""Return True for OpenAI reasoning models served via /responses."""
value = _normalized_model_name(model)
return value.startswith(("gpt-5", "o1", "o3"))
def copilot_model_from_args(
copilot_args: tuple[str, ...],
env: Mapping[str, str] | None = None,
) -> str | None:
"""Resolve the Copilot model from CLI args or environment variables."""
for idx, arg in enumerate(copilot_args):
if arg == "--model" and idx + 1 < len(copilot_args):
return copilot_args[idx + 1]
if arg.startswith("--model="):
return arg.split("=", 1)[1]
source = env or os.environ
return source.get("COPILOT_MODEL") or source.get("COPILOT_PROVIDER_MODEL_ID")
def default_wire_api_for_model(model: str | None) -> str:
"""Choose the Copilot OpenAI-compatible wire API for a model."""
return "responses" if model_prefers_responses_api(model) else "completions"
def provider_key_source(provider_type: str) -> str:
"""Return the preferred provider key variable for the selected provider type."""
return "ANTHROPIC_API_KEY" if provider_type == "anthropic" else "OPENAI_API_KEY"

View file

@ -256,6 +256,113 @@ def test_wrap_copilot_subscription_uses_github_auth_without_provider_key(
assert captured["openai_api_url"] == DEFAULT_API_URL
def test_wrap_copilot_subscription_defaults_to_responses_for_reasoning_model(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_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_subscription_bearer_token", return_value="gho-existing"),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
["wrap", "copilot", "--subscription", "--no-rtk", "--", "--model", "gpt-5.4"],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
assert env["COPILOT_PROVIDER_WIRE_API"] == "responses"
assert "COPILOT_PROVIDER_WIRE_API=responses" in captured["env_vars_display"]
def test_wrap_copilot_subscription_keeps_gpt4_on_completions(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Subscription routing must not blanket-promote every model to the responses
API: a non-reasoning model such as gpt-4.1 still defaults to ``completions``.
The provider-helper unit tests cover the wire-API decision in isolation; this
exercises the full CLI path (args -> subscription resolution -> launch env) so
the default can't silently regress to ``responses`` for GPT-4 traffic.
"""
_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_subscription_bearer_token", return_value="gho-existing"),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
["wrap", "copilot", "--subscription", "--no-rtk", "--", "--model", "gpt-4.1"],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
assert env["COPILOT_PROVIDER_WIRE_API"] == "completions"
def test_wrap_copilot_subscription_allows_explicit_responses_wire_api(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_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_subscription_bearer_token", return_value="gho-existing"),
patch("headroom.cli.wrap.has_oauth_auth", return_value=False),
patch("headroom.cli.wrap._launch_tool", side_effect=fake_launch_tool),
):
result = runner.invoke(
main,
[
"wrap",
"copilot",
"--subscription",
"--wire-api",
"responses",
"--no-rtk",
"--",
"--model",
"gpt-5.4",
],
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["COPILOT_PROVIDER_TYPE"] == "openai"
assert env["COPILOT_PROVIDER_WIRE_API"] == "responses"
def test_wrap_copilot_subscription_pins_validated_token_for_proxy(
runner: CliRunner,
wrap_modules: tuple[types.ModuleType, click.Group],
@ -515,6 +622,9 @@ def _clear_copilot_env(monkeypatch: pytest.MonkeyPatch) -> None:
"GITHUB_COPILOT_API_URL",
"GITHUB_COPILOT_TOKEN",
"GITHUB_COPILOT_GITHUB_TOKEN",
"COPILOT_MODEL",
"COPILOT_PROVIDER_MODEL_ID",
"COPILOT_PROVIDER_WIRE_API",
):
monkeypatch.delenv(var, raising=False)

View file

@ -10,8 +10,11 @@ import pytest
from headroom.providers.copilot.wrap import (
build_launch_env,
copilot_model_from_args,
default_wire_api_for_model,
detect_running_proxy_backend,
model_configured,
model_prefers_responses_api,
provider_key_source,
query_proxy_config,
resolve_provider_type,
@ -60,6 +63,45 @@ def test_validate_configuration_rejects_invalid_combinations() -> None:
validate_configuration(provider_type="openai", wire_api="responses", backend="anyllm")
@pytest.mark.parametrize(
("model", "expected"),
[
("gpt-5.5", True),
("gpt-5-codex", True),
("openai/gpt-5.4", True),
("o1", True),
("o3-mini", True),
("gpt-4.1", False),
("claude-sonnet-4.6", False),
(None, False),
],
)
def test_model_prefers_responses_api_for_reasoning_models(
model: str | None,
expected: bool,
) -> None:
assert model_prefers_responses_api(model) is expected
assert default_wire_api_for_model(model) == ("responses" if expected else "completions")
def test_copilot_model_from_args_prefers_cli_over_environment() -> None:
assert (
copilot_model_from_args(
("--model", "gpt-5.5"),
{"COPILOT_MODEL": "gpt-4.1"},
)
== "gpt-5.5"
)
assert (
copilot_model_from_args(
("--model=gpt-5-codex",),
{"COPILOT_PROVIDER_MODEL_ID": "gpt-4.1"},
)
== "gpt-5-codex"
)
assert copilot_model_from_args((), {"COPILOT_PROVIDER_MODEL_ID": "gpt-4.1"}) == "gpt-4.1"
def test_provider_key_source_and_build_launch_env_cover_anthropic_and_openai() -> None:
assert provider_key_source("anthropic") == "ANTHROPIC_API_KEY"
assert provider_key_source("openai") == "OPENAI_API_KEY"