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` |
| `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>
<summary><b>Skywork / APIFree</b></summary>

View File

@ -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

View File

@ -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,

View File

@ -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)

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_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

View File

@ -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 --

View File

@ -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": {

View File

@ -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

View File

@ -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)

View File

@ -142,6 +142,8 @@ interface ModelConfigurationDraft {
type PendingRestartSection = "runtime" | "web" | "image";
type PendingRestartSections = Record<PendingRestartSection, boolean>;
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<Record<string, Record<string, string>>>({});
const [customMcpForm, setCustomMcpForm] = useState<CustomMcpForm>(DEFAULT_CUSTOM_MCP_FORM);
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 [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
const [pendingRestartSections, setPendingRestartSections] = useState<PendingRestartSections>(
@ -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<string, { apiKey: string; apiBase: string }>;
providerForms: Record<string, ProviderForm>;
visibleProviderKeys: Record<string, boolean>;
editingProviderKeys: Record<string, boolean>;
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<ProviderForm>) => 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]"
/>
</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">
<Button
size="sm"

View File

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

View File

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