mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
028efabb4e
commit
84ac332d14
5 changed files with 221 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue