mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
refactor(providers): declare Responses capabilities
This commit is contained in:
parent
cdb75f8e7d
commit
720f14661f
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user