mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
feat: Enhance OpenAI provider configuration with extraBody support and apiType validation
This commit is contained in:
parent
d472595417
commit
c433d60681
@ -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>
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user