feat: Add OpenAI API type configuration and update provider settings

This commit is contained in:
outlook84 2026-05-23 18:04:40 +08:00 committed by Xubin Ren
parent 92915ea424
commit d472595417
12 changed files with 147 additions and 6 deletions

View File

@ -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` | | `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) | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
<details>
<summary><b>OpenAI</b></summary>
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`.
</details>
<details> <details>
<summary><b>Skywork / APIFree</b></summary> <summary><b>Skywork / APIFree</b></summary>

View File

@ -171,6 +171,7 @@ class ProviderConfig(Base):
api_key: str | None = None api_key: str | None = None
api_base: 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_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 fields merged into every request body

View File

@ -98,6 +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",
) )
provider.generation = resolved.to_generation_settings() provider.generation = resolved.to_generation_settings()
@ -183,6 +184,7 @@ def provider_signature(
config.get_api_base(fallback.model, preset=fallback), config.get_api_base(fallback.model, preset=fallback),
fp.extra_headers if fp else None, fp.extra_headers if fp else None,
fp.extra_body 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, "region", None) if fp else None,
getattr(fp, "profile", None) if fp else None, getattr(fp, "profile", None) if fp else None,
fallback.max_tokens, fallback.max_tokens,
@ -199,6 +201,7 @@ def provider_signature(
config.get_api_base(resolved.model, preset=resolved), config.get_api_base(resolved.model, preset=resolved),
p.extra_headers if p else None, p.extra_headers if p else None,
p.extra_body 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, "region", None) if p else None,
getattr(p, "profile", None) if p else None, getattr(p, "profile", None) if p else None,
resolved.max_tokens, resolved.max_tokens,

View File

@ -289,12 +289,14 @@ class OpenAICompatProvider(LLMProvider):
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
spec: ProviderSpec | None = None, spec: ProviderSpec | None = None,
extra_body: dict[str, Any] | None = None, extra_body: dict[str, Any] | None = None,
api_type: str = "auto",
): ):
super().__init__(api_key, api_base) super().__init__(api_key, api_base)
self.default_model = default_model self.default_model = default_model
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
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)
@ -692,6 +694,13 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None, reasoning_effort: str | None,
) -> bool: ) -> bool:
"""Use Responses API only for direct OpenAI requests that benefit from it.""" """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"): if self._spec and self._spec.name not in ("openai", "github_copilot"):
return False return False
if self._spec is None or self._spec.name != "github_copilot": if self._spec is None or self._spec.name != "github_copilot":
@ -707,7 +716,14 @@ class OpenAICompatProvider(LLMProvider):
if not wants: if not wants:
return False 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) key = _responses_circuit_key(model, self.default_model, reasoning_effort)
failures = self._responses_failures.get(key, 0) failures = self._responses_failures.get(key, 0)
if failures >= _RESPONSES_FAILURE_THRESHOLD: if failures >= _RESPONSES_FAILURE_THRESHOLD:
@ -1269,6 +1285,8 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort) self._record_responses_failure(model, reasoning_effort)
@ -1342,6 +1360,8 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would # falling back to /chat/completions cannot succeed and would
# hide the real error. # hide the real error.
raise raise
if self._api_type == "responses":
raise
if not self._should_fallback_from_responses_error(responses_error): if not self._should_fallback_from_responses_error(responses_error):
raise raise
self._record_responses_failure(model, reasoning_effort) self._record_responses_failure(model, reasoning_effort)

View File

@ -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_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,
} }
) )
@ -471,6 +472,16 @@ 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:
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: if changed:
save_config(config) save_config(config)
image_config = config.tools.image_generation image_config = config.tools.image_generation

View File

@ -241,7 +241,7 @@ def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
signature = provider_signature(config) signature = provider_signature(config)
fallback_signatures = signature[-1] fallback_signatures = signature[-1]
assert fallback_signatures[0][11] is None assert fallback_signatures[0][12] is None
# -- FallbackProvider tests -- # -- FallbackProvider tests --

View File

@ -1,3 +1,5 @@
import pytest
from nanobot.config.schema import Config 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 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: def test_legacy_defaults_config_without_presets_still_resolves() -> None:
config = Config.model_validate({ config = Config.model_validate({
"agents": { "agents": {

View File

@ -20,6 +20,7 @@ def _make_copilot_provider() -> OpenAICompatProvider:
p.default_model = "github_copilot/gpt-5.4-mini" p.default_model = "github_copilot/gpt-5.4-mini"
p._spec = find_by_name("github_copilot") p._spec = find_by_name("github_copilot")
p._effective_base = "https://api.githubcopilot.com" p._effective_base = "https://api.githubcopilot.com"
p._api_type = "auto"
p._responses_failures = {} p._responses_failures = {}
p._responses_tripped_at = {} p._responses_tripped_at = {}
return p return p

View File

@ -18,6 +18,7 @@ def provider():
p.default_model = "gpt-5" p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})() p._spec = type("Spec", (), {"name": "openai"})()
p._effective_base = "https://api.openai.com/v1" p._effective_base = "https://api.openai.com/v1"
p._api_type = "auto"
p._responses_failures = {} p._responses_failures = {}
p._responses_tripped_at = {} p._responses_tripped_at = {}
return p 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 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): 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)

View File

@ -142,6 +142,8 @@ interface ModelConfigurationDraft {
type PendingRestartSection = "runtime" | "web" | "image"; type PendingRestartSection = "runtime" | "web" | "image";
type PendingRestartSections = Record<PendingRestartSection, boolean>; type PendingRestartSections = Record<PendingRestartSection, boolean>;
type ProviderApiType = "auto" | "chat_completions" | "responses";
type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType };
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse"; type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png"; const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
@ -189,6 +191,11 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
codeWrap: true, codeWrap: true,
brandLogos: 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( const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [ ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
@ -303,7 +310,7 @@ export function SettingsView({
const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({}); const [mcpFieldValues, setMcpFieldValues] = useState<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM); const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
const [mcpConfigImport, setMcpConfigImport] = useState(""); const [mcpConfigImport, setMcpConfigImport] = useState("");
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({}); const [providerForms, setProviderForms] = useState<Record<string, ProviderForm>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({}); const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({}); const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>( const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
@ -460,6 +467,7 @@ export function SettingsView({
next[provider.name] = { next[provider.name] = {
apiKey: next[provider.name]?.apiKey ?? "", apiKey: next[provider.name]?.apiKey ?? "",
apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "", apiBase: next[provider.name]?.apiBase ?? provider.api_base ?? provider.default_api_base ?? "",
apiType: next[provider.name]?.apiType ?? provider.api_type ?? "auto",
}; };
} }
return next; return next;
@ -620,7 +628,7 @@ export function SettingsView({
if (providerSaving) return; if (providerSaving) return;
const provider = settings?.providers.find((item) => item.name === providerName); const provider = settings?.providers.find((item) => item.name === providerName);
if (!provider) return; if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" }; const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "", apiType: "auto" };
const apiKey = providerForm.apiKey.trim(); const apiKey = providerForm.apiKey.trim();
const apiKeyRequired = provider.api_key_required ?? true; const apiKeyRequired = provider.api_key_required ?? true;
if (!provider.configured && apiKeyRequired && !apiKey) { if (!provider.configured && apiKeyRequired && !apiKey) {
@ -633,6 +641,7 @@ export function SettingsView({
provider: providerName, provider: providerName,
apiKey: apiKey || undefined, apiKey: apiKey || undefined,
apiBase: providerForm.apiBase.trim(), apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
}); });
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
@ -643,6 +652,7 @@ export function SettingsView({
[providerName]: { [providerName]: {
apiKey: "", apiKey: "",
apiBase: providerForm.apiBase.trim(), apiBase: providerForm.apiBase.trim(),
apiType: providerForm.apiType,
}, },
})); }));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
@ -719,6 +729,7 @@ export function SettingsView({
[providerName]: { [providerName]: {
apiKey: "", apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "", apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
}, },
})); }));
setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false })); setVisibleProviderKeys((prev) => ({ ...prev, [providerName]: false }));
@ -772,6 +783,7 @@ export function SettingsView({
[providerName]: { [providerName]: {
apiKey: "", apiKey: "",
apiBase: forms[providerName]?.apiBase ?? "", apiBase: forms[providerName]?.apiBase ?? "",
apiType: forms[providerName]?.apiType ?? "auto",
}, },
})); }));
setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false })); setVisibleProviderKeys((visible) => ({ ...visible, [providerName]: false }));
@ -957,6 +969,7 @@ export function SettingsView({
[provider]: { [provider]: {
apiKey: prev[provider]?.apiKey ?? "", apiKey: prev[provider]?.apiKey ?? "",
apiBase: prev[provider]?.apiBase ?? "", apiBase: prev[provider]?.apiBase ?? "",
apiType: prev[provider]?.apiType ?? "auto",
...value, ...value,
}, },
})) }))
@ -1713,7 +1726,7 @@ function ProvidersSettings({
}: { }: {
settings: SettingsPayload; settings: SettingsPayload;
expandedProvider: string | null; expandedProvider: string | null;
providerForms: Record<string, { apiKey: string; apiBase: string }>; providerForms: Record<string, ProviderForm>;
visibleProviderKeys: Record<string, boolean>; visibleProviderKeys: Record<string, boolean>;
editingProviderKeys: Record<string, boolean>; editingProviderKeys: Record<string, boolean>;
providerSaving: string | null; providerSaving: string | null;
@ -1723,7 +1736,7 @@ function ProvidersSettings({
onToggleProvider: (provider: string) => void; onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void; onToggleProviderKey: (provider: string) => void;
onToggleProviderKeyEditing: (provider: string) => void; onToggleProviderKeyEditing: (provider: string) => void;
onChangeProviderForm: (provider: string, value: Partial<{ apiKey: string; apiBase: string }>) => void; onChangeProviderForm: (provider: string, value: Partial<ProviderForm>) => void;
onSaveProvider: (provider: string) => void; onSaveProvider: (provider: string) => void;
onResetProviderDraft: (provider: string) => void; onResetProviderDraft: (provider: string) => void;
imageProviderRestartPending: boolean; imageProviderRestartPending: boolean;
@ -1744,6 +1757,7 @@ function ProvidersSettings({
const form = providerForms[provider.name] ?? { const form = providerForms[provider.name] ?? {
apiKey: "", apiKey: "",
apiBase: provider.api_base ?? provider.default_api_base ?? "", apiBase: provider.api_base ?? provider.default_api_base ?? "",
apiType: provider.api_type ?? "auto",
}; };
const saving = providerSaving === provider.name; const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[provider.name]; const keyVisible = !!visibleProviderKeys[provider.name];
@ -1855,6 +1869,38 @@ function ProvidersSettings({
className="h-9 rounded-full text-[13px]" className="h-9 rounded-full text-[13px]"
/> />
</label> </label>
{provider.name === "openai" ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.byok.apiType", "API type")}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="h-9 w-full justify-between rounded-full px-3 text-[13px]"
>
<span>
{OPENAI_API_TYPE_OPTIONS.find((option) => option.value === form.apiType)?.label ??
form.apiType}
</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[220px]">
{OPENAI_API_TYPE_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => onChangeProviderForm(provider.name, { apiType: option.value })}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</label>
) : null}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button <Button
size="sm" size="sm"

View File

@ -290,6 +290,7 @@ export async function updateProviderSettings(
query.set("provider", update.provider); query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey); if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.apiBase !== undefined) query.set("api_base", update.apiBase); if (update.apiBase !== undefined) query.set("api_base", update.apiBase);
if (update.apiType !== undefined) query.set("api_type", update.apiType);
return request<SettingsPayload>( return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`, `${base}/api/settings/provider/update?${query}`,
token, token,

View File

@ -206,6 +206,7 @@ export interface SettingsPayload {
api_key_hint?: string | null; api_key_hint?: string | null;
api_base?: string | null; api_base?: string | null;
default_api_base?: string | null; default_api_base?: string | null;
api_type?: "auto" | "chat_completions" | "responses";
}>; }>;
web_search: { web_search: {
provider: string; provider: string;
@ -391,6 +392,7 @@ export interface ProviderSettingsUpdate {
provider: string; provider: string;
apiKey?: string; apiKey?: string;
apiBase?: string; apiBase?: string;
apiType?: "auto" | "chat_completions" | "responses";
} }
export interface WebSearchSettingsUpdate { export interface WebSearchSettingsUpdate {