From 720f14661faa321d63c3b75be5c6c9bd3dbddd28 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sat, 1 Aug 2026 12:13:01 +0800 Subject: [PATCH] refactor(providers): declare Responses capabilities --- nanobot/providers/factory.py | 5 +- nanobot/providers/openai_compat_provider.py | 85 ++++++++++--------- nanobot/providers/registry.py | 51 +++++++++-- .../providers/test_github_copilot_routing.py | 1 + .../test_responses_circuit_breaker.py | 60 ++++++++++--- 5 files changed, 140 insertions(+), 62 deletions(-) diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 37fa2ed8b..0723ec4ca 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -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, ) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 7b6238f22..7dc89e74a 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -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 diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index f46b54f90..7582ac155 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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( diff --git a/tests/providers/test_github_copilot_routing.py b/tests/providers/test_github_copilot_routing.py index 265a2510d..326dee6bd 100644 --- a/tests/providers/test_github_copilot_routing.py +++ b/tests/providers/test_github_copilot_routing.py @@ -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 diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py index 1b6c95cac..523e05b3b 100644 --- a/tests/providers/test_responses_circuit_breaker.py +++ b/tests/providers/test_responses_circuit_breaker.py @@ -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