mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 15:24:06 +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,
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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"),
|
||||||
[
|
[
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user