From dd26b4407dae1ac3867fda72dff77ab8354a8e1d Mon Sep 17 00:00:00 2001 From: Peixian Gong Date: Wed, 22 Apr 2026 14:01:46 +0800 Subject: [PATCH] fix(providers): make GitHub Copilot backend work with GPT-5/o-series models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calling GitHub Copilot with `gpt-5.*` / `o*` models (e.g. `github_copilot/gpt-5.4`, `github_copilot/gpt-5.4-mini`) failed with a chain of misleading errors: 1. `Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.` 2. `model "gpt-5.4-mini" is not accessible via the /chat/completions endpoint` (`unsupported_api_for_model`). 3. `The requested model is not supported.` (`model_not_supported`) even after routing to /responses. Root causes (each one masked the next): * The `github_copilot` ProviderSpec did not opt into `supports_max_completion_tokens`, so `_build_kwargs` always sent the legacy `max_tokens` parameter that GPT-5/o-series reject. * `_should_use_responses_api` was hard-gated to `spec.name == "openai"` plus a direct-OpenAI base URL, so the GitHub Copilot backend always went through /chat/completions even for models the Copilot gateway exposes only via /responses (e.g. `gpt-5.4-mini`). * When /responses did fail on github_copilot, the existing "compatibility marker" heuristic silently fell back to /chat/completions — which can never succeed for these models — so the real upstream error was hidden. * `_build_responses_body` did not honour `spec.strip_model_prefix`, so the request body sent `model="github_copilot/gpt-5.4-mini"` (with the routing prefix), which the Copilot gateway rejects with `model_not_supported`. (`_build_kwargs` already stripped it; this branch was missed.) Fix: * registry.py: set `supports_max_completion_tokens=True` on the `github_copilot` spec so requests use `max_completion_tokens`. * openai_compat_provider.py: - `_should_use_responses_api` now also allows the `github_copilot` spec, and skips the direct-OpenAI base check for it (the Copilot gateway is its own base URL). - `_build_responses_body` now strips the model routing prefix when `spec.strip_model_prefix` is set, matching `_build_kwargs`. - `chat` / `chat_stream` no longer fall back from /responses to /chat/completions on the `github_copilot` spec: the fallback cannot succeed for GPT-5/o-series and would mask the real gateway error. Tests: * tests/cli/test_commands.py: switched the `test_github_copilot_provider_refreshes_client_api_key_before_chat` fixture model from `gpt-5.1` to `gpt-4` so it continues to exercise the /chat/completions code path it was designed for (gpt-5.1 now correctly routes to /responses on github_copilot). * `pytest tests/providers/ tests/cli/test_commands.py` — 314 passed. * Verified end-to-end against the live Copilot gateway with both `github_copilot/gpt-5.4` and `github_copilot/gpt-5.4-mini`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nanobot/providers/openai_compat_provider.py | 19 ++++++++++++++++--- nanobot/providers/registry.py | 1 + tests/cli/test_commands.py | 4 ++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 4f726df2..d9eb64dd 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -446,10 +446,11 @@ class OpenAICompatProvider(LLMProvider): reasoning_effort: str | None, ) -> bool: """Use Responses API only for direct OpenAI requests that benefit from it.""" - if self._spec and self._spec.name != "openai": - return False - if not _is_direct_openai_base(self._effective_base): + if self._spec and self._spec.name not in ("openai", "github_copilot"): return False + if self._spec is None or self._spec.name != "github_copilot": + if not _is_direct_openai_base(self._effective_base): + return False model_name = (model or self.default_model).lower() wants = False @@ -527,6 +528,8 @@ class OpenAICompatProvider(LLMProvider): ) -> dict[str, Any]: """Build a Responses API body for direct OpenAI requests.""" model_name = model or self.default_model + if self._spec and self._spec.strip_model_prefix: + model_name = model_name.split("/")[-1] sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages)) instructions, input_items = convert_messages(sanitized_messages) @@ -987,6 +990,11 @@ class OpenAICompatProvider(LLMProvider): self._record_responses_success(model, reasoning_effort) return result except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise if not self._should_fallback_from_responses_error(responses_error): raise self._record_responses_failure(model, reasoning_effort) @@ -1045,6 +1053,11 @@ class OpenAICompatProvider(LLMProvider): reasoning_content=reasoning_content, ) except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise if not self._should_fallback_from_responses_error(responses_error): raise self._record_responses_failure(model, reasoning_effort) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 05237338..f633cc83 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -223,6 +223,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://api.githubcopilot.com", strip_model_prefix=True, is_oauth=True, + supports_max_completion_tokens=True, ), # DeepSeek: OpenAI-compatible at api.deepseek.com ProviderSpec( diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 0344af23..2719beed 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -421,13 +421,13 @@ async def test_github_copilot_provider_refreshes_client_api_key_before_chat(): }) with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client): - provider = GitHubCopilotProvider(default_model="github-copilot/gpt-5.1") + provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4") provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token") response = await provider.chat( messages=[{"role": "user", "content": "hi"}], - model="github-copilot/gpt-5.1", + model="github-copilot/gpt-4", max_tokens=16, temperature=0.1, )