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, ) diff --git a/tests/providers/test_github_copilot_routing.py b/tests/providers/test_github_copilot_routing.py new file mode 100644 index 00000000..90e4cb4d --- /dev/null +++ b/tests/providers/test_github_copilot_routing.py @@ -0,0 +1,79 @@ +"""Regression tests for GitHub Copilot /responses routing. + +Covers the Copilot-specific branches added to route GPT-5 / o-series models +through the /responses endpoint without falling back to /chat/completions. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from nanobot.providers.openai_compat_provider import OpenAICompatProvider +from nanobot.providers.registry import find_by_name + + +def _make_copilot_provider() -> OpenAICompatProvider: + """Build a bare provider with the real github_copilot spec (no network).""" + p = OpenAICompatProvider.__new__(OpenAICompatProvider) + p.default_model = "github_copilot/gpt-5.4-mini" + p._spec = find_by_name("github_copilot") + p._effective_base = "https://api.githubcopilot.com" + p._responses_failures = {} + p._responses_tripped_at = {} + return p + + +def test_should_use_responses_api_allows_github_copilot_non_openai_base(): + """github_copilot bypasses the direct-OpenAI base check and still opts in for GPT-5.""" + provider = _make_copilot_provider() + assert provider._should_use_responses_api("github_copilot/gpt-5.4-mini", None) is True + assert provider._should_use_responses_api("github_copilot/o3", None) is True + + +def test_build_responses_body_strips_github_copilot_prefix(): + """/responses body must send the bare model name; gateway rejects routing prefixes.""" + provider = _make_copilot_provider() + body = provider._build_responses_body( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model="github_copilot/gpt-5.4-mini", + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + assert body["model"] == "gpt-5.4-mini" + + +@pytest.mark.asyncio +async def test_github_copilot_does_not_fall_back_from_responses_error(): + """On /responses failure, github_copilot must re-raise instead of hitting /chat/completions.""" + from nanobot.providers.github_copilot_provider import GitHubCopilotProvider + + mock_client = MagicMock() + mock_client.api_key = "no-key" + + class _CompatError(Exception): + """Looks like a fallback-eligible error on other providers.""" + status_code = 400 + body = "Unsupported parameter responses api" + + mock_client.responses.create = AsyncMock(side_effect=_CompatError("boom")) + mock_client.chat.completions.create = AsyncMock() + + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client): + provider = GitHubCopilotProvider(default_model="github_copilot/gpt-5.4-mini") + 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.4-mini", + max_tokens=16, + temperature=0.1, + ) + + assert response.finish_reason == "error" + mock_client.responses.create.assert_awaited_once() + mock_client.chat.completions.create.assert_not_awaited()