From c433d60681673f8a47950acb6208056c982bfafc Mon Sep 17 00:00:00 2001 From: outlook84 <96007761+outlook84@users.noreply.github.com> Date: Sat, 23 May 2026 19:13:07 +0800 Subject: [PATCH] feat: Enhance OpenAI provider configuration with extraBody support and apiType validation --- docs/configuration.md | 17 ++++ nanobot/config/schema.py | 12 ++- nanobot/providers/factory.py | 2 +- nanobot/providers/openai_compat_provider.py | 52 ++++++++++-- nanobot/webui/settings_api.py | 43 +++++----- tests/channels/test_websocket_channel.py | 33 +++++++- tests/config/test_model_presets.py | 12 +++ tests/providers/test_extra_body_config.py | 81 +++++++++++++++++++ .../test_responses_circuit_breaker.py | 7 ++ 9 files changed, 230 insertions(+), 29 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 94b237058..15e1761dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -185,6 +185,23 @@ By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normal Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`. +`extraBody` follows the selected OpenAI API surface. With Chat Completions, nanobot passes it through as the SDK `extra_body` value. With Responses, configure it in Responses API body shape; nanobot merges ordinary top-level fields into the Responses request body, appends `extraBody.tools` after generated function tools, and merges `extraBody.include` without duplicates: + +```json +{ + "providers": { + "openai": { + "apiKey": "${OPENAI_API_KEY}", + "apiType": "responses", + "extraBody": { + "tools": [{ "type": "web_search" }], + "include": ["web_search_call.action.sources"] + } + } + } +} +``` +
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0309e462b..f194820cc 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -173,7 +173,7 @@ class ProviderConfig(Base): api_base: str | None = None api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) - extra_body: dict[str, Any] | None = None # Extra fields merged into every request body + extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface class BedrockProviderConfig(ProviderConfig): @@ -224,6 +224,16 @@ class ProvidersConfig(Base): qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) + @model_validator(mode="after") + def _validate_api_type_scope(self) -> "ProvidersConfig": + for name in self.__class__.model_fields: + if name == "openai": + continue + provider = getattr(self, name, None) + if isinstance(provider, ProviderConfig) and provider.api_type != "auto": + raise ValueError("providers..api_type is only supported for providers.openai") + return self + class HeartbeatConfig(Base): """Heartbeat service configuration.""" diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 6934db8ce..a10c0d5cd 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -98,7 +98,7 @@ def _make_provider_core( extra_headers=p.extra_headers if p else None, spec=spec, extra_body=p.extra_body if p else None, - api_type=p.api_type if p else "auto", + api_type=p.api_type if p and provider_name == "openai" else "auto", ) provider.generation = resolved.to_generation_settings() diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 8c374d1de..582a431e4 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -274,6 +274,47 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any return merged +def _merge_unique_list(base: Any, override: Any) -> Any: + """Append list values while preserving order and removing duplicates.""" + if not isinstance(base, list) or not isinstance(override, list): + return override + result: list[Any] = [] + seen: set[str] = set() + for value in [*base, *override]: + try: + key = json.dumps(value, sort_keys=True, ensure_ascii=False) + except Exception: + key = repr(value) + if key in seen: + continue + seen.add(key) + result.append(value) + return result + + +def _merge_responses_extra_body( + body: dict[str, Any], + extra_body: dict[str, Any], +) -> dict[str, Any]: + """Merge configured Responses API body fields without clobbering tools.""" + reserved = {"include", "tools"} + regular_extra = {key: value for key, value in extra_body.items() if key not in reserved} + merged = _deep_merge(body, regular_extra) + + if "include" in extra_body: + merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"]) + + if "tools" in extra_body: + current_tools = body.get("tools") + configured_tools = extra_body["tools"] + if isinstance(current_tools, list) and isinstance(configured_tools, list): + merged["tools"] = [*current_tools, *configured_tools] + else: + merged["tools"] = configured_tools + + return merged + + class OpenAICompatProvider(LLMProvider): """Unified provider for all OpenAI-compatible APIs. @@ -296,7 +337,7 @@ class OpenAICompatProvider(LLMProvider): self.extra_headers = extra_headers or {} self._spec = spec self._extra_body = extra_body or {} - self._api_type = api_type + self._api_type = api_type if spec and spec.name == "openai" else "auto" if api_key and spec and spec.env_key: self._setup_env(api_key, api_base) @@ -697,12 +738,9 @@ class OpenAICompatProvider(LLMProvider): if self._api_type == "chat_completions": return False if self._spec and self._spec.name not in ("openai", "github_copilot"): - if self._api_type != "responses": - return False + return False if self._api_type == "responses": return self._responses_circuit_allows_probe(model, reasoning_effort) - 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 @@ -815,6 +853,10 @@ class OpenAICompatProvider(LLMProvider): body["tools"] = convert_tools(tools) body["tool_choice"] = tool_choice or "auto" + extra_body = getattr(self, "_extra_body", {}) + if extra_body: + body = _merge_responses_extra_body(body, extra_body) + return body # ------------------------------------------------------------------ diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 1a9c90fa1..7e093a5e2 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -181,18 +181,18 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]: provider_config = getattr(config.providers, spec.name, None) if provider_config is None or spec.is_oauth: continue - providers.append( - { - "name": spec.name, - "label": spec.label, - "configured": _provider_configured_for_settings(spec, provider_config), - "api_key_required": _provider_requires_api_key(spec), - "api_key_hint": _mask_secret_hint(provider_config.api_key), - "api_base": provider_config.api_base, - "default_api_base": spec.default_api_base or None, - "api_type": provider_config.api_type, - } - ) + row = { + "name": spec.name, + "label": spec.label, + "configured": _provider_configured_for_settings(spec, provider_config), + "api_key_required": _provider_requires_api_key(spec), + "api_key_hint": _mask_secret_hint(provider_config.api_key), + "api_base": provider_config.api_base, + "default_api_base": spec.default_api_base or None, + } + if spec.name == "openai": + row["api_type"] = provider_config.api_type + providers.append(row) search_config = config.tools.web.search image_config = config.tools.image_generation @@ -472,15 +472,16 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]: provider_config.api_base = api_base changed = True - if "api_type" in query or "apiType" in query: - api_type = (_query_first_alias(query, "api_type", "apiType") or "").strip() - try: - parsed_api_type = type(provider_config)(api_type=api_type).api_type - except Exception: - raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None - if provider_config.api_type != parsed_api_type: - provider_config.api_type = parsed_api_type - changed = True + if "api_type" in query: + if spec.name == "openai": + api_type = (_query_first(query, "api_type") or "").strip() + try: + parsed_api_type = type(provider_config)(api_type=api_type).api_type + except Exception: + raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None + if provider_config.api_type != parsed_api_type: + provider_config.api_type = parsed_api_type + changed = True if changed: save_config(config) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 03143d540..2b0bb76cd 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -30,7 +30,7 @@ from nanobot.channels.websocket import ( ) from nanobot.config.loader import load_config, save_config from nanobot.config.schema import Config, ModelPresetConfig -from nanobot.webui.settings_api import settings_payload +from nanobot.webui.settings_api import settings_payload, update_provider_settings # -- Shared helpers (aligned with test_websocket_integration.py) --------------- @@ -1351,6 +1351,37 @@ def test_settings_payload_normalizes_camel_case_provider( assert body["agent"]["provider"] == "minimax_anthropic" +def test_settings_payload_exposes_api_type_only_for_openai(monkeypatch, tmp_path) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.openai.api_type = "responses" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + body = settings_payload() + providers = {provider["name"]: provider for provider in body["providers"]} + + assert providers["openai"]["api_type"] == "responses" + assert "api_type" not in providers["custom"] + + +def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, tmp_path) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + body = update_provider_settings({ + "provider": ["custom"], + "api_base": ["https://example.test/v1"], + "api_type": ["responses"], + }) + + assert body["providers"] + config = load_config(config_path) + assert config.providers.custom.api_base == "https://example.test/v1" + assert config.providers.custom.api_type == "auto" + + @pytest.mark.asyncio async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None: port = 29880 diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index 4fa75d4bd..06e015746 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -36,6 +36,18 @@ def test_provider_api_type_accepts_exact_values_only() -> None: }) +def test_provider_api_type_is_openai_only() -> None: + with pytest.raises(ValueError, match="only supported"): + Config.model_validate({ + "providers": { + "custom": { + "apiBase": "https://example.test/v1", + "apiType": "responses", + } + } + }) + + def test_legacy_defaults_config_without_presets_still_resolves() -> None: config = Config.model_validate({ "agents": { diff --git a/tests/providers/test_extra_body_config.py b/tests/providers/test_extra_body_config.py index 08ca33408..5b69348c2 100644 --- a/tests/providers/test_extra_body_config.py +++ b/tests/providers/test_extra_body_config.py @@ -9,6 +9,7 @@ from nanobot.providers.openai_compat_provider import ( OpenAICompatProvider, _deep_merge, ) +from nanobot.providers.registry import find_by_name # --------------------------------------------------------------------------- # _deep_merge unit tests @@ -185,6 +186,86 @@ class TestBuildKwargsExtraBody: assert kwargs["extra_body"]["repetition_penalty"] == 1.15 +class TestBuildResponsesBodyExtraBody: + """Verify extra_body flows into Responses API request bodies.""" + + def test_responses_extra_body_merges_top_level_fields(self) -> None: + provider = OpenAICompatProvider( + api_key="test-key", + default_model="gpt-5", + spec=find_by_name("openai"), + extra_body={ + "metadata": {"source": "test"}, + "parallel_tool_calls": False, + }, + ) + + body = provider._build_responses_body( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort=None, tool_choice=None, + ) + + assert body["metadata"] == {"source": "test"} + assert body["parallel_tool_calls"] is False + + def test_responses_extra_body_appends_tools(self) -> None: + provider = OpenAICompatProvider( + api_key="test-key", + default_model="gpt-5", + spec=find_by_name("openai"), + extra_body={"tools": [{"type": "web_search"}]}, + ) + + body = provider._build_responses_body( + messages=_simple_messages(), + tools=[{ + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file", + "parameters": {"type": "object"}, + }, + }], + model=None, max_tokens=100, temperature=0.1, + reasoning_effort=None, tool_choice=None, + ) + + assert body["tools"] == [ + { + "type": "function", + "name": "read_file", + "description": "Read a file", + "parameters": {"type": "object"}, + }, + {"type": "web_search"}, + ] + + def test_responses_extra_body_merges_include_without_duplicates(self) -> None: + provider = OpenAICompatProvider( + api_key="test-key", + default_model="gpt-5", + spec=find_by_name("openai"), + extra_body={ + "include": [ + "reasoning.encrypted_content", + "web_search_call.action.sources", + ], + }, + ) + + body = provider._build_responses_body( + messages=_simple_messages(), + tools=None, model=None, max_tokens=100, + temperature=0.1, reasoning_effort="high", tool_choice=None, + ) + + assert body["include"] == [ + "reasoning.encrypted_content", + "web_search_call.action.sources", + ] + + # --------------------------------------------------------------------------- # Schema validation # --------------------------------------------------------------------------- diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py index 9a148c7fe..ed07ca2e8 100644 --- a/tests/providers/test_responses_circuit_breaker.py +++ b/tests/providers/test_responses_circuit_breaker.py @@ -39,6 +39,13 @@ def test_api_type_responses_forces_responses_for_openai(provider): assert provider._should_use_responses_api("gpt-4o", None) is True +def test_api_type_responses_does_not_force_non_openai(provider): + provider._spec = type("Spec", (), {"name": "custom"})() + provider._api_type = "responses" + + assert provider._should_use_responses_api("gpt-4o", None) is False + + def test_circuit_opens_after_threshold(provider): for _ in range(_RESPONSES_FAILURE_THRESHOLD): provider._record_responses_failure("gpt-5", None)