Merge remote-tracking branch 'origin/main' into pr-3379
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user