fix: expose dynamic custom providers in WebUI settings

maintainer edit: WebUI settings still treated non-registry custom providers as unknown, so users could not select them in model configurations or fetch their model list. Reuse dynamic provider specs for settings payloads, model-list requests, and provider updates.
This commit is contained in:
chengyongru 2026-06-11 15:05:44 +08:00 committed by Xubin Ren
parent 68c6844c0b
commit 37ae655fa6
2 changed files with 220 additions and 47 deletions

View File

@ -22,12 +22,12 @@ from nanobot.audio.transcription_registry import (
transcription_provider_names, transcription_provider_names,
) )
from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import ModelPresetConfig, ProviderConfig
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
get_image_gen_provider, get_image_gen_provider,
image_gen_provider_names, image_gen_provider_names,
) )
from nanobot.providers.registry import PROVIDERS, find_by_name from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.security.workspace_access import workspace_sandbox_status from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.token_usage import token_usage_payload from nanobot.webui.token_usage import token_usage_payload
from nanobot.webui.workspaces import ( from nanobot.webui.workspaces import (
@ -333,6 +333,62 @@ def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
) )
def _dynamic_provider_items(config: Any) -> list[tuple[str, ProviderConfig]]:
items: list[tuple[str, ProviderConfig]] = []
for name, provider_config in (config.providers.model_extra or {}).items():
if isinstance(provider_config, ProviderConfig):
items.append((name, provider_config))
return items
def _resolve_settings_provider(
config: Any,
provider_name: str,
) -> tuple[Any, str, ProviderConfig] | None:
spec = find_by_name(provider_name)
if spec is not None:
provider_config = getattr(config.providers, spec.name, None)
if isinstance(provider_config, ProviderConfig):
return spec, spec.name, provider_config
return None
normalized = provider_name.replace("-", "_")
for extra_name, provider_config in _dynamic_provider_items(config):
if provider_name == extra_name or normalized == extra_name.replace("-", "_"):
return create_dynamic_spec(extra_name), extra_name, provider_config
return None
def _provider_settings_row(
name: str,
spec: Any,
provider_config: ProviderConfig,
) -> dict[str, Any]:
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
row = {
"name": name,
"label": spec.label,
"configured": (
bool(oauth_status["configured"])
if oauth_status is not None
else _provider_configured_for_settings(spec, provider_config)
),
"auth_type": "oauth" if spec.is_oauth else "api_key",
"api_key_required": _provider_requires_api_key(spec),
"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,
"model_selectable": not spec.is_transcription_only,
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
row["oauth_expires_at"] = oauth_status["expires_at"]
row["oauth_login_supported"] = oauth_status["login_supported"]
if spec.name == "openai":
row["api_type"] = provider_config.api_type
return row
def _model_catalog_kind(spec: Any) -> str: def _model_catalog_kind(spec: Any) -> str:
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS: if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
return "catalog" return "catalog"
@ -423,12 +479,15 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip() provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name: if not provider_name:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None: config = load_config()
resolved_provider = _resolve_settings_provider(config, provider_name)
if resolved_provider is None:
raise WebUISettingsError("unknown provider") raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider
base_payload: dict[str, Any] = { base_payload: dict[str, Any] = {
"provider": spec.name, "provider": provider_key,
"label": spec.label, "label": spec.label,
"catalog_kind": _model_catalog_kind(spec), "catalog_kind": _model_catalog_kind(spec),
"models": [], "models": [],
@ -451,11 +510,6 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"message": "Model list is not available for this provider. Type a model ID manually.", "message": "Model list is not available for this provider. Type a model ID manually.",
} }
config = load_config()
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
raise WebUISettingsError("unknown provider")
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base: if spec.name == "openai" and not api_base:
api_base = "https://api.openai.com/v1" api_base = "https://api.openai.com/v1"
@ -556,16 +610,13 @@ def _model_configuration_slug(label: str) -> str:
def _validate_configured_provider(config: Any, provider: str) -> None: def _validate_configured_provider(config: Any, provider: str) -> None:
if provider == "auto": if provider == "auto":
return return
spec = find_by_name(provider) resolved_provider = _resolve_settings_provider(config, provider)
if spec is None: if resolved_provider is None:
raise WebUISettingsError("unknown provider") raise WebUISettingsError("unknown provider")
spec, _, provider_config = resolved_provider
if spec.is_transcription_only: if spec.is_transcription_only:
raise WebUISettingsError("provider does not support chat models") raise WebUISettingsError("provider does not support chat models")
provider_config = getattr(config.providers, provider, None) if not _provider_configured_for_settings(spec, provider_config):
if (
provider_config is None
or not _provider_configured_for_settings(spec, provider_config)
):
raise WebUISettingsError("provider is not configured") raise WebUISettingsError("provider is not configured")
@ -645,29 +696,15 @@ def settings_payload(
provider_config = getattr(config.providers, spec.name, None) provider_config = getattr(config.providers, spec.name, None)
if provider_config is None: if provider_config is None:
continue continue
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None providers.append(_provider_settings_row(spec.name, spec, provider_config))
row = { for provider_key, provider_config in _dynamic_provider_items(config):
"name": spec.name, providers.append(
"label": spec.label, _provider_settings_row(
"configured": ( provider_key,
bool(oauth_status["configured"]) create_dynamic_spec(provider_key),
if oauth_status is not None provider_config,
else _provider_configured_for_settings(spec, provider_config) )
), )
"auth_type": "oauth" if spec.is_oauth else "api_key",
"api_key_required": _provider_requires_api_key(spec),
"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,
"model_selectable": not spec.is_transcription_only,
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
row["oauth_expires_at"] = oauth_status["expires_at"]
row["oauth_login_supported"] = oauth_status["login_supported"]
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
@ -1024,13 +1061,13 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip() provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name: if not provider_name:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or spec.is_oauth:
raise WebUISettingsError("unknown provider")
config = load_config() config = load_config()
provider_config = getattr(config.providers, spec.name, None) resolved_provider = _resolve_settings_provider(config, provider_name)
if provider_config is None: if resolved_provider is None:
raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider
if spec.is_oauth:
raise WebUISettingsError("unknown provider") raise WebUISettingsError("unknown provider")
changed = False changed = False
@ -1065,8 +1102,8 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
restart_required = ( restart_required = (
changed changed
and image_config.enabled and image_config.enabled
and image_config.provider == spec.name and image_config.provider == provider_key
and get_image_gen_provider(spec.name) is not None and get_image_gen_provider(provider_key) is not None
) )
return settings_payload(requires_restart=restart_required) return settings_payload(requires_restart=restart_required)

View File

@ -18,6 +18,7 @@ from nanobot.webui.settings_api import (
update_agent_settings, update_agent_settings,
update_model_configuration, update_model_configuration,
update_network_safety_settings, update_network_safety_settings,
update_provider_settings,
update_transcription_settings, update_transcription_settings,
) )
@ -64,6 +65,38 @@ def test_create_model_configuration_writes_label_and_selects(
assert duplicate.value.status == 409 assert duplicate.value.status == 409
def test_create_model_configuration_accepts_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"providers": {
"my-company-api": {
"apiBase": "https://example.test/v1",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = create_model_configuration(
{
"label": ["Tenant model"],
"provider": ["my-company-api"],
"model": ["gpt-4o-mini"],
}
)
assert payload["agent"]["model_preset"] == "tenant-model"
assert payload["agent"]["provider"] == "my-company-api"
saved = load_config(config_path)
assert saved.model_presets["tenant-model"].provider == "my-company-api"
assert saved.model_presets["tenant-model"].model == "gpt-4o-mini"
def test_create_model_configuration_rejects_unconfigured_provider( def test_create_model_configuration_rejects_unconfigured_provider(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -124,6 +157,40 @@ def test_update_model_configuration_edits_named_preset_and_selects(
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5" assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
def test_update_provider_settings_updates_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"providers": {
"my-company-api": {
"apiBase": "https://old.example/v1",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_provider_settings(
{
"provider": ["my-company-api"],
"apiBase": ["https://new.example/v1"],
"apiKey": ["sk-test"],
}
)
providers = {row["name"]: row for row in payload["providers"]}
assert providers["my-company-api"]["api_base"] == "https://new.example/v1"
assert providers["my-company-api"]["api_key_hint"] == "••••"
saved = load_config(config_path)
dynamic_provider = saved.providers.model_extra["my-company-api"]
assert dynamic_provider.api_base == "https://new.example/v1"
assert dynamic_provider.api_key == "sk-test"
def test_update_agent_settings_accepts_context_window_options( def test_update_agent_settings_accepts_context_window_options(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -223,6 +290,39 @@ def test_settings_payload_includes_oauth_provider_status(
assert providers["openai_codex"]["oauth_account"] == "acct-test" assert providers["openai_codex"]["oauth_account"] == "acct-test"
def test_settings_payload_includes_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "my-company-api",
"model": "gpt-4o-mini",
}
},
"providers": {
"my-company-api": {
"apiBase": "https://example.test/v1",
}
},
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {row["name"]: row for row in payload["providers"]}
assert payload["agent"]["provider"] == "my-company-api"
assert payload["agent"]["resolved_provider"] == "my-company-api"
assert providers["my-company-api"]["configured"] is True
assert providers["my-company-api"]["api_key_required"] is False
assert providers["my-company-api"]["api_base"] == "https://example.test/v1"
def test_settings_payload_includes_network_safety_fields( def test_settings_payload_includes_network_safety_fields(
tmp_path, tmp_path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -678,6 +778,42 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536 assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"providers": {
"my-company-api": {
"apiBase": "https://example.test/v1",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fake_get(url: str, **kwargs):
assert url == "https://example.test/v1/models"
assert "Authorization" not in kwargs["headers"]
return httpx.Response(
200,
json={"data": [{"id": "custom-gpt", "owned_by": "example"}]},
request=httpx.Request("GET", url),
)
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
payload = provider_models_payload({"provider": ["my-company-api"]})
assert payload["provider"] == "my-company-api"
assert payload["status"] == "available"
assert payload["catalog_kind"] == "custom"
assert payload["models"][0]["id"] == "custom-gpt"
@pytest.mark.parametrize( @pytest.mark.parametrize(
("api_base", "expected_url"), ("api_base", "expected_url"),
[ [