Merge remote-tracking branch 'origin/main' into pr-3379

This commit is contained in:
Xubin Ren
2026-04-22 08:08:05 +00:00
4 changed files with 98 additions and 5 deletions
+16 -3
View File
@@ -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)
+1
View File
@@ -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(
+2 -2
View File
@@ -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,
)
@@ -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()