diff --git a/docs/configuration.md b/docs/configuration.md index 9b1aaca48..94b237058 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,6 +167,26 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) | +
+OpenAI + +By default, OpenAI uses `apiType: "auto"`: nanobot calls Chat Completions normally and routes GPT-5/o-series or explicit `reasoningEffort` requests through the Responses API when useful. You can force a specific API surface: + +```json +{ + "providers": { + "openai": { + "apiKey": "${OPENAI_API_KEY}", + "apiType": "chat_completions" + } + } +} +``` + +Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`. + +
+
Skywork / APIFree diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 5844faf8c..0309e462b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -171,6 +171,7 @@ class ProviderConfig(Base): api_key: str | None = None 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 diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 288611392..6934db8ce 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -98,6 +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", ) provider.generation = resolved.to_generation_settings() @@ -183,6 +184,7 @@ def provider_signature( config.get_api_base(fallback.model, preset=fallback), fp.extra_headers if fp else None, fp.extra_body if fp else None, + fp.api_type if fp else "auto", getattr(fp, "region", None) if fp else None, getattr(fp, "profile", None) if fp else None, fallback.max_tokens, @@ -199,6 +201,7 @@ def provider_signature( config.get_api_base(resolved.model, preset=resolved), p.extra_headers if p else None, p.extra_body if p else None, + p.api_type if p else "auto", getattr(p, "region", None) if p else None, getattr(p, "profile", None) if p else None, resolved.max_tokens, diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index cd6dd300b..8c374d1de 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -289,12 +289,14 @@ class OpenAICompatProvider(LLMProvider): extra_headers: dict[str, str] | None = None, spec: ProviderSpec | None = None, extra_body: dict[str, Any] | None = None, + api_type: str = "auto", ): super().__init__(api_key, api_base) self.default_model = default_model self.extra_headers = extra_headers or {} self._spec = spec self._extra_body = extra_body or {} + self._api_type = api_type if api_key and spec and spec.env_key: self._setup_env(api_key, api_base) @@ -692,6 +694,13 @@ class OpenAICompatProvider(LLMProvider): reasoning_effort: str | None, ) -> bool: """Use Responses API only for direct OpenAI requests that benefit from it.""" + 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 + 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": @@ -707,7 +716,14 @@ class OpenAICompatProvider(LLMProvider): if not wants: return False - # Circuit breaker: skip after repeated failures, probe periodically. + return self._responses_circuit_allows_probe(model, reasoning_effort) + + def _responses_circuit_allows_probe( + self, + model: str | None, + reasoning_effort: str | None, + ) -> bool: + """Return False when the Responses API circuit breaker is open.""" key = _responses_circuit_key(model, self.default_model, reasoning_effort) failures = self._responses_failures.get(key, 0) if failures >= _RESPONSES_FAILURE_THRESHOLD: @@ -1269,6 +1285,8 @@ class OpenAICompatProvider(LLMProvider): # falling back to /chat/completions cannot succeed and would # hide the real error. raise + if self._api_type == "responses": + raise if not self._should_fallback_from_responses_error(responses_error): raise self._record_responses_failure(model, reasoning_effort) @@ -1342,6 +1360,8 @@ class OpenAICompatProvider(LLMProvider): # falling back to /chat/completions cannot succeed and would # hide the real error. raise + if self._api_type == "responses": + raise if not self._should_fallback_from_responses_error(responses_error): raise self._record_responses_failure(model, reasoning_effort) diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 7019597a9..1a9c90fa1 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -190,6 +190,7 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]: "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, } ) @@ -471,6 +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 changed: save_config(config) image_config = config.tools.image_generation diff --git a/tests/agent/test_runner_fallback.py b/tests/agent/test_runner_fallback.py index 0e36fb02a..4ae161e4a 100644 --- a/tests/agent/test_runner_fallback.py +++ b/tests/agent/test_runner_fallback.py @@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None: signature = provider_signature(config) fallback_signatures = signature[-1] - assert fallback_signatures[0][11] is None + assert fallback_signatures[0][12] is None # -- FallbackProvider tests -- diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index fe01c2547..4fa75d4bd 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -1,3 +1,5 @@ +import pytest + from nanobot.config.schema import Config @@ -12,6 +14,28 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None: assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort +def test_provider_api_type_accepts_exact_values_only() -> None: + config = Config.model_validate({ + "providers": { + "openai": { + "apiKey": "sk-test", + "apiType": "responses", + } + } + }) + assert config.providers.openai.api_type == "responses" + + with pytest.raises(ValueError): + Config.model_validate({ + "providers": { + "openai": { + "apiKey": "sk-test", + "apiType": "response", + } + } + }) + + def test_legacy_defaults_config_without_presets_still_resolves() -> None: config = Config.model_validate({ "agents": { diff --git a/tests/providers/test_github_copilot_routing.py b/tests/providers/test_github_copilot_routing.py index 3b6c194d9..b5dd46670 100644 --- a/tests/providers/test_github_copilot_routing.py +++ b/tests/providers/test_github_copilot_routing.py @@ -20,6 +20,7 @@ def _make_copilot_provider() -> OpenAICompatProvider: p.default_model = "github_copilot/gpt-5.4-mini" p._spec = find_by_name("github_copilot") p._effective_base = "https://api.githubcopilot.com" + p._api_type = "auto" p._responses_failures = {} p._responses_tripped_at = {} return p diff --git a/tests/providers/test_responses_circuit_breaker.py b/tests/providers/test_responses_circuit_breaker.py index 409aea1d5..9a148c7fe 100644 --- a/tests/providers/test_responses_circuit_breaker.py +++ b/tests/providers/test_responses_circuit_breaker.py @@ -18,6 +18,7 @@ def provider(): p.default_model = "gpt-5" p._spec = type("Spec", (), {"name": "openai"})() p._effective_base = "https://api.openai.com/v1" + p._api_type = "auto" p._responses_failures = {} p._responses_tripped_at = {} return p @@ -27,6 +28,17 @@ def test_responses_api_available_by_default(provider): assert provider._should_use_responses_api("gpt-5", None) is True +def test_api_type_chat_completions_disables_responses(provider): + provider._api_type = "chat_completions" + assert provider._should_use_responses_api("gpt-5", None) is False + + +def test_api_type_responses_forces_responses_for_openai(provider): + provider.default_model = "gpt-4o" + provider._api_type = "responses" + assert provider._should_use_responses_api("gpt-4o", None) is True + + def test_circuit_opens_after_threshold(provider): for _ in range(_RESPONSES_FAILURE_THRESHOLD): provider._record_responses_failure("gpt-5", None) diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 1176fe317..1fe379f6a 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -142,6 +142,8 @@ interface ModelConfigurationDraft { type PendingRestartSection = "runtime" | "web" | "image"; type PendingRestartSections = Record; +type ProviderApiType = "auto" | "chat_completions" | "responses"; +type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType }; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; @@ -189,6 +191,11 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = { codeWrap: true, brandLogos: true, }; +const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ + { value: "auto", label: "Auto" }, + { value: "chat_completions", label: "Chat Completions" }, + { value: "responses", label: "Responses" }, +]; const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map( ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ @@ -303,7 +310,7 @@ export function SettingsView({ const [mcpFieldValues, setMcpFieldValues] = useState>>({}); const [customMcpForm, setCustomMcpForm] = useState(DEFAULT_CUSTOM_MCP_FORM); const [mcpConfigImport, setMcpConfigImport] = useState(""); - const [providerForms, setProviderForms] = useState>({}); + const [providerForms, setProviderForms] = useState>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState>({}); const [editingProviderKeys, setEditingProviderKeys] = useState>({}); const [pendingRestartSections, setPendingRestartSections] = useState( @@ -460,6 +467,7 @@ export function SettingsView({ next[provider.name] = { apiKey: next[provider.name]?.apiKey ?? "", apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "", + apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto", }; } return next; @@ -620,7 +628,7 @@ export function SettingsView({ if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); if (!provider) return; - const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; + const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" }; const apiKey = providerForm.apiKey.trim(); const apiKeyRequired = provider.api_key_required ?? true; if (!provider.configured && apiKeyRequired && !apiKey) { @@ -633,6 +641,7 @@ export function SettingsView({ provider: providerName, apiKey: apiKey || undefined, apiBase: providerForm.apiBase.trim(), + apiType: providerForm.apiType, }); applyPayload(payload); if (payload.requires_restart) { @@ -643,6 +652,7 @@ export function SettingsView({ [providerName]: { apiKey: "", apiBase: providerForm.apiBase.trim(), + apiType: providerForm.apiType, }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); @@ -719,6 +729,7 @@ export function SettingsView({ [providerName]: { apiKey: "", apiBase: provider.api_base ?? provider.default_api_base ?? "", + apiType: provider.api_type ?? "auto", }, })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); @@ -772,6 +783,7 @@ export function SettingsView({ [providerName]: { apiKey: "", apiBase: forms[providerName]?.apiBase ?? "", + apiType: forms[providerName]?.apiType ?? "auto", }, })); setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); @@ -957,6 +969,7 @@ export function SettingsView({ [provider]: { apiKey: prev[provider]?.apiKey ?? "", apiBase: prev[provider]?.apiBase ?? "", + apiType: prev[provider]?.apiType ?? "auto", ...value, }, })) @@ -1713,7 +1726,7 @@ function ProvidersSettings({ }: { settings: SettingsPayload; expandedProvider: string | null; - providerForms: Record; + providerForms: Record; visibleProviderKeys: Record; editingProviderKeys: Record; providerSaving: string | null; @@ -1723,7 +1736,7 @@ function ProvidersSettings({ onToggleProvider: (provider: string) => void; onToggleProviderKey: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void; - onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; + onChangeProviderForm: (provider: string, value: Partial) => void; onSaveProvider: (provider: string) => void; onResetProviderDraft: (provider: string) => void; imageProviderRestartPending: boolean; @@ -1744,6 +1757,7 @@ function ProvidersSettings({ const form = providerForms[provider.name] ?? { apiKey: "", apiBase: provider.api_base ?? provider.default_api_base ?? "", + apiType: provider.api_type ?? "auto", }; const saving = providerSaving === provider.name; const keyVisible = !!visibleProviderKeys[provider.name]; @@ -1855,6 +1869,38 @@ function ProvidersSettings({ className="h-9 rounded-full text-[13px]" /> + {provider.name === "openai" ? ( + + ) : null}