mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
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:
parent
68c6844c0b
commit
37ae655fa6
@ -22,12 +22,12 @@ from nanobot.audio.transcription_registry import (
|
||||
transcription_provider_names,
|
||||
)
|
||||
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 (
|
||||
get_image_gen_provider,
|
||||
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.webui.token_usage import token_usage_payload
|
||||
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:
|
||||
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
|
||||
return "catalog"
|
||||
@ -423,12 +479,15 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||
provider_name = (_query_first(query, "provider") or "").strip()
|
||||
if not provider_name:
|
||||
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")
|
||||
spec, provider_key, provider_config = resolved_provider
|
||||
|
||||
base_payload: dict[str, Any] = {
|
||||
"provider": spec.name,
|
||||
"provider": provider_key,
|
||||
"label": spec.label,
|
||||
"catalog_kind": _model_catalog_kind(spec),
|
||||
"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.",
|
||||
}
|
||||
|
||||
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
|
||||
if spec.name == "openai" and not api_base:
|
||||
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:
|
||||
if provider == "auto":
|
||||
return
|
||||
spec = find_by_name(provider)
|
||||
if spec is None:
|
||||
resolved_provider = _resolve_settings_provider(config, provider)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
spec, _, provider_config = resolved_provider
|
||||
if spec.is_transcription_only:
|
||||
raise WebUISettingsError("provider does not support chat models")
|
||||
provider_config = getattr(config.providers, provider, None)
|
||||
if (
|
||||
provider_config is None
|
||||
or not _provider_configured_for_settings(spec, provider_config)
|
||||
):
|
||||
if not _provider_configured_for_settings(spec, provider_config):
|
||||
raise WebUISettingsError("provider is not configured")
|
||||
|
||||
|
||||
@ -645,29 +696,15 @@ def settings_payload(
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None:
|
||||
continue
|
||||
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
|
||||
row = {
|
||||
"name": spec.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
|
||||
providers.append(row)
|
||||
providers.append(_provider_settings_row(spec.name, spec, provider_config))
|
||||
for provider_key, provider_config in _dynamic_provider_items(config):
|
||||
providers.append(
|
||||
_provider_settings_row(
|
||||
provider_key,
|
||||
create_dynamic_spec(provider_key),
|
||||
provider_config,
|
||||
)
|
||||
)
|
||||
|
||||
search_config = config.tools.web.search
|
||||
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()
|
||||
if not provider_name:
|
||||
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()
|
||||
provider_config = getattr(config.providers, spec.name, None)
|
||||
if provider_config is None:
|
||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||
if resolved_provider is None:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
spec, provider_key, provider_config = resolved_provider
|
||||
if spec.is_oauth:
|
||||
raise WebUISettingsError("unknown provider")
|
||||
|
||||
changed = False
|
||||
@ -1065,8 +1102,8 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||
restart_required = (
|
||||
changed
|
||||
and image_config.enabled
|
||||
and image_config.provider == spec.name
|
||||
and get_image_gen_provider(spec.name) is not None
|
||||
and image_config.provider == provider_key
|
||||
and get_image_gen_provider(provider_key) is not None
|
||||
)
|
||||
return settings_payload(requires_restart=restart_required)
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ from nanobot.webui.settings_api import (
|
||||
update_agent_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
update_transcription_settings,
|
||||
)
|
||||
|
||||
@ -64,6 +65,38 @@ def test_create_model_configuration_writes_label_and_selects(
|
||||
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(
|
||||
tmp_path,
|
||||
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"
|
||||
|
||||
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@ -223,6 +290,39 @@ def test_settings_payload_includes_oauth_provider_status(
|
||||
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(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@ -678,6 +778,42 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
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(
|
||||
("api_base", "expected_url"),
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user