feat(models): discover provider catalogs and search chat models (#7871)

* feat(models): discover provider catalogs and search chat models

* docs(models): scope catalog guidance to owning directories

* test(guidance): register model catalog scoped instructions

* test(memory): pin capacity scenarios to a stable clock

* fix(models): guard catalog imports and align picker interactions

* test(memory): share the fixed clock with updater capacity tests

* fix(models): clear provider filters removed by catalog refresh

* refactor(models): share connection fields across model forms

* fix(models): keep active provider filters clearable

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Wenchao An 2026-11-05 06:43:01 +08:00 committed by GitHub
parent a46d9221f8
commit 2145c9ab1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2218 additions and 167 deletions

View File

@ -337,6 +337,12 @@ fences, including unclosed fences, in complete and streamed replies.
Newly saved models are enabled and immediately refresh existing chat and Agent
model selectors, without editing YAML or restarting the Gateway.
Use **Fetch and add models** to reuse a saved connection or enter a new one,
search its catalog, and test selected models before adding them. Existing
entries are retained on retries; individual failures remain available to retry.
Chat pickers support name/ID search, provider filtering, favorites and the
eight most recently selected models, scoped to your account in this browser.
The Moonshot (China) preset recognizes `kimi-k3.1-pro` and
`kimi-k3.1-pro-highspeed` with required thinking, image input and native
`low` / `high` / `max` reasoning effort (default `max`). Thinking stays on

View File

@ -0,0 +1,78 @@
"""Bounded, read-only provider catalog retrieval. Never follow credentialed redirects."""
import asyncio
import json
import httpx
from deerflow.config.managed_model_providers import get_managed_model_provider
from deerflow.config.managed_models import ManagedModel
MAX_MODELS = 2000
MAX_BYTES = 2 * 1024 * 1024
class DiscoveryError(ValueError):
def __init__(self, code: str):
self.code = code
super().__init__(code)
async def fetch_model_catalog(profile: ManagedModel) -> tuple[list[str], bool]:
provider = get_managed_model_provider(profile.provider)
endpoint = provider.endpoint or profile.base_url
key = profile.api_key.get_secret_value() if profile.api_key else None
native = provider.protocol == "anthropic-messages"
url = endpoint.rstrip("/") + ("/v1/models" if native else "/models")
headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
if native:
headers.update({"x-api-key": key or "", "anthropic-version": "2023-06-01"})
elif key:
headers["Authorization"] = f"Bearer {key}"
# HTTPX constructs TLS trust synchronously; keep that work off the event loop.
client = await asyncio.to_thread(httpx.AsyncClient, timeout=15, follow_redirects=False)
names: set[str] = set()
cursor = None
size = 0
try:
async with asyncio.timeout(20), client:
for _ in range(20):
params = {"limit": 100, **({"after_id": cursor} if cursor else {})} if native else None
async with client.stream("GET", url, headers=headers, params=params) as response:
if response.status_code in (401, 403):
raise DiscoveryError("authentication_failed")
if response.status_code in (404, 405):
raise DiscoveryError("discovery_unsupported")
if response.status_code != 200:
raise DiscoveryError("discovery_failed")
# Reject compression before HTTPX can allocate a decoded
# chunk larger than our response budget.
if response.headers.get("content-encoding", "identity").strip().lower() != "identity":
raise DiscoveryError("discovery_failed")
chunks = []
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > MAX_BYTES:
raise DiscoveryError("response_limit")
chunks.append(chunk)
data = json.loads(b"".join(chunks))
if not isinstance(data, dict) or not isinstance(data.get("data"), list):
raise DiscoveryError("discovery_failed")
for item in data["data"]:
name = item.get("id") if isinstance(item, dict) else None
if not isinstance(name, str) or not name or len(name) > 200 or name != name.strip() or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in name):
continue
names.add(name)
if len(names) >= MAX_MODELS:
return sorted(names), True
if not native or not data.get("has_more"):
return sorted(names), bool(data.get("has_more"))
next_cursor = data.get("last_id")
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor or len(next_cursor) > 200:
raise DiscoveryError("discovery_failed")
cursor = next_cursor
return sorted(names), True
except DiscoveryError:
raise
except Exception:
raise DiscoveryError("discovery_failed") from None

View File

@ -112,3 +112,12 @@ Unauthenticated auth routes (`/register`, `/setup-status`, `/providers`,
`/oauth/{provider}`, `/callback/{provider}`, the `/login/local` throttle) read
`get_app_config()` through `asyncio.to_thread`: each read stats and hashes the
config files. Coverage: `tests/blocking_io/test_auth_router_config_reads.py`.
Managed-model `/discover` and `/import` require administrator access before
reading credentials or making outbound requests. Discovery is read-only and
bounded (20 seconds, 2 MiB, 2,000 IDs/20 pages); it never follows redirects
and requests identity encoding, rejecting compressed bodies before decoding.
Import probes each new profile, saves through the drained writer, and atomically
deduplicates retries without changing existing disabled profiles. Saved-key reuse
requires matching connection and revision, checked again under the write lock
after probing (409 on change); provider errors remain redacted.

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict, model_validator
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write
@ -180,3 +180,100 @@ async def test_model(request: Request, body: SaveModelRequest):
except Exception as exc:
# Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"}
class ModelConnection(BaseModel):
"""A transient connection draft; credentials never enter a browser query cache."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
provider: str = "openai"
base_url: str | None = None
api_key: SecretStr | None = None
saved_model_name: str | None = Field(default=None, max_length=100)
expected_revision: str | None = Field(default=None, max_length=100)
@model_validator(mode="after")
def valid_connection(self):
ManagedModel(provider=self.provider, base_url=self.base_url, api_key=self.api_key, model="catalog-check")
if bool(self.saved_model_name) != bool(self.expected_revision):
raise ValueError("Saved connection requires its revision")
return self
class ImportModelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
connection: ModelConnection
model: str = Field(min_length=1, max_length=200)
@field_validator("model")
@classmethod
def valid_id(cls, value):
if value != value.strip():
raise ValueError("Invalid model ID")
return ManagedModel.valid_model_id(value)
def _connection_profile(body: ModelConnection, model: str = "catalog-check") -> ManagedModel:
previous = None
if body.saved_model_name:
previous = next((item for item in ManagedModelStore().list() if item.name == body.saved_model_name), None)
if previous is None or previous.revision != body.expected_revision:
raise HTTPException(409, "Connection changed; reload before continuing")
profile = ManagedModel(provider=body.provider, base_url=body.base_url, api_key=body.api_key, model=model)
profile = profile.model_copy(update={"api_key": retained_api_key(profile, previous)})
if profile.provider != "openai-compatible" and not (profile.api_key and profile.api_key.get_secret_value()):
raise HTTPException(422, {"code": "missing_api_key", "message": "Enter the connection API key"})
return profile
def _same_connection(left: ManagedModel, right: ManagedModel) -> bool:
return (left.provider, left.base_url, left.api_key) == (right.provider, right.base_url, right.api_key)
def _existing_import(profile: ManagedModel) -> ManagedModel | None:
return next((item for item in ManagedModelStore().list() if item.model == profile.model and _same_connection(item, profile)), None)
@router.post("/discover")
async def discover_models(request: Request, body: ModelConnection):
from app.gateway.model_discovery import DiscoveryError, fetch_model_catalog
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body)
ids, truncated = await fetch_model_catalog(profile)
records = await asyncio.to_thread(lambda: ManagedModelStore().list())
configured = {item.model for item in records if _same_connection(item, profile)}
return {"models": [{"id": mid, "configured": mid in configured} for mid in ids], "truncated": truncated}
except DiscoveryError as exc:
raise HTTPException(502, {"code": exc.code, "message": "Could not retrieve the model catalog"}) from None
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None
def _save_import(profile: ManagedModel, connection: ModelConnection):
config = get_app_config()
reserved = {item.name for item in config.models if item.name not in config._managed_model_names}
source = (connection.saved_model_name, connection.expected_revision) if connection.saved_model_name and connection.expected_revision else None
try:
return ManagedModelStore().save(profile, expected_revision=None, reserved_names=reserved, deduplicate=True, expected_source=source).public()
except FileExistsError:
raise HTTPException(409, "Connection changed; reload before importing") from None
@router.post("/import")
async def import_model(request: Request, body: ImportModelRequest):
"""One bounded, retry-safe item. The UI sequences items and reports partial success."""
await require_admin_user(request, detail=_ADMIN)
try:
profile = await asyncio.to_thread(_connection_profile, body.connection, body.model)
existing = await asyncio.to_thread(_existing_import, profile)
if existing:
return {"status": "existing", "model": existing.public()}
probe = await test_model(request, SaveModelRequest(config=profile))
if not probe["ok"]:
return {"status": "failed", "message": probe["message"]}
saved = await run_drained_write("Import managed model", _save_import, (HTTPException,), profile, body.connection)
return {"status": "saved", "model": saved}
except (OSError, ValueError):
raise HTTPException(503, "Managed model storage is unavailable") from None

View File

@ -66,6 +66,7 @@ class ModelResponse(BaseModel):
description: str | None = Field(None, description="Model description")
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode (deprecated: derived from `reasoning`)")
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort (deprecated: derived from `reasoning`)")
provider: str | None = Field(default=None, description="Managed provider display label; no endpoint or credentials")
reasoning: ReasoningCapabilitiesResponse = Field(..., description="Normalized reasoning capability contract")
@ -75,6 +76,7 @@ def _model_response(model: ModelConfig) -> ModelResponse:
model=model.model,
display_name=model.display_name,
description=model.description,
provider=model._managed_provider,
supports_thinking=model.supports_thinking,
supports_reasoning_effort=model.supports_reasoning_effort,
reasoning=ReasoningCapabilitiesResponse(**reasoning_capabilities_payload(resolve_reasoning_contract(model))),

View File

@ -342,6 +342,26 @@ Plan from the model name. Plans/regions without a preset can use the custom
OpenAI-compatible option if their documented protocol is compatible; native
protocols need a verified backend preset or operator YAML configuration.
Settings → Models → **Fetch and add models** reads the connection's model
catalog without changing configuration. Administrators can reuse an existing
saved connection without exposing its key, search IDs, select visible results,
and test/add the selection. Each selected new model must pass the existing
streaming tool-call probe before it is enabled. Tests can incur provider charges.
Failures are displayed individually; stop/retry preserves successful imports and
deduplicates by provider, endpoint, credential and model ID. Existing disabled
profiles remain disabled. Renaming and enabling/disabling stay in the model editor.
Discovery uses the provider's model-list endpoint (including Anthropic pagination)
and reports unsupported endpoints for manual entry. It follows no redirects and
caps reads at 20 seconds, 2 MiB and 2,000 model IDs/20 pages; truncated catalogs are
marked. IDs do not establish chat/tool capabilities. No background synchronization
or automatic enabling occurs just because a model appears in the catalog.
Chat model pickers search display names, configured names, IDs and provider labels,
with provider filtering and favorites/recent groups. The eight recent selections
are browser-local and user-scoped, and only currently available models appear.
Selecting a model continues to use the existing conversation/account preferences.
The `moonshot-cn` preset recognizes the exact IDs `kimi-k3.1-pro` and
`kimi-k3.1-pro-highspeed`. Both require thinking, preserve reasoning history,
and accept top-level `reasoning_effort` values `low`, `high` and `max`, with

View File

@ -228,3 +228,9 @@ endpoints; `ManagedModel.runtime_config()` combines them with profile fields.
### Web model provider presets
Managed model provider presets live only in `managed_model_providers.py`. `managed_models.py` reads legacy custom encrypted records compatibly and converts presets into ModelConfig without changing YAML, previous snapshots or injected configurations. See the managed-model section in `../models/AGENTS.md` and the Web registration section of `backend/docs/CONFIGURATION.md`.
Catalog imports use `ManagedModelStore.save(deduplicate=True)` under the existing
thread/process lock, checking the saved source revision before deduplication
by provider, endpoint, key and model ID. Ordinary edits
retain revision checks. `_managed_provider` is private runtime display metadata;
it must never enter serialized config or SDK request kwargs.

View File

@ -126,13 +126,15 @@ class ManagedModel(BaseModel):
settings["supports_vision"] = self.supports_vision
if self.max_tokens is not None:
settings["max_tokens"] = self.max_tokens
return ModelConfig(
result = ModelConfig(
name=self.name or "connection-check",
display_name=self.display_name or f"{get_managed_model_provider(self.provider).display_name} · {self.model}"[:100],
model=self.model,
context_window=self.context_window,
**settings,
)
result._managed_provider = get_managed_model_provider(self.provider).display_name
return result
class ManagedModelInputError(ValueError):
@ -176,9 +178,15 @@ class ManagedModelStore:
# Never include provider secrets or decrypted validation inputs.
raise ValueError("Cannot read managed models; check the catalog and encryption key") from None
def save(self, config: ManagedModel, *, expected_revision: str | None, reserved_names: set[str] | None = None) -> ManagedModel:
def save(self, config: ManagedModel, *, expected_revision: str | None, reserved_names: set[str] | None = None, deduplicate: bool = False, expected_source: tuple[str, str] | None = None) -> ManagedModel:
with _lock, extensions_config_file_lock(self.path):
records = self.list()
if expected_source is not None and not any((item.name, item.revision) == expected_source for item in records):
raise FileExistsError("Connection changed; reload before importing")
if deduplicate:
for item in records:
if (item.provider, item.base_url, item.api_key, item.model) == (config.provider, config.base_url, config.api_key, config.model):
return item
if not config.name:
if expected_revision is not None:
raise ManagedModelInputError("An existing model name is required when editing")

View File

@ -1,7 +1,7 @@
import re
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, PrivateAttr, ValidationInfo, field_validator, model_validator
from deerflow.config._boolean_guards import reject_boolean
@ -134,6 +134,8 @@ class ReasoningCapabilities(BaseModel):
class ModelConfig(BaseModel):
"""Config section for a model"""
_managed_provider: str | None = PrivateAttr(default=None)
name: str = Field(..., description="Unique name for the model")
request_admission: RequestAdmissionConfig | None = Field(default=None, description="Opt-in process-local RPM pacing. Changing an active group's policy requires a process restart.")
display_name: str | None = Field(..., default_factory=lambda: None, description="Display name for the model")

View File

@ -109,3 +109,27 @@ async def test_preset_catalog_registration_and_native_client_stay_off_loop(tmp_p
profile = ManagedModel(name=result["name"], provider="anthropic", model="arbitrary")
assert await router.test_model(request, router.SaveModelRequest(config=profile, expected_revision=result["revision"])) == {"ok": False, "message": "authentication_failed"}
assert built == [True]
@pytest.mark.asyncio
async def test_catalog_reads_and_client_construction_stay_off_loop(tmp_path, monkeypatch):
from app.gateway import model_discovery
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
original = httpx.AsyncClient.__init__
def initialize(self, *args, **kwargs):
with pytest.raises(RuntimeError, match="no running event loop"):
asyncio.get_running_loop()
original(self, *args, **kwargs)
monkeypatch.setattr(httpx.AsyncClient, "__init__", initialize)
async def send(client, request, **kwargs):
return httpx.Response(200, request=request, json={"data": [{"id": "a"}]})
monkeypatch.setattr(httpx.AsyncClient, "send", send)
result = await model_discovery.fetch_model_catalog(ManagedModel(provider="openai", model="catalog", api_key="test"))
assert result == (["a"], False)
admin = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
assert (await router.discover_models(admin, router.ModelConnection(provider="openai", api_key="test")))["models"] == [{"id": "a", "configured": False}]

View File

@ -56,9 +56,11 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/uploads/AGENTS.md",
"backend/packages/harness/deerflow/utils/AGENTS.md",
"frontend/src/AGENTS.md",
"frontend/src/components/workspace/AGENTS.md",
"frontend/src/components/workspace/browser-view/AGENTS.md",
"frontend/src/core/auth/AGENTS.md",
"frontend/src/core/memory/AGENTS.md",
"frontend/src/core/models/AGENTS.md",
"frontend/tests/e2e/AGENTS.md",
"scripts/AGENTS.md",
"examples/deerflow-extension-agent-teams/AGENTS.md",

View File

@ -0,0 +1,247 @@
"""Catalog discovery is read-only; imports validate before encrypted persistence."""
import asyncio
from types import SimpleNamespace
import httpx
import pytest
from fastapi import HTTPException
from pydantic import SecretStr
from app.gateway.routers import managed_models as router
from deerflow.config.app_config import AppConfig
from deerflow.config.managed_models import ManagedModel, ManagedModelStore
ADMIN = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
@pytest.fixture
def setup(monkeypatch, tmp_path):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(router, "get_app_config", lambda: AppConfig.model_validate({"sandbox": {"use": "test"}}))
return ManagedModelStore()
def connection(**kw):
return router.ModelConnection(provider="openai", api_key="catalog-secret", **kw)
@pytest.mark.asyncio
async def test_discovery_uses_saved_key_and_never_writes(setup, monkeypatch):
saved = setup.save(ManagedModel(provider="openai", model="existing", api_key="catalog-secret"), expected_revision=None)
before = setup.path.read_bytes()
async def send(client, request, **kwargs):
assert str(request.url) == "https://api.openai.com/v1/models"
assert request.headers["authorization"] == "Bearer catalog-secret"
return httpx.Response(200, request=request, json={"data": [{"id": "existing"}, {"id": "new"}, {"id": "new"}, {"id": "bad\n"}, {"id": 5}]})
monkeypatch.setattr(httpx.AsyncClient, "send", send)
body = router.ModelConnection(provider="openai", saved_model_name=saved.name, expected_revision=saved.revision)
result = await router.discover_models(ADMIN, body)
assert result == {"models": [{"id": "existing", "configured": True}, {"id": "new", "configured": False}], "truncated": False}
assert setup.path.read_bytes() == before
assert "catalog-secret" not in str(result)
@pytest.mark.asyncio
@pytest.mark.parametrize("status,code", [(401, "authentication_failed"), (403, "authentication_failed"), (404, "discovery_unsupported"), (302, "discovery_failed"), (500, "discovery_failed")])
async def test_discovery_errors_redact_provider_details(setup, monkeypatch, status, code):
async def send(client, request, **kwargs):
return httpx.Response(status, request=request, text="catalog-secret", headers={"location": "https://other.invalid"})
monkeypatch.setattr(httpx.AsyncClient, "send", send)
with pytest.raises(HTTPException) as exc:
await router.discover_models(ADMIN, connection())
assert exc.value.detail["code"] == code
assert "catalog-secret" not in str(exc.value.detail)
@pytest.mark.asyncio
async def test_non_admin_cannot_discover_or_import(setup):
member = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="user")))
for fn, body in [(router.discover_models, connection()), (router.import_model, router.ImportModelRequest(connection=connection(), model="new"))]:
with pytest.raises(HTTPException) as exc:
await fn(member, body)
assert exc.value.status_code == 403
assert not setup.path.exists()
@pytest.mark.asyncio
async def test_import_only_saves_after_probe_and_retries_do_not_duplicate(setup, monkeypatch):
calls = []
async def probe(request, body):
calls.append(body.config.model)
return {"ok": body.config.model != "bad", "message": "success" if body.config.model != "bad" else "tool_call_missing"}
monkeypatch.setattr(router, "test_model", probe)
bad = await router.import_model(ADMIN, router.ImportModelRequest(connection=connection(), model="bad"))
assert bad["status"] == "failed"
assert not setup.path.exists()
body = router.ImportModelRequest(connection=connection(), model="new")
results = await asyncio.gather(*(router.import_model(ADMIN, body) for _ in range(3)))
assert all(r["status"] in {"saved", "existing"} for r in results)
assert len(setup.list()) == 1
assert setup.list()[0].api_key == SecretStr("catalog-secret")
assert b"catalog-secret" not in setup.path.read_bytes()
again = await router.import_model(ADMIN, body)
assert again["status"] == "existing"
@pytest.mark.asyncio
async def test_saved_key_cannot_cross_connection_or_revision(setup):
saved = setup.save(ManagedModel(provider="openai", model="existing", api_key="catalog-secret"), expected_revision=None)
for overrides in [{"provider": "anthropic"}, {"expected_revision": "stale"}]:
body = router.ModelConnection(**{"provider": "openai", "saved_model_name": saved.name, "expected_revision": saved.revision, **overrides})
with pytest.raises(HTTPException):
await router.discover_models(ADMIN, body)
@pytest.mark.asyncio
async def test_native_pagination_and_auth_headers(setup, monkeypatch):
requests = []
async def send(client, request, **kwargs):
requests.append(request)
assert request.url.path == "/v1/models"
assert request.headers["x-api-key"] == "native-secret"
assert "authorization" not in request.headers
page = {"data": [{"id": "first"}], "has_more": True, "last_id": "first"} if len(requests) == 1 else {"data": [{"id": "second"}], "has_more": False}
return httpx.Response(200, request=request, json=page)
monkeypatch.setattr(httpx.AsyncClient, "send", send)
result = await router.discover_models(ADMIN, router.ModelConnection(provider="anthropic", api_key="native-secret"))
assert requests[1].url.params["after_id"] == "first"
assert [m["id"] for m in result["models"]] == ["first", "second"]
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{}, {"data": "bad"}, [], {"data": [{"id": "x"}], "has_more": True}])
async def test_malformed_native_catalog_fails_without_writing(setup, monkeypatch, payload):
async def send(client, request, **kwargs):
return httpx.Response(200, request=request, json=payload)
monkeypatch.setattr(httpx.AsyncClient, "send", send)
with pytest.raises(HTTPException):
await router.discover_models(ADMIN, router.ModelConnection(provider="anthropic", api_key="test"))
assert not setup.path.exists()
@pytest.mark.asyncio
async def test_catalog_limits_and_cancellation(setup, monkeypatch):
from app.gateway import model_discovery as discovery
monkeypatch.setattr(discovery, "MAX_MODELS", 2)
async def send(client, request, **kwargs):
return httpx.Response(200, request=request, json={"data": [{"id": "a"}, {"id": "b"}, {"id": "c"}]})
monkeypatch.setattr(httpx.AsyncClient, "send", send)
result = await router.discover_models(ADMIN, connection())
assert len(result["models"]) == 2 and result["truncated"]
monkeypatch.setattr(discovery, "MAX_BYTES", 5)
with pytest.raises(HTTPException) as exc:
await router.discover_models(ADMIN, connection())
assert exc.value.detail["code"] == "response_limit"
async def cancelled(*args, **kwargs):
raise asyncio.CancelledError
monkeypatch.setattr(httpx.AsyncClient, "send", cancelled)
with pytest.raises(asyncio.CancelledError):
await router.discover_models(ADMIN, connection())
assert not setup.path.exists()
@pytest.mark.asyncio
async def test_import_reuses_disabled_record_without_enabling_or_overwriting(setup, monkeypatch):
old = setup.save(ManagedModel(provider="openai", model="old", api_key="catalog-secret", enabled=False, display_name="Keep"), expected_revision=None)
async def forbidden(*args, **kwargs):
pytest.fail("Existing imports must not call the provider")
monkeypatch.setattr(router, "test_model", forbidden)
result = await router.import_model(ADMIN, router.ImportModelRequest(connection=connection(), model="old"))
assert result["status"] == "existing"
assert result["model"]["revision"] == old.revision
assert not result["model"]["enabled"]
assert result["model"]["display_name"] == "Keep"
def test_provider_projection_is_metadata_not_an_sdk_argument():
from app.gateway.routers.models import _model_response
from deerflow.models.factory import create_chat_model
profile = ManagedModel(provider="openai", model="gpt-6.1-sol", api_key="secret")
config = profile.runtime_config()
assert _model_response(config).provider == "OpenAI"
assert "_managed_provider" not in config.model_dump()
app = AppConfig.model_validate({"models": [config], "sandbox": {"use": "test"}})
client = create_chat_model(config.name, app_config=app, attach_tracing=False)
assert "_managed_provider" not in client.model_kwargs
@pytest.mark.asyncio
@pytest.mark.parametrize("rotate", [False, True])
@pytest.mark.parametrize("concurrent_import", [False, True])
async def test_import_rechecks_saved_connection_after_probe(setup, monkeypatch, rotate, concurrent_import):
source = setup.save(ManagedModel(provider="openai", model="source", api_key="old-key"), expected_revision=None)
before = setup.path.read_bytes()
async def probe(request, body):
nonlocal before
assert body.config.api_key == SecretStr("old-key")
if concurrent_import:
setup.save(body.config, expected_revision=None)
if rotate:
setup.save(source.model_copy(update={"api_key": SecretStr("new-key")}), expected_revision=source.revision)
before = setup.path.read_bytes()
return {"ok": True, "message": "success"}
monkeypatch.setattr(router, "test_model", probe)
body = router.ImportModelRequest(connection=router.ModelConnection(provider="openai", saved_model_name=source.name, expected_revision=source.revision), model="new")
if rotate:
with pytest.raises(HTTPException) as exc:
await router.import_model(ADMIN, body)
assert exc.value.status_code == 409
assert setup.path.read_bytes() == before
assert len(setup.list()) == (2 if concurrent_import else 1)
else:
assert (await router.import_model(ADMIN, body))["status"] == "saved"
assert len(setup.list()) == 2
@pytest.mark.asyncio
@pytest.mark.parametrize("encoding", ["gzip", "br", "deflate", "identity, gzip"])
async def test_compressed_catalog_is_rejected_before_reading(setup, monkeypatch, encoding):
reads = []
encodings = []
class CompressedStream(httpx.AsyncByteStream):
async def __aiter__(self):
import gzip
reads.append(True)
yield gzip.compress(b" " * (8 * 1024 * 1024))
async def send(client, request, **kwargs):
encodings.append(request.headers["accept-encoding"])
return httpx.Response(200, request=request, headers={"content-encoding": encoding}, stream=CompressedStream())
monkeypatch.setattr(httpx.AsyncClient, "send", send)
with pytest.raises(HTTPException) as exc:
await router.discover_models(ADMIN, connection())
assert exc.value.detail["code"] == "discovery_failed"
assert not reads
assert encodings == ["identity"]
assert not setup.path.exists()
def test_import_source_must_still_exist_before_deduplication(setup):
profile = ManagedModel(provider="openai", model="existing", api_key="key")
setup.save(profile, expected_revision=None)
before = setup.path.read_bytes()
with pytest.raises(FileExistsError):
setup.save(profile, expected_revision=None, deduplicate=True, expected_source=("deleted-source", "old-revision"))
assert setup.path.read_bytes() == before

View File

@ -0,0 +1,13 @@
# Workspace model catalog
`settings/model-catalog-dialog.tsx` keeps connection drafts in component state,
aborts on dismissal, sequences `/discover` + `/import`, and refreshes both model
queries after partial/cancelled imports. Never retry writes automatically. Custom endpoints precede keys; destination edits clear credentials. Picker rows
and arrow-key navigation share one group order; catalog refresh clears absent
provider filters; active filters always remain clearable, including YAML models. The
server deduplicates by connection, credential and model under the catalog lock.
`settings/model-connection-fields.tsx` owns provider, endpoint and key fields for
single-model editing and catalog imports. Destination edits drop draft keys and
saved-source references; blank keys retain, explicit removal sends an empty key.
Parents own their model capabilities, revisions and independent draft lifetimes.

View File

@ -5,18 +5,20 @@ import { CheckIcon, StarIcon } from "lucide-react";
import {
type KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import {
projectModelChoices,
type ModelChoiceProjection,
} from "@/core/models/favorites";
import { filterModels, projectRecentModels } from "@/core/models/catalog";
import { projectModelChoices } from "@/core/models/favorites";
import { rememberModel, useRecentModels } from "@/core/models/recent";
import { type Model } from "@/core/models/types";
import { useModelFavorites } from "@/core/models/use-model-favorites";
import { cn } from "@/lib/utils";
@ -36,10 +38,6 @@ type FocusedControl = {
kind: "model" | "favorite";
};
function orderedModels(projection: ModelChoiceProjection): readonly Model[] {
return [...projection.favorites, ...projection.others];
}
function ModelDetails({ model }: { model: Model }) {
return (
<span className="flex min-w-0 flex-1 flex-col text-left">
@ -60,6 +58,25 @@ export function ModelPickerContent({
const { t } = useI18n();
const { user, isLoading } = useAuth();
const favorites = useModelFavorites(user?.id ?? null);
const recentNames = useRecentModels(user?.id ?? null);
const [query, setQuery] = useState("");
const [provider, setProvider] = useState("");
useEffect(() => {
if (provider && !models.some((model) => model.provider === provider)) {
setProvider("");
}
}, [models, provider]);
const filteredModels = useMemo(
() => filterModels(models, query, provider),
[models, query, provider],
);
const providerNames = [
...new Set(
models
.map((model) => model.provider)
.filter((name): name is string => !!name),
),
];
const modelButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const favoriteButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const pendingFavoriteFocusRef = useRef<string | null>(null);
@ -67,10 +84,43 @@ export function ModelPickerContent({
const wasOpenRef = useRef(false);
const projection = useMemo(
() => projectModelChoices(models, favorites.names),
[favorites.names, models],
() => projectModelChoices(filteredModels, favorites.names),
[favorites.names, filteredModels],
);
const visibleModels = useMemo(() => orderedModels(projection), [projection]);
const recentModels = projectRecentModels(
filteredModels,
recentNames,
favorites.names,
);
const otherModels = projection.others.filter(
(model) => !recentModels.some((recent) => recent.name === model.name),
);
const groups = [
{
key: "favorites",
heading: t.modelPicker.favorites,
models: projection.favorites,
},
{ key: "recent", heading: t.modelPicker.recent, models: recentModels },
...(providerNames.length > 1
? [...new Set(otherModels.map((model) => model.provider ?? ""))].map(
(name) => ({
key: `provider:${name}`,
heading: name || t.modelPicker.otherModels,
models: otherModels.filter(
(model) => (model.provider ?? "") === name,
),
}),
)
: [
{
key: "others",
heading: t.modelPicker.otherModels,
models: otherModels,
},
]),
];
const visibleModels = groups.flatMap((group) => group.models);
const focusInitialModel = useCallback(() => {
const preferredModel =
@ -198,7 +248,10 @@ export function ModelPickerContent({
kind: "model",
};
}}
onClick={() => onModelSelect(model.name)}
onClick={() => {
if (!isLoading) rememberModel(user?.id ?? null, model.name);
onModelSelect(model.name);
}}
onKeyDown={(event) => handleRowKeyDown(event, model.name)}
>
<ModelDetails model={model} />
@ -266,15 +319,51 @@ export function ModelPickerContent({
focusInitialModel();
}}
>
<div className="shrink-0 space-y-2 border-b p-2">
<Input
aria-label={t.modelPicker.search}
placeholder={t.modelPicker.search}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
modelButtonRefs.current
.get(visibleModels[0]?.name ?? "")
?.focus();
}
}}
/>
{(providerNames.length > 1 || provider !== "") && (
<select
aria-label={t.modelPicker.provider}
className="bg-background w-full rounded border px-2 py-1 text-xs"
value={provider}
onChange={(event) => setProvider(event.target.value)}
>
<option value="">{t.modelPicker.allProviders}</option>
{providerNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
</div>
<div className="min-h-0 overflow-x-hidden overflow-y-auto py-0.5">
{visibleModels.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
{t.modelPicker.noModels}
{query || provider
? t.modelPicker.noResults
: t.modelPicker.noModels}
</div>
) : (
<>
{renderGroup(t.modelPicker.favorites, projection.favorites)}
{renderGroup(t.modelPicker.otherModels, projection.others)}
{groups.map((group) => (
<div key={group.key}>
{renderGroup(group.heading, group.models)}
</div>
))}
</>
)}
</div>

View File

@ -0,0 +1,384 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { GatewayApiError } from "@/core/api/errors";
import { useI18n } from "@/core/i18n/hooks";
import {
discoverModels,
importCatalogModel,
type CatalogModel,
type ManagedModel,
type ManagedModelProvider,
type ModelConnection,
} from "@/core/models/management";
import { ModelConnectionFields } from "./model-connection-fields";
export function ModelCatalogDialog({
models,
providers,
close,
saved,
}: {
models: ManagedModel[];
providers: ManagedModelProvider[];
close: () => void;
saved: () => Promise<void>;
}) {
const { t } = useI18n();
const text = t.settings.models;
const [connectionOpen, setConnectionOpen] = useState(true);
const [connection, setConnection] = useState<ModelConnection>({
provider: "openai",
});
const source = connection.saved_model_name ?? "";
const [catalog, setCatalog] = useState<CatalogModel[] | null>(null);
const [truncated, setTruncated] = useState(false);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [results, setResults] = useState<Record<string, string>>({});
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState("");
const operation = useRef<AbortController | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
operation.current?.abort();
};
}, []);
const provider = providers.find((item) => item.id === connection.provider);
const valid =
!!provider &&
(!provider.custom_endpoint || !!connection.base_url) &&
(!provider.requires_api_key ||
!!connection.api_key?.trim() ||
!!connection.saved_model_name);
const visible =
catalog?.filter((item) =>
item.id.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
) ?? [];
const pendingIds = selected.filter(
(id) => !catalog?.find((item) => item.id === id)?.configured,
);
function change(next: ModelConnection) {
setConnection(next);
setCatalog(null);
setSelected([]);
setResults({});
setMessage("");
setProgress("");
}
function errorText(error: unknown) {
if (error instanceof GatewayApiError) {
if (error.status === 409) return text.revisionConflict;
if (error.code === "authentication_failed")
return text.authentication_failed;
if (error.code === "missing_api_key") return text.missing_api_key;
if (error.code === "discovery_unsupported")
return text.discovery_unsupported;
if (error.code === "response_limit") return text.response_limit;
}
return text.discovery_failed;
}
async function discover() {
const controller = new AbortController();
operation.current = controller;
setBusy(true);
setCatalog(null);
setSelected([]);
setResults({});
setMessage("");
setProgress("");
try {
const result = await discoverModels(connection, controller.signal);
if (mounted.current && !controller.signal.aborted) {
setCatalog(result.models);
setConnectionOpen(false);
setTruncated(result.truncated);
}
} catch (error) {
if (mounted.current && !controller.signal.aborted)
setMessage(errorText(error));
} finally {
if (mounted.current) setBusy(false);
}
}
async function importSelected() {
const controller = new AbortController();
operation.current = controller;
const ids = [...pendingIds];
setBusy(true);
setMessage("");
try {
for (const [index, id] of ids.entries()) {
if (controller.signal.aborted || !mounted.current) break;
setProgress(`${index + 1} / ${ids.length} · ${id}`);
setResults((previous) => ({ ...previous, [id]: text.working }));
try {
const result = await importCatalogModel(
connection,
id,
controller.signal,
);
if (!mounted.current || controller.signal.aborted) break;
setResults((previous) => ({
...previous,
[id]:
result.status === "failed"
? text[result.message]
: result.status === "existing"
? text.alreadyConfigured
: text.saved,
}));
if (result.status !== "failed") {
setCatalog(
(previous) =>
previous?.map((item) =>
item.id === id ? { ...item, configured: true } : item,
) ?? null,
);
setSelected((previous) => previous.filter((name) => name !== id));
}
} catch (error) {
if (!mounted.current || controller.signal.aborted) break;
setResults((previous) => ({
...previous,
[id]:
error instanceof GatewayApiError
? errorText(error)
: text.importUncertain,
}));
}
}
} finally {
// A cancelled HTTP response may already have committed. Refresh even on dismissal;
// importing again checks the server catalog before probing or writing.
await saved().catch(() => undefined);
if (mounted.current) {
setBusy(false);
setProgress("");
if (controller.signal.aborted) {
setMessage(text.importStopped);
setResults((previous) =>
Object.fromEntries(
Object.entries(previous).map(([id, result]) => [
id,
result === text.working ? text.importUncertain : result,
]),
),
);
}
}
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) close();
}}
>
<DialogContent className="flex h-[90vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{text.importTitle}</DialogTitle>
<DialogDescription>{text.importDescription}</DialogDescription>
</DialogHeader>
<details
open={connectionOpen}
onToggle={(event) => setConnectionOpen(event.currentTarget.open)}
className="min-w-0 shrink-0"
>
<summary className="cursor-pointer text-sm font-medium">
{text.connectionSettings} ·{" "}
{provider?.custom_endpoint
? text.customProvider
: provider?.display_name}
</summary>
<fieldset disabled={busy} className="mt-3 grid min-w-0 gap-3">
{models.length > 0 && (
<label className="grid min-w-0 gap-1 text-sm">
{text.savedConnection}
<select
className="bg-background h-9 w-full max-w-full min-w-0 rounded border px-2"
value={source}
onChange={(event) => {
const model = models.find(
(item) => item.name === event.target.value,
);
change(
model
? {
provider: model.provider,
...(model.base_url
? { base_url: model.base_url }
: {}),
saved_model_name: model.name,
expected_revision: model.revision,
}
: { provider: "openai" },
);
}}
>
<option value="">{text.newConnection}</option>
{models
.filter(
(model) =>
model.has_api_key ||
model.provider === "openai-compatible",
)
.map((model) => (
<option key={model.name} value={model.name}>
{model.display_name || model.model}
</option>
))}
</select>
</label>
)}
<ModelConnectionFields
value={connection}
providers={providers}
canKeepKey={
!!models.find((model) => model.name === source)?.has_api_key
}
onChange={change}
/>
<Button
variant="outline"
disabled={!valid || busy}
onClick={() => void discover()}
>
{text.fetchModels}
</Button>
</fieldset>
</details>
{message && (
<p role="status" className="text-sm">
{message}
</p>
)}
{catalog && (
<>
<div className="shrink-0 space-y-2">
<Input
aria-label={text.searchModels}
placeholder={text.searchModels}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span>
{text.catalogCount(
visible.length,
catalog.length,
pendingIds.length,
)}
</span>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() =>
setSelected([
...new Set([
...selected,
...visible
.filter((item) => !item.configured)
.map((item) => item.id),
]),
])
}
>
{text.selectVisible}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setSelected([])}
>
{text.clearSelection}
</Button>
</div>
{truncated && (
<p role="status" className="text-sm">
{text.catalogTruncated}
</p>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto rounded border">
{visible.length === 0 && (
<p className="p-4 text-sm">{text.noCatalogResults}</p>
)}
{visible.map((item) => (
<label
key={item.id}
className="flex items-start gap-3 border-b p-3 text-sm last:border-b-0"
>
<input
type="checkbox"
className="mt-1"
disabled={busy || item.configured}
checked={selected.includes(item.id) || item.configured}
onChange={(event) =>
setSelected((previous) =>
event.target.checked
? [...previous, item.id]
: previous.filter((id) => id !== item.id),
)
}
/>
<span className="min-w-0 flex-1 break-all">
<span>{item.id}</span>
{(results[item.id] ?? item.configured) && (
<span className="text-muted-foreground block text-xs">
{results[item.id] ?? text.alreadyConfigured}
</span>
)}
</span>
</label>
))}
</div>
</>
)}
{progress && (
<p role="status" className="shrink-0 truncate text-sm">
{progress}
</p>
)}
<div className="mt-auto flex shrink-0 justify-end gap-2">
{busy ? (
<Button
variant="outline"
onClick={() => operation.current?.abort()}
>
{text.stopImport}
</Button>
) : (
<Button variant="outline" onClick={close}>
{text.closeCatalog}
</Button>
)}
<Button
disabled={busy || !pendingIds.length}
onClick={() => void importSelected()}
>
{text.testAndAdd(pendingIds.length)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,112 @@
"use client";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
import type {
ManagedModelProvider,
ModelConnection,
} from "@/core/models/management";
/** Shared connection fields; destination changes never carry credentials forward. */
export function ModelConnectionFields({
value,
providers,
onChange,
canKeepKey = false,
allowClearKey = false,
requireKey = true,
}: {
value: ModelConnection;
providers: ManagedModelProvider[];
onChange: (value: ModelConnection) => void;
canKeepKey?: boolean;
allowClearKey?: boolean;
requireKey?: boolean;
}) {
const { t } = useI18n();
const text = t.settings.models;
const provider = providers.find((item) => item.id === value.provider);
const requiresKey =
provider?.requires_api_key ?? value.provider !== "openai-compatible";
const clearKey = allowClearKey && canKeepKey && value.api_key === "";
return (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<label className="grid min-w-0 gap-1 text-sm">
{text.provider}
<select
className="bg-background h-9 w-full max-w-full min-w-0 rounded border px-2"
value={value.provider}
disabled={providers.length === 0}
onChange={(event) => {
const next = providers.find(
(item) => item.id === event.target.value,
);
onChange({
provider: event.target.value,
...(next?.custom_endpoint ? { base_url: "" } : {}),
});
}}
>
{providers.map((item) => (
<option key={item.id} value={item.id}>
{item.custom_endpoint ? text.customProvider : item.display_name}
</option>
))}
</select>
</label>
{provider?.custom_endpoint && (
<label className="grid min-w-0 gap-1 text-sm">
{text.endpoint}
<Input
type="url"
required
value={value.base_url ?? ""}
onChange={(event) =>
onChange({
provider: value.provider,
base_url: event.target.value,
})
}
/>
</label>
)}
<label
className={`grid min-w-0 gap-1 text-sm ${provider?.custom_endpoint ? "sm:col-span-2" : ""}`}
>
{text.apiKey}
<Input
type="password"
autoComplete="new-password"
disabled={clearKey}
required={requireKey && requiresKey && !canKeepKey && !clearKey}
value={value.api_key ?? ""}
placeholder={
canKeepKey
? text.keepKey
: requiresKey
? text.requiredKey
: text.optionalKey
}
onChange={(event) =>
onChange({ ...value, api_key: event.target.value || undefined })
}
/>
</label>
{allowClearKey && canKeepKey && (
<label className="flex items-center gap-2 sm:col-span-2">
<input
type="checkbox"
checked={clearKey}
onChange={(event) =>
onChange({
...value,
api_key: event.target.checked ? "" : undefined,
})
}
/>
{text.clearKey}
</label>
)}
</div>
);
}

View File

@ -33,9 +33,12 @@ import {
testManagedModel,
type ManagedModel,
type ManagedModelProvider,
type ModelConnection,
} from "@/core/models/management";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { ModelCatalogDialog } from "./model-catalog-dialog";
import { ModelConnectionFields } from "./model-connection-fields";
import { SettingsSection } from "./settings-section";
function modelManagementError(
@ -77,6 +80,8 @@ export function ModelSettingsPage() {
});
const [editing, setEditing] = useState<ManagedModel | "new" | null>(null);
const [pending, setPending] = useState(false);
const [importing, setImporting] = useState(false);
const [search, setSearch] = useState("");
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: ["managed-models"] }),
@ -103,7 +108,13 @@ export function ModelSettingsPage() {
<p>{text.adminOnly}</p>
) : (
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
disabled={pending || !providers.data}
onClick={() => setImporting(true)}
>
{text.importTitle}
</Button>
<Button
disabled={pending || !providers.data}
onClick={() => setEditing("new")}
@ -130,57 +141,87 @@ export function ModelSettingsPage() {
</div>
)}
{catalog.data?.models.length === 0 && <p>{text.empty}</p>}
{catalog.data?.models.map((model) => (
<div
key={`${model.source}:${model.name}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
>
<div>
<p className="font-medium">
{model.display_name || model.name}
</p>
<p className="text-muted-foreground text-sm">
{model.source === "managed" && (
<>
{model.provider === "openai-compatible"
? text.customProvider
: (providers.data?.providers.find(
(p) => p.id === model.provider,
)?.display_name ?? model.provider)}{" "}
·{" "}
</>
<Input
aria-label={text.searchModels}
placeholder={text.searchModels}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{catalog.data?.models
.filter((model) =>
[
model.model,
model.name,
model.display_name,
model.source === "managed" ? model.provider : "",
].some((value) =>
value
?.toLocaleLowerCase()
.includes(search.trim().toLocaleLowerCase()),
),
)
.map((model) => (
<div
key={`${model.source}:${model.name}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
>
<div>
<p className="font-medium">
{model.display_name || model.name}
</p>
<p className="text-muted-foreground text-sm">
{model.source === "managed" && (
<>
{model.provider === "openai-compatible"
? text.customProvider
: (providers.data?.providers.find(
(p) => p.id === model.provider,
)?.display_name ?? model.provider)}{" "}
·{" "}
</>
)}
{model.model} ·{" "}
{model.source === "config"
? text.yaml
: model.enabled
? text.enabled
: text.disabled}
</p>
{model.source === "managed" && model.conflict && (
<p role="alert">{text.conflict}</p>
)}
{model.model} ·{" "}
{model.source === "config"
? text.yaml
: model.enabled
? text.enabled
: text.disabled}
</p>
{model.source === "managed" && model.conflict && (
<p role="alert">{text.conflict}</p>
</div>
{model.source === "managed" && (
<div className="flex gap-2">
<Button
variant="outline"
disabled={pending || model.conflict}
onClick={() => setEditing(model)}
>
{text.edit}
</Button>
<Button
variant="outline"
disabled={pending || model.conflict}
onClick={() => void toggle(model)}
>
{model.enabled ? text.disable : text.enable}
</Button>
</div>
)}
</div>
{model.source === "managed" && (
<div className="flex gap-2">
<Button
variant="outline"
disabled={pending || model.conflict}
onClick={() => setEditing(model)}
>
{text.edit}
</Button>
<Button
variant="outline"
disabled={pending || model.conflict}
onClick={() => void toggle(model)}
>
{model.enabled ? text.disable : text.enable}
</Button>
</div>
))}
{importing && (
<ModelCatalogDialog
key={user?.id}
models={(catalog.data?.models ?? []).filter(
(model): model is ManagedModel => model.source === "managed",
)}
</div>
))}
providers={providers.data?.providers ?? []}
close={() => setImporting(false)}
saved={refresh}
/>
)}
{editing && (
<ModelEditor
key={`${user?.id}:${editing === "new" ? "new" : editing.name}`}
@ -210,8 +251,8 @@ function ModelEditor({
const { t } = useI18n();
const text = t.settings.models;
const [draft, setDraft] = useState(() => modelDraft(model));
const [key, setKey] = useState("");
const [clearKey, setClearKey] = useState(false);
const key = draft.api_key ?? "";
const clearKey = draft.api_key === "";
const [pending, setPending] = useState(false);
const [result, setResult] = useState("");
const active = useRef(true);
@ -232,29 +273,28 @@ function ModelEditor({
const requiresKey =
provider?.requires_api_key ?? draft.provider !== "openai-compatible";
const missingKey = requiresKey && ((!key.trim() && !canKeepKey) || clearKey);
function changeProvider(value: string) {
function changeConnection(next: ModelConnection) {
const rest = { ...draft };
delete rest.base_url;
delete rest.reasoning;
delete rest.api_key;
if (next.provider !== draft.provider) {
delete rest.reasoning;
rest.supports_vision = null;
rest.context_window = null;
rest.max_tokens = null;
}
setDraft({
...rest,
provider: value,
supports_vision: null,
context_window: null,
max_tokens: null,
...(value === "openai-compatible" ? { base_url: "" } : {}),
provider: next.provider,
...(next.base_url !== undefined ? { base_url: next.base_url } : {}),
...(next.api_key !== undefined ? { api_key: next.api_key } : {}),
});
setKey("");
setClearKey(false);
setResult("");
abort.current?.abort();
}
function body() {
return {
config: {
...draft,
...(clearKey ? { api_key: "" } : key ? { api_key: key } : {}),
},
config: draft,
expected_revision: model?.revision ?? null,
};
}
@ -305,46 +345,18 @@ function ModelEditor({
>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<fieldset disabled={pending} className="space-y-3">
<div className="space-y-1">
<label id="model-provider-label">{text.provider}</label>
<Select
value={draft.provider}
onValueChange={changeProvider}
disabled={pending || providers.length === 0}
>
<SelectTrigger
aria-labelledby="model-provider-label"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.custom_endpoint
? text.customProvider
: item.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{provider?.custom_endpoint && (
<label className="block space-y-1">
<span>{text.endpoint}</span>
<Input
type="url"
required
value={draft.base_url ?? ""}
onChange={(e) => {
setDraft({ ...draft, base_url: e.target.value });
setKey("");
setClearKey(false);
setResult("");
}}
/>
</label>
)}
<ModelConnectionFields
value={{
provider: draft.provider,
base_url: draft.base_url ?? undefined,
api_key: draft.api_key,
}}
providers={providers}
canKeepKey={canKeepKey}
allowClearKey
requireKey={draft.enabled}
onChange={changeConnection}
/>
{model && !sameConnection && (
<p role="status">{text.providerChanged}</p>
)}
@ -360,43 +372,6 @@ function ModelEditor({
}}
/>
</label>
<label className="block space-y-1">
<span>{text.apiKey}</span>
<Input
type="password"
autoComplete="new-password"
disabled={clearKey}
required={
requiresKey && draft.enabled && !canKeepKey && !clearKey
}
value={key}
placeholder={
canKeepKey
? text.keepKey
: requiresKey
? text.requiredKey
: text.optionalKey
}
onChange={(e) => {
setKey(e.target.value);
setResult("");
}}
/>
</label>
{canKeepKey && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={clearKey}
onChange={(e) => {
setClearKey(e.target.checked);
setKey("");
setResult("");
}}
/>
{text.clearKey}
</label>
)}
<details>
<summary className="cursor-pointer">{text.advanced}</summary>
<div className="space-y-3 pt-3">

View File

@ -450,6 +450,12 @@ export const enUS: Translations = {
},
modelPicker: {
search: "Search models…",
provider: "Provider",
allProviders: "All providers",
noResults: "No matching models",
recent: "Recently used",
title: "Choose a model",
favorites: "Favorites",
otherModels: "Other models",
@ -1999,6 +2005,34 @@ export const enUS: Translations = {
title: "Settings",
description: "Adjust how DeerFlow looks and behaves for you.",
models: {
connectionSettings: "Connection settings",
importTitle: "Fetch and add models",
importDescription:
"Fetch a provider catalog, select models, then test and add them. Each test may incur provider charges. Only models that pass the streaming tool-call test are enabled.",
savedConnection: "Use a saved connection",
newConnection: "New connection",
fetchModels: "Fetch models",
searchModels: "Search models…",
selectVisible: "Select visible",
clearSelection: "Clear selection",
alreadyConfigured: "Already configured",
noCatalogResults:
"No matching models. Try another search or add a model manually.",
catalogTruncated:
"This catalog was limited to 2,000 models or 20 pages. You can still add other model IDs manually.",
discovery_unsupported:
"This endpoint does not offer model discovery. Add a model ID manually.",
discovery_failed:
"Could not fetch the catalog. Check the connection and try again.",
importUncertain:
"Result not confirmed. Retry to check whether this model was saved.",
importStopped:
"Stopped. Completed models are saved; retry to reconcile the last request.",
stopImport: "Stop",
closeCatalog: "Done",
catalogCount: (visible, total, selected) =>
`${visible} / ${total} models · ${selected} selected`,
testAndAdd: (count) => `Test and add (${count})`,
title: "Models",
description:
"Manage shared models available to users. Models from the server configuration are read-only.",

View File

@ -342,6 +342,12 @@ export interface Translations {
};
modelPicker: {
search: string;
provider: string;
allProviders: string;
noResults: string;
recent: string;
title: string;
favorites: string;
otherModels: string;
@ -1664,6 +1670,30 @@ export interface Translations {
title: string;
description: string;
models: {
connectionSettings: string;
importTitle: string;
importDescription: string;
savedConnection: string;
newConnection: string;
fetchModels: string;
searchModels: string;
selectVisible: string;
clearSelection: string;
alreadyConfigured: string;
noCatalogResults: string;
catalogTruncated: string;
discovery_unsupported: string;
discovery_failed: string;
importUncertain: string;
importStopped: string;
stopImport: string;
closeCatalog: string;
catalogCount: (
visible: number,
total: number,
selected: number,
) => string;
testAndAdd: (count: number) => string;
title: string;
description: string;
adminOnly: string;

View File

@ -427,6 +427,12 @@ export const zhCN: Translations = {
},
modelPicker: {
search: "搜索模型名称或 ID…",
provider: "渠道",
allProviders: "全部渠道",
noResults: "没有匹配的模型",
recent: "最近使用",
title: "选择模型",
favorites: "收藏",
otherModels: "其他模型",
@ -1894,6 +1900,30 @@ export const zhCN: Translations = {
title: "设置",
description: "根据你的偏好调整 DeerFlow 的界面和行为。",
models: {
connectionSettings: "渠道配置",
importTitle: "获取并添加模型",
importDescription:
"获取渠道目录后,勾选模型并测试添加。测试可能产生接口费用,只有通过流式工具调用测试的模型才会启用。",
savedConnection: "使用已有渠道配置",
newConnection: "新建渠道配置",
fetchModels: "获取模型",
searchModels: "搜索模型…",
selectVisible: "选择当前结果",
clearSelection: "清空选择",
alreadyConfigured: "已配置",
noCatalogResults: "没有匹配的模型,请修改搜索或手动添加。",
catalogTruncated:
"目录最多显示 2,000 个模型或 20 页,其余模型可手动添加。",
discovery_unsupported: "此接口不支持获取模型目录,请手动填写模型 ID。",
discovery_failed: "获取目录失败,请检查渠道连接后重试。",
importUncertain: "结果未确认,重试时会检查模型是否已经保存。",
importStopped:
"已停止,已完成的模型会保留;可重试确认最后一项的保存结果。",
stopImport: "停止",
closeCatalog: "完成",
catalogCount: (visible, total, selected) =>
`显示 ${visible} / ${total} 个 · 已选 ${selected} 个`,
testAndAdd: (count) => `测试并添加(${count})`,
title: "模型",
description: "管理供用户选择的共享模型。服务器配置中的模型为只读。",
adminOnly: "只有管理员可以管理共享模型,演示模式不支持此功能。",

View File

@ -0,0 +1,4 @@
# Model catalog and recent choices
`catalog.ts` filters only authorized models. `recent.ts` stores eight recent
choices per user/browser. Favorites remain in the existing store.

View File

@ -0,0 +1,30 @@
import type { Model } from "./types";
export function filterModels(
models: readonly Model[],
query: string,
provider = "",
) {
const term = query.trim().toLocaleLowerCase();
return models.filter(
(model) =>
(!provider || (model.provider ?? "") === provider) &&
[model.name, model.model, model.display_name, model.provider].some(
(value) => value?.toLocaleLowerCase().includes(term),
),
);
}
export function projectRecentModels(
models: readonly Model[],
recent: readonly string[],
favorites: readonly string[],
) {
const available = new Map(models.map((model) => [model.name, model]));
return [...new Set(recent)]
.filter((name) => !favorites.includes(name))
.flatMap((name) => {
const model = available.get(name);
return model ? [model] : [];
});
}

View File

@ -50,7 +50,10 @@ export type SaveModelRequest = {
async function request<T>(
suffix: string,
method: string,
body?: SaveModelRequest,
body?:
| SaveModelRequest
| ModelConnection
| { connection: ModelConnection; model: string },
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(
@ -111,3 +114,30 @@ export function modelDraft(model?: ManagedModel): ModelDraft {
max_tokens: model?.max_tokens ?? null,
};
}
export type ModelConnection = {
provider: string;
base_url?: string;
api_key?: string;
saved_model_name?: string;
expected_revision?: string;
};
export type CatalogModel = { id: string; configured: boolean };
export const discoverModels = (body: ModelConnection, signal?: AbortSignal) =>
request<{ models: CatalogModel[]; truncated: boolean }>(
"/discover",
"POST",
body,
signal,
);
export type ImportResult =
| { status: "saved" | "existing"; model: ManagedModel }
| {
status: "failed";
message: Awaited<ReturnType<typeof testManagedModel>>["message"];
};
export const importCatalogModel = (
connection: ModelConnection,
model: string,
signal?: AbortSignal,
) => request<ImportResult>("/import", "POST", { connection, model }, signal);

View File

@ -0,0 +1,45 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { safeLocalStorage } from "@/core/settings/local";
import { parseFavoriteNames, serializeFavoriteNames } from "./favorites";
const EVENT = "deerflow:model-recent";
const memory = new Map<string, string>();
export const recentKey = (userId: string) =>
`deerflow.model-recent.v1:${encodeURIComponent(userId)}`;
export function rememberModel(userId: string | null, name: string) {
if (!userId || !name.trim()) return;
const key = recentKey(userId);
const previous = parseFavoriteNames(
memory.get(key) ?? safeLocalStorage.getItem(key),
);
const raw = serializeFavoriteNames(
[name, ...previous.filter((item) => item !== name)].slice(0, 8),
);
if (!safeLocalStorage.setItem(key, raw)) memory.set(key, raw);
else memory.delete(key);
window.dispatchEvent(new Event(EVENT));
}
export function useRecentModels(userId: string | null) {
const subscribe = useCallback((notify: () => void) => {
window.addEventListener(EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(EVENT, notify);
window.removeEventListener("storage", notify);
};
}, []);
const snapshot = useCallback(
() =>
userId
? (memory.get(recentKey(userId)) ??
safeLocalStorage.getItem(recentKey(userId)))
: null,
[userId],
);
const raw = useSyncExternalStore(subscribe, snapshot, () => null);
return useMemo(() => parseFavoriteNames(raw), [raw]);
}

View File

@ -22,6 +22,7 @@ export interface ModelReasoningCapabilities {
}
export interface Model {
provider?: string | null;
id: string;
name: string;
model: string;

View File

@ -0,0 +1,268 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
for (const mobile of [false, true]) {
test(`catalog batch import and searchable picker ${mobile ? "mobile" : "desktop"}`, async ({
page,
}, testInfo) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, { threads: [{ thread_id: MOCK_THREAD_ID }] });
const models: Record<string, unknown>[] = [];
const added: string[] = [];
await page.route("**/api/models", (route) =>
route.fulfill({
json: {
models: models.filter((m) => m.enabled),
token_usage: { enabled: false },
},
}),
);
await page.route("**/api/managed-models", (route) =>
route.fulfill({ json: { models } }),
);
await page.route("**/api/managed-models/providers", (route) =>
route.fulfill({
json: {
providers: [
{
id: "openai-compatible",
display_name: "Custom",
requires_api_key: false,
custom_endpoint: true,
},
{
id: "openai",
display_name: "OpenAI",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "qwen-cn",
display_name: "Alibaba Cloud Bailian / Qwen (China)",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "doubao",
display_name: "Volcengine / Doubao (Beijing, API)",
requires_api_key: true,
custom_endpoint: false,
},
],
},
}),
);
await page.route("**/api/managed-models/discover", (route) => {
expect(route.request().postDataJSON()).toMatchObject({
provider: "openai-compatible",
base_url: "https://custom.example/v1",
api_key: "fixture-key",
});
return route.fulfill({
json: {
models: [
{ id: "alpha", configured: false },
{ id: "beta", configured: false },
{ id: "no-tools", configured: false },
],
truncated: false,
},
});
});
await page.route("**/api/managed-models/import", (route) => {
const body = route.request().postDataJSON() as { model: string };
added.push(body.model);
if (body.model === "no-tools")
return route.fulfill({
json: { status: "failed", message: "tool_call_missing" },
});
const model = {
name: body.model,
model: body.model,
display_name: body.model,
provider: "OpenAI",
enabled: true,
source: "managed",
revision: "1",
has_api_key: true,
};
models.push(model);
return route.fulfill({ json: { status: "saved", model } });
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}?settings=models`);
await page
.getByRole("button", { name: "Fetch and add models", exact: true })
.waitFor();
await page.screenshot({
path: testInfo.outputPath("entry.png"),
animations: "disabled",
});
await page.getByRole("button", { name: "Add model", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Add model", exact: true });
await editor
.getByRole("combobox", { name: "Provider", exact: true })
.selectOption("openai-compatible");
await editor
.getByLabel("Base URL", { exact: true })
.fill("https://custom.example/v1");
await editor
.getByLabel("API Key", { exact: true })
.fill("single-model-draft-key");
await editor.getByLabel("Model ID", { exact: true }).fill("manual-model");
await expect(editor).toHaveCSS("opacity", "1");
const singleEndpointBox = (await editor
.getByLabel("Base URL", { exact: true })
.boundingBox())!;
const singleKeyBox = (await editor
.getByLabel("API Key", { exact: true })
.boundingBox())!;
expect(singleEndpointBox.y + singleEndpointBox.height).toBeLessThanOrEqual(
singleKeyBox.y,
);
await editor.screenshot({
path: testInfo.outputPath("shared-editor.png"),
animations: "disabled",
});
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await page
.getByRole("button", { name: "Fetch and add models", exact: true })
.click();
const dialog = page.getByRole("dialog", {
name: "Fetch and add models",
exact: true,
});
const provider = dialog.getByRole("combobox", {
name: "Provider",
exact: true,
});
const apiKey = dialog.getByLabel("API Key", { exact: true });
await expect(apiKey).toHaveValue("");
await expect(dialog).toHaveCSS("opacity", "1");
const providerBox = (await provider.boundingBox())!;
const keyBox = (await apiKey.boundingBox())!;
const dialogBox = (await dialog.boundingBox())!;
expect(providerBox.x + providerBox.width).toBeLessThanOrEqual(
dialogBox.x + dialogBox.width,
);
if (!mobile)
expect(providerBox.x + providerBox.width).toBeLessThanOrEqual(keyBox.x);
await dialog.screenshot({
path: testInfo.outputPath("connection.png"),
animations: "disabled",
});
await provider.selectOption("openai-compatible");
const endpoint = dialog.getByLabel("Base URL", { exact: true });
await endpoint.fill("https://custom.example/v1");
await apiKey.fill("fixture-key");
const endpointBox = (await endpoint.boundingBox())!;
const customKeyBox = (await apiKey.boundingBox())!;
expect(endpointBox.y + endpointBox.height).toBeLessThanOrEqual(
customKeyBox.y,
);
await dialog.screenshot({
path: testInfo.outputPath("custom-connection.png"),
animations: "disabled",
});
await dialog
.getByRole("button", { name: "Fetch models", exact: true })
.click();
await expect(dialog.getByText("alpha", { exact: true })).toBeVisible();
expect(added).toEqual([]);
await expect(dialog.getByLabel("API Key", { exact: true })).toBeHidden();
await dialog.getByRole("button", { name: "Select visible" }).click();
await dialog.getByRole("button", { name: "Test and add (3)" }).click();
await expect(dialog.getByText(/did not return a tool call/)).toBeVisible();
expect(added).toEqual(["alpha", "beta", "no-tools"]);
await expect(
dialog.getByRole("button", { name: "Test and add (1)" }),
).toBeEnabled();
await page.screenshot({
path: testInfo.outputPath("catalog.png"),
animations: "disabled",
});
await dialog.getByRole("button", { name: "Done", exact: true }).click();
await page.keyboard.press("Escape");
await page
.getByRole("button", { name: "alpha", exact: true })
.first()
.click();
const picker = page.getByRole("dialog", { name: "Choose a model" });
await picker.getByRole("textbox", { name: "Search models…" }).fill("BETA");
await expect(
picker.getByRole("button", { name: "beta (beta)", exact: true }),
).toBeVisible();
await expect(
picker.getByRole("button", { name: "alpha (alpha)", exact: true }),
).toHaveCount(0);
await picker
.getByRole("button", { name: "beta (beta)", exact: true })
.click();
await page
.getByRole("button", { name: "beta", exact: true })
.first()
.click();
await expect(
picker.getByRole("group", { name: "Recently used" }),
).toBeVisible();
await expect(picker).toHaveCSS("opacity", "1");
await page.screenshot({
path: testInfo.outputPath("picker.png"),
animations: "disabled",
});
const box = await picker.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
});
}
for (const mobile of [false, true]) {
test(`provider group keyboard order ${mobile ? "mobile" : "desktop"}`, async ({
page,
}, testInfo) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, { threads: [{ thread_id: MOCK_THREAD_ID }] });
const models = [
{
name: "a1",
model: "a1",
display_name: "Alpha 1",
provider: "Provider A",
},
{
name: "b1",
model: "b1",
display_name: "Beta 1",
provider: "Provider B",
},
{
name: "a2",
model: "a2",
display_name: "Alpha 2",
provider: "Provider A",
},
];
await page.route("**/api/models", (route) =>
route.fulfill({ json: { models, token_usage: { enabled: false } } }),
);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page
.getByRole("button", { name: "Alpha 1", exact: true })
.first()
.click();
const picker = page.getByRole("dialog", { name: "Choose a model" });
const options = picker.locator('[data-model-picker-option="true"]');
await expect(options).toHaveText(["Alpha 1a1", "Alpha 2a2", "Beta 1b1"]);
await options.nth(0).focus();
for (const index of [1, 2, 0]) {
await page.keyboard.press("ArrowDown");
await expect(options.nth(index)).toBeFocused();
}
await page.keyboard.press("ArrowUp");
await expect(options.nth(2)).toBeFocused();
await picker.screenshot({
path: testInfo.outputPath("grouped-picker.png"),
animations: "disabled",
});
});
}

View File

@ -33,6 +33,11 @@ rs.mock("@/core/i18n/hooks", () => ({
t: {
modelPicker: {
title: "Choose a model",
search: "Search models",
provider: "Provider",
allProviders: "All providers",
noResults: "No matching models",
recent: "Recently used",
favorites: "Favorites",
otherModels: "Other models",
noModels: "No models available",
@ -162,6 +167,7 @@ function favoriteButton(model: Model) {
}
beforeEach(() => {
window.localStorage.clear();
authUser = { id: "alice" };
authLoading = false;
favoriteNames = [MODELS[1]!.name];
@ -444,3 +450,150 @@ describe("ModelPickerContent popover lifecycle", () => {
);
});
});
it("searches model IDs and intersects provider filtering without changing selection", async () => {
const onSelect = rs.fn();
render(
<StatefulPicker
models={MODELS.map((model, i) => ({
...model,
provider: i === 0 ? "First" : "Second",
}))}
onModelSelect={onSelect}
/>,
);
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), {
target: { value: "BETA-API" },
});
expect(modelButtons().length).toBe(1);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "First" },
});
expect(screen.getByText("No matching models")).not.toBeNull();
expect(onSelect).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "" },
});
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search models" }), {
key: "ArrowDown",
});
expect(document.activeElement).toBe(modelButtons()[0]);
});
it("navigates in rendered provider order, including wrapping and filtered results", async () => {
favoriteNames = [];
const models = [
{ ...MODELS[0]!, name: "a1", provider: "A" },
{ ...MODELS[1]!, name: "b1", provider: "B" },
{ ...MODELS[2]!, name: "a2", provider: "A" },
];
render(<StatefulPicker models={models} selectedModelName="a1" />);
await screen.findByRole("group", { name: "A" });
const buttons = modelButtons();
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
"Shared label (a1)",
"Gamma (a2)",
"Shared label (b1)",
]);
for (const direction of ["ArrowDown", "ArrowUp"]) {
buttons[0]!.focus();
for (let step = 1; step <= buttons.length; step++) {
fireEvent.keyDown(document.activeElement!, { key: direction });
const index =
(direction === "ArrowDown" ? step : buttons.length - step) %
buttons.length;
expect(document.activeElement).toBe(buttons[index]);
}
}
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "A" },
});
const filtered = modelButtons();
filtered[0]!.focus();
fireEvent.keyDown(filtered[0]!, { key: "ArrowDown" });
expect(document.activeElement).toBe(filtered[1]);
});
it.each([0, 1, 2])(
"clears a removed provider filter with %i providers remaining",
async (remaining) => {
favoriteNames = [];
const models = [
{ ...MODELS[0]!, name: "a1", provider: "A" },
{ ...MODELS[1]!, name: "b1", provider: "B" },
{ ...MODELS[2]!, name: "c1", provider: "C" },
];
const view = render(<ControlledPicker open models={models} />);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "A" },
});
expect(modelButtons()).toHaveLength(1);
view.rerender(
<ControlledPicker open models={models.slice(1, remaining + 1)} />,
);
await waitFor(() => expect(modelButtons()).toHaveLength(remaining));
if (remaining > 0)
expect(modelButtons()[0]!.getAttribute("aria-label")).toBe(
"Shared label (b1)",
);
if (remaining < 2)
expect(screen.queryByRole("combobox", { name: "Provider" })).toBeNull();
else
expect(screen.getByRole("combobox", { name: "Provider" })).toHaveProperty(
"value",
"",
);
view.rerender(<ControlledPicker open models={models} />);
expect(modelButtons()).toHaveLength(3);
},
);
it("keeps a provider filter while that provider still has a model", async () => {
favoriteNames = [];
const models = [
{ ...MODELS[0]!, name: "a1", provider: "A" },
{ ...MODELS[1]!, name: "b1", provider: "B" },
{ ...MODELS[2]!, name: "a2", provider: "A" },
];
const view = render(<ControlledPicker open models={models} />);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "A" },
});
view.rerender(<ControlledPicker open models={models.slice(1)} />);
await waitFor(() => expect(modelButtons()).toHaveLength(1));
expect(modelButtons()[0]!.getAttribute("aria-label")).toBe("Gamma (a2)");
expect(screen.getByRole("combobox", { name: "Provider" })).toHaveProperty(
"value",
"A",
);
});
it("keeps an active provider filter clearable when YAML models remain", async () => {
favoriteNames = [];
const models = [
{ ...MODELS[0]!, name: "a1", provider: "A" },
{ ...MODELS[1]!, name: "b1", provider: "B" },
{ ...MODELS[2]!, name: "yaml-model", model: "yaml-model" },
];
const view = render(<ControlledPicker open models={models} />);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "A" },
});
view.rerender(<ControlledPicker open models={[models[0]!, models[2]!]} />);
expect(modelButtons()).toHaveLength(1);
expect(screen.getByRole("combobox", { name: "Provider" })).toHaveProperty(
"value",
"A",
);
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), {
target: { value: "yaml-model" },
});
expect(modelButtons()).toHaveLength(0);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "" },
});
await waitFor(() => expect(modelButtons()).toHaveLength(1));
expect(modelButtons()[0]!.getAttribute("aria-label")).toBe(
"Gamma (yaml-model)",
);
});

View File

@ -0,0 +1,248 @@
import { afterEach, beforeEach, expect, it, rs } from "@rstest/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { ModelCatalogDialog } from "@/components/workspace/settings/model-catalog-dialog";
import { enUS } from "@/core/i18n/locales/en-US";
import {
discoverModels,
importCatalogModel,
type ManagedModel,
} from "@/core/models/management";
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("@/core/models/management", () => ({
discoverModels: rs.fn(),
importCatalogModel: rs.fn(),
}));
const discover = rs.mocked(discoverModels),
add = rs.mocked(importCatalogModel);
const saved = rs.fn().mockResolvedValue(undefined);
const props = {
models: [],
providers: [
{
id: "openai",
display_name: "OpenAI",
requires_api_key: true,
custom_endpoint: false,
},
],
close: rs.fn(),
saved,
};
beforeEach(() => {
rs.clearAllMocks();
discover.mockResolvedValue({
models: [
{ id: "good", configured: false },
{ id: "bad", configured: false },
{ id: "old", configured: true },
],
truncated: false,
});
});
afterEach(cleanup);
async function load() {
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "only-in-draft" },
});
fireEvent.click(screen.getByRole("button", { name: "Fetch models" }));
await screen.findByText("good");
}
it("fetches without saving, filters selection, keeps partial failures retryable", async () => {
add.mockImplementation(async (_connection, id) =>
id === "bad"
? { status: "failed", message: "tool_call_missing" }
: { status: "saved", model: { name: "saved" } as never },
);
render(<ModelCatalogDialog {...props} />);
await load();
expect(add).not.toHaveBeenCalled();
expect(screen.queryByLabelText("API Key")?.closest("details")?.open).toBe(
false,
);
expect(screen.getByRole("checkbox", { name: /old/ })).toHaveProperty(
"disabled",
true,
);
fireEvent.change(screen.getByRole("textbox", { name: "Search models…" }), {
target: { value: "good" },
});
fireEvent.click(screen.getByRole("button", { name: "Select visible" }));
fireEvent.change(screen.getByRole("textbox", { name: "Search models…" }), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "bad" }));
fireEvent.click(screen.getByRole("button", { name: "Test and add (2)" }));
await waitFor(() => expect(saved).toHaveBeenCalled());
expect(add.mock.calls.map((call) => call[1])).toEqual(["good", "bad"]);
expect(
screen.getByText(enUS.settings.models.tool_call_missing),
).toBeDefined();
expect(
screen.getByRole("button", { name: "Test and add (1)" }),
).toHaveProperty("disabled", false);
});
it("clears catalog and selected models after a credential edit", async () => {
render(<ModelCatalogDialog {...props} />);
await load();
fireEvent.click(screen.getByRole("button", { name: "Select visible" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "other-key" },
});
expect(screen.queryByText("good")).toBeNull();
expect(
screen.getByRole("button", { name: "Test and add (0)" }),
).toHaveProperty("disabled", true);
});
it("aborts an unmounted discovery and ignores its late response", async () => {
let finish!: (value: Awaited<ReturnType<typeof discoverModels>>) => void;
discover.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const view = render(<ModelCatalogDialog {...props} />);
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test" },
});
fireEvent.click(screen.getByRole("button", { name: "Fetch models" }));
const signal = discover.mock.calls[0]![1]!;
view.unmount();
expect(signal.aborted).toBe(true);
finish({ models: [{ id: "late", configured: false }], truncated: false });
expect(add).not.toHaveBeenCalled();
});
it("stopping an import refreshes committed results and leaves the uncertain item retryable", async () => {
add.mockImplementation(
(_connection, _id, signal) =>
new Promise((_resolve, reject) => {
signal!.addEventListener("abort", () =>
reject(new DOMException("Cancelled", "AbortError")),
);
}),
);
render(<ModelCatalogDialog {...props} />);
await load();
fireEvent.click(screen.getByRole("checkbox", { name: "good" }));
fireEvent.click(screen.getByRole("button", { name: "Test and add (1)" }));
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
await screen.findByText(enUS.settings.models.importStopped);
expect(screen.getByText(enUS.settings.models.importUncertain)).not.toBeNull();
expect(
screen.getByRole("button", { name: "Test and add (1)" }),
).toHaveProperty("disabled", false);
expect(saved).toHaveBeenCalledTimes(1);
});
it("fills a custom connection in display order without losing the key", async () => {
render(
<ModelCatalogDialog
{...props}
providers={[
...props.providers,
{
id: "openai-compatible",
display_name: "Custom",
requires_api_key: false,
custom_endpoint: true,
},
]}
/>,
);
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: "openai-compatible" },
});
const endpoint = screen.getByLabelText(enUS.settings.models.endpoint);
const key = screen.getByLabelText("API Key");
const inputs = Array.from(document.querySelectorAll("input"));
expect(inputs.indexOf(endpoint as HTMLInputElement)).toBeLessThan(
inputs.indexOf(key as HTMLInputElement),
);
fireEvent.change(endpoint, {
target: { value: "https://custom.example/v1" },
});
fireEvent.change(key, { target: { value: "custom-key" } });
fireEvent.click(screen.getByRole("button", { name: "Fetch models" }));
await waitFor(() => expect(discover).toHaveBeenCalled());
expect(discover.mock.calls[0]![0]).toMatchObject({
provider: "openai-compatible",
base_url: "https://custom.example/v1",
api_key: "custom-key",
});
fireEvent.change(endpoint, { target: { value: "https://other.example/v1" } });
expect((key as HTMLInputElement).value).toBe("");
});
it("reuses a saved connection only until its destination changes", async () => {
const model: ManagedModel = {
name: "saved-custom",
display_name: "Saved custom",
model: "old",
provider: "openai-compatible",
base_url: "https://saved.example/v1",
enabled: true,
supports_vision: null,
context_window: null,
max_tokens: null,
revision: "rev-1",
source: "managed",
has_api_key: true,
};
render(
<ModelCatalogDialog
{...props}
models={[model]}
providers={[
...props.providers,
{
id: "openai-compatible",
display_name: "Custom",
requires_api_key: false,
custom_endpoint: true,
},
]}
/>,
);
fireEvent.change(
screen.getByLabelText(enUS.settings.models.savedConnection),
{ target: { value: model.name } },
);
fireEvent.click(screen.getByRole("button", { name: "Fetch models" }));
await screen.findByText("good");
expect(discover.mock.calls[0]![0]).toEqual({
provider: model.provider,
base_url: model.base_url,
saved_model_name: model.name,
expected_revision: model.revision,
});
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "replacement-key" },
});
fireEvent.change(screen.getByLabelText(enUS.settings.models.endpoint), {
target: { value: "https://new.example/v1" },
});
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "");
expect(
screen.getByLabelText(enUS.settings.models.savedConnection),
).toHaveProperty("value", "");
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "new-destination-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Fetch models" }));
await waitFor(() => expect(discover).toHaveBeenCalledTimes(2));
expect(discover.mock.calls[1]![0]).toEqual({
provider: model.provider,
base_url: "https://new.example/v1",
api_key: "new-destination-key",
});
});

View File

@ -195,8 +195,9 @@ test("new preset needs only provider, API key and arbitrary model ID", async ()
).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Add model" }));
expect(screen.getByRole("combobox", { name: "Provider" }).textContent).toBe(
"OpenAI",
expect(screen.getByRole("combobox", { name: "Provider" })).toHaveProperty(
"value",
"openai",
);
expect(screen.queryByLabelText("Base URL")).toBeNull();
expect(screen.queryByLabelText("Unique name")).toBeNull();
@ -222,12 +223,10 @@ test("new preset needs only provider, API key and arbitrary model ID", async ()
});
async function selectProvider(name: string) {
fireEvent.pointerDown(screen.getByRole("combobox", { name: "Provider" }), {
button: 0,
ctrlKey: false,
pointerType: "mouse",
const option = await screen.findByRole("option", { name });
fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), {
target: { value: option.getAttribute("value") },
});
fireEvent.click(await screen.findByRole("option", { name }));
}
test("switching providers clears test status and cannot reuse a saved key", async () => {
@ -494,3 +493,29 @@ test("revision/name conflicts are localized and the unsaved draft survives", asy
);
expect(screen.queryByText("private server detail")).toBeNull();
});
test("changing a custom destination clears its key without losing model settings", async () => {
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "draft-key" },
});
fireEvent.change(screen.getByLabelText("Base URL"), {
target: { value: "https://other.example/v1" },
});
expect(screen.getByLabelText("API Key")).toHaveProperty("value", "");
expect(screen.queryByLabelText("Remove the saved API key")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0]).toMatchObject({
config: {
name: existing.name,
model: existing.model,
base_url: "https://other.example/v1",
},
expected_revision: existing.revision,
});
expect(
rs.mocked(saveManagedModel).mock.calls[0]![0].config,
).not.toHaveProperty("api_key");
});

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "@rstest/core";
import { filterModels, projectRecentModels } from "@/core/models/catalog";
import type { Model } from "@/core/models/types";
const models: Model[] = [
{
id: "a",
name: "a",
model: "api-a",
display_name: "快速模型",
provider: "First",
},
{
id: "b",
name: "b",
model: "api-b",
display_name: "Second",
provider: "Other",
},
];
describe("model catalog selection", () => {
it("searches names, IDs and providers literally, intersecting the provider filter", () => {
expect(filterModels(models, " 快速 ")).toEqual([models[0]]);
expect(filterModels(models, "API-A")).toEqual([models[0]]);
expect(filterModels(models, "First", "Other")).toEqual([]);
expect(filterModels(models, ".*")).toEqual([]);
});
it("orders recent authorized models without duplicating favorites or deleted models", () => {
expect(
projectRecentModels(models, ["b", "removed", "a", "b"], ["a"]),
).toEqual([models[1]]);
});
});

View File

@ -0,0 +1,45 @@
import { afterEach, expect, it, rs } from "@rstest/core";
import { act, cleanup, renderHook } from "@testing-library/react";
import {
rememberModel,
recentKey,
useRecentModels,
} from "@/core/models/recent";
afterEach(() => {
cleanup();
rs.restoreAllMocks();
localStorage.clear();
});
it("synchronizes mounted pickers, bounds history and isolates accounts", () => {
const first = renderHook(({ user }) => useRecentModels(user), {
initialProps: { user: "recent-a" as string | null },
});
const second = renderHook(() => useRecentModels("recent-a"));
act(() => {
for (let i = 0; i < 12; i++) rememberModel("recent-a", `model-${i}`);
});
expect(first.result.current).toHaveLength(8);
expect(second.result.current).toEqual(first.result.current);
act(() => rememberModel("recent-a", "model-7"));
expect(first.result.current[0]).toBe("model-7");
first.rerender({ user: null });
expect(first.result.current).toEqual([]);
first.rerender({ user: "recent-b" });
expect(first.result.current).toEqual([]);
act(() => {
localStorage.removeItem(recentKey("recent-a"));
window.dispatchEvent(new Event("storage"));
});
expect(second.result.current).toEqual([]);
});
it("continues in memory when storage writes fail", () => {
rs.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
const result = renderHook(() => useRecentModels("blocked-recent"));
act(() => rememberModel("blocked-recent", "a"));
act(() => rememberModel("blocked-recent", "b"));
expect(result.result.current).toEqual(["b", "a"]);
});