feat: Enhance OpenAI provider configuration with extraBody support and apiType validation

This commit is contained in:
outlook84 2026-05-23 19:13:07 +08:00 committed by Xubin Ren
parent d472595417
commit c433d60681
9 changed files with 230 additions and 29 deletions

View File

@ -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`. 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"]
}
}
}
}
```
</details> </details>
<details> <details>

View File

@ -173,7 +173,7 @@ class ProviderConfig(Base):
api_base: str | None = None api_base: str | None = None
api_type: Literal["auto", "chat_completions", "responses"] = "auto" # Request API surface 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_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): class BedrockProviderConfig(ProviderConfig):
@ -224,6 +224,16 @@ class ProvidersConfig(Base):
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆) qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys) 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.<name>.api_type is only supported for providers.openai")
return self
class HeartbeatConfig(Base): class HeartbeatConfig(Base):
"""Heartbeat service configuration.""" """Heartbeat service configuration."""

View File

@ -98,7 +98,7 @@ def _make_provider_core(
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
spec=spec, spec=spec,
extra_body=p.extra_body if p else None, 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() provider.generation = resolved.to_generation_settings()

View File

@ -274,6 +274,47 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
return merged 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): class OpenAICompatProvider(LLMProvider):
"""Unified provider for all OpenAI-compatible APIs. """Unified provider for all OpenAI-compatible APIs.
@ -296,7 +337,7 @@ class OpenAICompatProvider(LLMProvider):
self.extra_headers = extra_headers or {} self.extra_headers = extra_headers or {}
self._spec = spec self._spec = spec
self._extra_body = extra_body or {} 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: if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base) self._setup_env(api_key, api_base)
@ -697,12 +738,9 @@ class OpenAICompatProvider(LLMProvider):
if self._api_type == "chat_completions": if self._api_type == "chat_completions":
return False return False
if self._spec and self._spec.name not in ("openai", "github_copilot"): 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": if self._api_type == "responses":
return self._responses_circuit_allows_probe(model, reasoning_effort) 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 self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base): if not _is_direct_openai_base(self._effective_base):
return False return False
@ -815,6 +853,10 @@ class OpenAICompatProvider(LLMProvider):
body["tools"] = convert_tools(tools) body["tools"] = convert_tools(tools)
body["tool_choice"] = tool_choice or "auto" 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 return body
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@ -181,8 +181,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
provider_config = getattr(config.providers, spec.name, None) provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth: if provider_config is None or spec.is_oauth:
continue continue
providers.append( row = {
{
"name": spec.name, "name": spec.name,
"label": spec.label, "label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config), "configured": _provider_configured_for_settings(spec, provider_config),
@ -190,9 +189,10 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
"api_key_hint": _mask_secret_hint(provider_config.api_key), "api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base, "api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None, "default_api_base": spec.default_api_base or None,
"api_type": provider_config.api_type,
} }
) if spec.name == "openai":
row["api_type"] = provider_config.api_type
providers.append(row)
search_config = config.tools.web.search search_config = config.tools.web.search
image_config = config.tools.image_generation image_config = config.tools.image_generation
@ -472,8 +472,9 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_config.api_base = api_base provider_config.api_base = api_base
changed = True changed = True
if "api_type" in query or "apiType" in query: if "api_type" in query:
api_type = (_query_first_alias(query, "api_type", "apiType") or "").strip() if spec.name == "openai":
api_type = (_query_first(query, "api_type") or "").strip()
try: try:
parsed_api_type = type(provider_config)(api_type=api_type).api_type parsed_api_type = type(provider_config)(api_type=api_type).api_type
except Exception: except Exception:

View File

@ -30,7 +30,7 @@ from nanobot.channels.websocket import (
) )
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig 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) --------------- # -- 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" 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 @pytest.mark.asyncio
async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None: async def test_end_to_end_server_pushes_streaming_deltas_to_client(bus: MagicMock) -> None:
port = 29880 port = 29880

View File

@ -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: def test_legacy_defaults_config_without_presets_still_resolves() -> None:
config = Config.model_validate({ config = Config.model_validate({
"agents": { "agents": {

View File

@ -9,6 +9,7 @@ from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider, OpenAICompatProvider,
_deep_merge, _deep_merge,
) )
from nanobot.providers.registry import find_by_name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _deep_merge unit tests # _deep_merge unit tests
@ -185,6 +186,86 @@ class TestBuildKwargsExtraBody:
assert kwargs["extra_body"]["repetition_penalty"] == 1.15 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 # Schema validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -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 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): def test_circuit_opens_after_threshold(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD): for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None) provider._record_responses_failure("gpt-5", None)