refactor(providers): declare Responses capabilities

This commit is contained in:
chengyongru 2026-08-01 12:13:01 +08:00
parent cdb75f8e7d
commit 720f14661f
5 changed files with 140 additions and 62 deletions

View File

@ -24,7 +24,6 @@ class ProviderSnapshot:
@dataclass(frozen=True)
class _ProviderSetup:
model: str
provider_name: str
provider_config: ProviderConfig | None
spec: ProviderSpec | None
backend: str
@ -100,7 +99,6 @@ def _resolve_provider_setup(
return _ProviderSetup(
model=model,
provider_name=provider_name,
provider_config=p,
spec=spec,
backend=backend,
@ -136,7 +134,6 @@ def _make_provider_core(
model=model,
)
model = setup.model
provider_name = setup.provider_name
p = setup.provider_config
spec = setup.spec
backend = setup.backend
@ -201,7 +198,7 @@ def _make_provider_core(
extra_headers=_provider_extra_headers(spec, p),
spec=spec,
extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto",
api_type=p.api_type if p else "auto",
extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
)

View File

@ -49,7 +49,7 @@ from nanobot.providers.openai_responses import (
if TYPE_CHECKING:
from openai import AsyncOpenAI as AsyncOpenAIType
from nanobot.providers.registry import ProviderSpec
from nanobot.providers.registry import ProviderSpec, ResponsesCapabilities
# Module-level placeholder — set lazily by _ensure_client on first real
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
@ -470,7 +470,12 @@ class OpenAICompatProvider(LLMProvider):
self.extra_headers = extra_headers or {}
self._spec = spec
self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto"
responses = spec.responses if spec is not None else None
self._api_type = (
api_type
if responses is not None and responses.allows_api_type_override
else "auto"
)
self._extra_query = extra_query or {}
self._proxy = proxy or None
self._native_compaction_available = True
@ -961,39 +966,33 @@ class OpenAICompatProvider(LLMProvider):
"""Choose Responses for providers/models that explicitly support it."""
if self._api_type == "chat_completions":
return False
spec_name = self._spec.name if self._spec is not None else None
model_name = self._request_model_name(model or self.default_model).lower()
supported_models = {
supported.lower()
for supported in getattr(self._spec, "responses_models", ())
}
model_responses = any(
model_name == supported or model_name.endswith(f"/{supported}")
for supported in supported_models
)
provider_responses = spec_name in ("openai", "github_copilot")
if not provider_responses and not model_responses:
capabilities = self._responses_capabilities()
if capabilities is None:
return False
model_name = self._request_model_name(model or self.default_model).lower()
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
if not _is_direct_openai_base(self._effective_base):
return False
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
wants = False
if model_responses:
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
if not wants:
explicitly_supported = capabilities.matches_model(model_name)
wants_auto_route = capabilities.auto_route and (
(reasoning_effort is not None and reasoning_effort.lower() != "none")
or any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
)
if not explicitly_supported and not wants_auto_route:
return False
return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_capabilities(self) -> ResponsesCapabilities | None:
return self._spec.responses if self._spec is not None else None
def _responses_state_provider(self) -> str:
spec_name = self._spec.name if self._spec is not None else "custom"
effective_base = self._effective_base or "https://api.openai.com/v1"
@ -1016,14 +1015,20 @@ class OpenAICompatProvider(LLMProvider):
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Enable server compaction only on direct OpenAI Responses endpoints."""
_ = model
capabilities = self._responses_capabilities()
if (
not self._native_compaction_available
or self._api_type == "chat_completions"
or capabilities is None
or not capabilities.supports_native_compaction
):
return False
if self._spec is not None and self._spec.name != "openai":
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
return _is_direct_openai_base(self._effective_base)
return True
def _responses_circuit_allows_probe(
self,
@ -1111,7 +1116,10 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
capabilities = self._responses_capabilities()
preserve_reasoning = (
capabilities is not None and capabilities.reasoning_replay == "plaintext"
)
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
@ -1142,10 +1150,15 @@ class OpenAICompatProvider(LLMProvider):
"compact_threshold": compact_threshold,
}]
if self._supports_temperature(model_name, reasoning_effort):
supports_temperature = self._supports_temperature(model_name, reasoning_effort)
if supports_temperature:
body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
if (
not supports_temperature
and capabilities is not None
and capabilities.reasoning_replay == "encrypted"
):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
@ -1766,10 +1779,8 @@ 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.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._api_type == "responses":
raise
@ -1862,10 +1873,8 @@ class OpenAICompatProvider(LLMProvider):
)
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.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._api_type == "responses":
raise

View File

@ -13,7 +13,7 @@ Every entry writes out all fields so you can copy-paste as a template.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Literal
from pydantic.alias_generators import to_snake
@ -28,6 +28,32 @@ class ProviderModelSpec:
context_window: int | None = None
@dataclass(frozen=True)
class ResponsesCapabilities:
"""Provider capabilities for the shared OpenAI Responses execution path.
``reasoning_replay`` selects whether multi-turn reasoning is retained as
encrypted server content, plaintext local history, or not requested.
"""
models: tuple[str, ...] = ()
auto_route: bool = False
requires_direct_openai_base: bool = False
allows_api_type_override: bool = False
reasoning_replay: Literal["none", "encrypted", "plaintext"] = "none"
supports_native_compaction: bool = False
allows_chat_fallback: bool = True
def matches_model(self, model: str) -> bool:
"""Return whether *model* is explicitly routed through Responses."""
model_name = model.lower()
return any(
model_name == supported.lower()
or model_name.endswith(f"/{supported.lower()}")
for supported in self.models
)
@dataclass(frozen=True)
class ProviderSpec:
"""One LLM provider's metadata. See PROVIDERS below for real examples.
@ -111,10 +137,8 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = ()
# Models that expose the OpenAI Responses wire format. This is model-level
# because providers may add Responses support incrementally (DeepSeek V4
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = ()
# Capabilities for providers/models served through the shared Responses path.
responses: ResponsesCapabilities | None = None
# When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into
@ -373,6 +397,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="OpenAI",
backend="openai_compat",
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
requires_direct_openai_base=True,
allows_api_type_override=True,
reasoning_replay="encrypted",
supports_native_compaction=True,
),
),
# OpenAI Codex: OAuth-based, dedicated provider
ProviderSpec(
@ -456,6 +487,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=True,
is_oauth=True,
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
reasoning_replay="encrypted",
allows_chat_fallback=False,
),
),
# DeepSeek: OpenAI-compatible at api.deepseek.com
ProviderSpec(
@ -466,7 +502,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
responses=ResponsesCapabilities(
models=("deepseek-v4-flash",),
reasoning_replay="plaintext",
),
),
# Gemini: Google's OpenAI-compatible endpoint
ProviderSpec(

View File

@ -48,6 +48,7 @@ def test_build_responses_body_strips_github_copilot_prefix():
provider_context=ProviderCallContext(context_window_tokens=128_000),
)
assert body["model"] == "gpt-5.4-mini"
assert body["include"] == ["reasoning.encrypted_content"]
assert "context_management" not in body

View File

@ -10,6 +10,11 @@ from nanobot.providers.openai_compat_provider import (
_RESPONSES_PROBE_INTERVAL_S,
OpenAICompatProvider,
)
from nanobot.providers.registry import (
ProviderSpec,
ResponsesCapabilities,
find_by_name,
)
@pytest.fixture()
@ -17,7 +22,7 @@ def provider():
"""A direct-OpenAI provider with Responses API support."""
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._spec = find_by_name("openai")
p._effective_base = "https://api.openai.com/v1"
p._api_type = "auto"
p._responses_failures = {}
@ -30,12 +35,7 @@ def test_responses_api_available_by_default(provider):
def test_deepseek_v4_flash_uses_responses_by_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash"
@ -44,17 +44,48 @@ def test_deepseek_v4_flash_uses_responses_by_model(provider):
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
def test_responses_behavior_is_declared_by_capabilities(provider):
provider._spec = ProviderSpec(
name="example",
keywords=("example",),
env_key="EXAMPLE_API_KEY",
responses=ResponsesCapabilities(
models=("example-o3",),
reasoning_replay="plaintext",
),
)
provider._effective_base = "https://example.test"
assert provider._should_use_responses_api("example-o3", None) is True
body = provider._build_responses_body(
messages=[
{"role": "user", "content": "question"},
{
"role": "assistant",
"reasoning_content": "think first",
"content": "answer",
},
{"role": "user", "content": "follow-up"},
],
tools=None,
model="example-o3",
max_tokens=100,
temperature=0.1,
reasoning_effort="high",
tool_choice=None,
)
assert {"type": "reasoning", "content": "think first"} in body["input"]
assert "include" not in body
def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {}
@ -73,6 +104,7 @@ def test_direct_openai_enables_server_compaction(provider):
"type": "compaction",
"compact_threshold": 70_000,
}]
assert body["include"] == ["reasoning.encrypted_content"]
def test_api_type_chat_completions_disables_responses(provider):
@ -96,7 +128,7 @@ def test_api_type_responses_ignores_circuit_breaker(provider):
def test_api_type_responses_does_not_force_non_openai(provider):
provider._spec = type("Spec", (), {"name": "custom"})()
provider._spec = find_by_name("custom")
provider._api_type = "responses"
assert provider._should_use_responses_api("gpt-4o", None) is False