From 2145c9ab1f176e62a8c4fc087857ee3104b87b8b Mon Sep 17 00:00:00 2001 From: Wenchao An Date: Thu, 5 Nov 2026 06:43:01 +0800 Subject: [PATCH] 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 --- README.md | 6 + backend/app/gateway/model_discovery.py | 78 ++++ backend/app/gateway/routers/AGENTS.md | 9 + backend/app/gateway/routers/managed_models.py | 99 ++++- backend/app/gateway/routers/models.py | 2 + backend/docs/CONFIGURATION.md | 20 + .../harness/deerflow/config/AGENTS.md | 6 + .../harness/deerflow/config/managed_models.py | 12 +- .../harness/deerflow/config/model_config.py | 4 +- .../tests/blocking_io/test_managed_models.py | 24 ++ backend/tests/test_agent_guidance_check.py | 2 + backend/tests/test_model_catalog.py | 247 +++++++++++ frontend/src/components/workspace/AGENTS.md | 13 + .../workspace/model-picker-content.tsx | 119 +++++- .../settings/model-catalog-dialog.tsx | 384 ++++++++++++++++++ .../settings/model-connection-fields.tsx | 112 +++++ .../settings/model-settings-page.tsx | 255 ++++++------ frontend/src/core/i18n/locales/en-US.ts | 34 ++ frontend/src/core/i18n/locales/types.ts | 30 ++ frontend/src/core/i18n/locales/zh-CN.ts | 30 ++ frontend/src/core/models/AGENTS.md | 4 + frontend/src/core/models/catalog.ts | 30 ++ frontend/src/core/models/management.ts | 32 +- frontend/src/core/models/recent.ts | 45 ++ frontend/src/core/models/types.ts | 1 + frontend/tests/e2e/model-catalog.spec.ts | 268 ++++++++++++ .../model-picker-content.dom.test.tsx | 153 +++++++ .../model-catalog-dialog.dom.test.tsx | 248 +++++++++++ .../settings/model-settings-page.dom.test.tsx | 39 +- .../tests/unit/core/models/catalog.test.ts | 34 ++ .../tests/unit/core/models/recent.dom.test.ts | 45 ++ 31 files changed, 2218 insertions(+), 167 deletions(-) create mode 100644 backend/app/gateway/model_discovery.py create mode 100644 backend/tests/test_model_catalog.py create mode 100644 frontend/src/components/workspace/AGENTS.md create mode 100644 frontend/src/components/workspace/settings/model-catalog-dialog.tsx create mode 100644 frontend/src/components/workspace/settings/model-connection-fields.tsx create mode 100644 frontend/src/core/models/AGENTS.md create mode 100644 frontend/src/core/models/catalog.ts create mode 100644 frontend/src/core/models/recent.ts create mode 100644 frontend/tests/e2e/model-catalog.spec.ts create mode 100644 frontend/tests/unit/components/workspace/settings/model-catalog-dialog.dom.test.tsx create mode 100644 frontend/tests/unit/core/models/catalog.test.ts create mode 100644 frontend/tests/unit/core/models/recent.dom.test.ts diff --git a/README.md b/README.md index 4806344a19..1fd85b2fe4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/gateway/model_discovery.py b/backend/app/gateway/model_discovery.py new file mode 100644 index 0000000000..294163b31a --- /dev/null +++ b/backend/app/gateway/model_discovery.py @@ -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 diff --git a/backend/app/gateway/routers/AGENTS.md b/backend/app/gateway/routers/AGENTS.md index 4081b851e8..8dddba0be4 100644 --- a/backend/app/gateway/routers/AGENTS.md +++ b/backend/app/gateway/routers/AGENTS.md @@ -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. diff --git a/backend/app/gateway/routers/managed_models.py b/backend/app/gateway/routers/managed_models.py index 8496e1f0d6..e02cbd5538 100644 --- a/backend/app/gateway/routers/managed_models.py +++ b/backend/app/gateway/routers/managed_models.py @@ -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 diff --git a/backend/app/gateway/routers/models.py b/backend/app/gateway/routers/models.py index dd0f02e2e0..50f57d2cb0 100644 --- a/backend/app/gateway/routers/models.py +++ b/backend/app/gateway/routers/models.py @@ -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))), diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 8b885b4857..1b34201e5e 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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 diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index ab72957d44..a45165d7b4 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/config/managed_models.py b/backend/packages/harness/deerflow/config/managed_models.py index 36fcfd3911..a579a25593 100644 --- a/backend/packages/harness/deerflow/config/managed_models.py +++ b/backend/packages/harness/deerflow/config/managed_models.py @@ -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") diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index 1524c09e3f..97bf47f27e 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -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") diff --git a/backend/tests/blocking_io/test_managed_models.py b/backend/tests/blocking_io/test_managed_models.py index f2b564b20a..35e15b5aa9 100644 --- a/backend/tests/blocking_io/test_managed_models.py +++ b/backend/tests/blocking_io/test_managed_models.py @@ -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}] diff --git a/backend/tests/test_agent_guidance_check.py b/backend/tests/test_agent_guidance_check.py index b8e7eee6a7..bfd613acf3 100644 --- a/backend/tests/test_agent_guidance_check.py +++ b/backend/tests/test_agent_guidance_check.py @@ -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", diff --git a/backend/tests/test_model_catalog.py b/backend/tests/test_model_catalog.py new file mode 100644 index 0000000000..b7aa55cd38 --- /dev/null +++ b/backend/tests/test_model_catalog.py @@ -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 diff --git a/frontend/src/components/workspace/AGENTS.md b/frontend/src/components/workspace/AGENTS.md new file mode 100644 index 0000000000..c9d7894500 --- /dev/null +++ b/frontend/src/components/workspace/AGENTS.md @@ -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. diff --git a/frontend/src/components/workspace/model-picker-content.tsx b/frontend/src/components/workspace/model-picker-content.tsx index 26cb4a5163..dd95888b51 100644 --- a/frontend/src/components/workspace/model-picker-content.tsx +++ b/frontend/src/components/workspace/model-picker-content.tsx @@ -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 ( @@ -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()); const favoriteButtonRefs = useRef(new Map()); const pendingFavoriteFocusRef = useRef(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)} > @@ -266,15 +319,51 @@ export function ModelPickerContent({ focusInitialModel(); }} > +
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + modelButtonRefs.current + .get(visibleModels[0]?.name ?? "") + ?.focus(); + } + }} + /> + {(providerNames.length > 1 || provider !== "") && ( + + )} +
{visibleModels.length === 0 ? (
- {t.modelPicker.noModels} + {query || provider + ? t.modelPicker.noResults + : t.modelPicker.noModels}
) : ( <> - {renderGroup(t.modelPicker.favorites, projection.favorites)} - {renderGroup(t.modelPicker.otherModels, projection.others)} + {groups.map((group) => ( +
+ {renderGroup(group.heading, group.models)} +
+ ))} )}
diff --git a/frontend/src/components/workspace/settings/model-catalog-dialog.tsx b/frontend/src/components/workspace/settings/model-catalog-dialog.tsx new file mode 100644 index 0000000000..7940ff85ac --- /dev/null +++ b/frontend/src/components/workspace/settings/model-catalog-dialog.tsx @@ -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; +}) { + const { t } = useI18n(); + const text = t.settings.models; + const [connectionOpen, setConnectionOpen] = useState(true); + const [connection, setConnection] = useState({ + provider: "openai", + }); + const source = connection.saved_model_name ?? ""; + const [catalog, setCatalog] = useState(null); + const [truncated, setTruncated] = useState(false); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState([]); + const [results, setResults] = useState>({}); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(""); + const operation = useRef(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 ( + { + if (!open) close(); + }} + > + + + {text.importTitle} + {text.importDescription} + +
setConnectionOpen(event.currentTarget.open)} + className="min-w-0 shrink-0" + > + + {text.connectionSettings} ·{" "} + {provider?.custom_endpoint + ? text.customProvider + : provider?.display_name} + +
+ {models.length > 0 && ( + + )} + model.name === source)?.has_api_key + } + onChange={change} + /> + +
+
+ {message && ( +

+ {message} +

+ )} + {catalog && ( + <> +
+ setQuery(event.target.value)} + /> +
+ + {text.catalogCount( + visible.length, + catalog.length, + pendingIds.length, + )} + + + +
+ {truncated && ( +

+ {text.catalogTruncated} +

+ )} +
+
+ {visible.length === 0 && ( +

{text.noCatalogResults}

+ )} + {visible.map((item) => ( + + ))} +
+ + )} + {progress && ( +

+ {progress} +

+ )} +
+ {busy ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/settings/model-connection-fields.tsx b/frontend/src/components/workspace/settings/model-connection-fields.tsx new file mode 100644 index 0000000000..c205bcb9dd --- /dev/null +++ b/frontend/src/components/workspace/settings/model-connection-fields.tsx @@ -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 ( +
+ + {provider?.custom_endpoint && ( + + )} + + {allowClearKey && canKeepKey && ( + + )} +
+ ); +} diff --git a/frontend/src/components/workspace/settings/model-settings-page.tsx b/frontend/src/components/workspace/settings/model-settings-page.tsx index c5aa73e9fa..a243f423ff 100644 --- a/frontend/src/components/workspace/settings/model-settings-page.tsx +++ b/frontend/src/components/workspace/settings/model-settings-page.tsx @@ -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(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() {

{text.adminOnly}

) : (
-
+
+
)} {catalog.data?.models.length === 0 &&

{text.empty}

} - {catalog.data?.models.map((model) => ( -
-
-

- {model.display_name || model.name} -

-

- {model.source === "managed" && ( - <> - {model.provider === "openai-compatible" - ? text.customProvider - : (providers.data?.providers.find( - (p) => p.id === model.provider, - )?.display_name ?? model.provider)}{" "} - ·{" "} - + 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) => ( +

+
+

+ {model.display_name || model.name} +

+

+ {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} +

+ {model.source === "managed" && model.conflict && ( +

{text.conflict}

)} - {model.model} ·{" "} - {model.source === "config" - ? text.yaml - : model.enabled - ? text.enabled - : text.disabled} -

- {model.source === "managed" && model.conflict && ( -

{text.conflict}

+
+ {model.source === "managed" && ( +
+ + +
)}
- {model.source === "managed" && ( -
- - -
+ ))} + {importing && ( + model.source === "managed", )} -
- ))} + providers={providers.data?.providers ?? []} + close={() => setImporting(false)} + saved={refresh} + /> + )} {editing && ( 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({ >
-
- - -
- {provider?.custom_endpoint && ( - - )} + {model && !sameConnection && (

{text.providerChanged}

)} @@ -360,43 +372,6 @@ function ModelEditor({ }} /> - - {canKeepKey && ( - - )}
{text.advanced}
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 12f6133074..fd171873cf 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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.", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 43ba12ba1a..dd43567617 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 8c3cd963e9..551f8eba54 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "只有管理员可以管理共享模型,演示模式不支持此功能。", diff --git a/frontend/src/core/models/AGENTS.md b/frontend/src/core/models/AGENTS.md new file mode 100644 index 0000000000..77d5347b7b --- /dev/null +++ b/frontend/src/core/models/AGENTS.md @@ -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. diff --git a/frontend/src/core/models/catalog.ts b/frontend/src/core/models/catalog.ts new file mode 100644 index 0000000000..fbc6636c73 --- /dev/null +++ b/frontend/src/core/models/catalog.ts @@ -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] : []; + }); +} diff --git a/frontend/src/core/models/management.ts b/frontend/src/core/models/management.ts index f419d014ce..32b7835223 100644 --- a/frontend/src/core/models/management.ts +++ b/frontend/src/core/models/management.ts @@ -50,7 +50,10 @@ export type SaveModelRequest = { async function request( suffix: string, method: string, - body?: SaveModelRequest, + body?: + | SaveModelRequest + | ModelConnection + | { connection: ModelConnection; model: string }, signal?: AbortSignal, ): Promise { 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>["message"]; + }; +export const importCatalogModel = ( + connection: ModelConnection, + model: string, + signal?: AbortSignal, +) => request("/import", "POST", { connection, model }, signal); diff --git a/frontend/src/core/models/recent.ts b/frontend/src/core/models/recent.ts new file mode 100644 index 0000000000..7860a911ad --- /dev/null +++ b/frontend/src/core/models/recent.ts @@ -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(); +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]); +} diff --git a/frontend/src/core/models/types.ts b/frontend/src/core/models/types.ts index 168352fa2b..e62a036926 100644 --- a/frontend/src/core/models/types.ts +++ b/frontend/src/core/models/types.ts @@ -22,6 +22,7 @@ export interface ModelReasoningCapabilities { } export interface Model { + provider?: string | null; id: string; name: string; model: string; diff --git a/frontend/tests/e2e/model-catalog.spec.ts b/frontend/tests/e2e/model-catalog.spec.ts new file mode 100644 index 0000000000..4fcfa6956e --- /dev/null +++ b/frontend/tests/e2e/model-catalog.spec.ts @@ -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[] = []; + 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", + }); + }); +} diff --git a/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx b/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx index 3c1141696f..a680ff0763 100644 --- a/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/model-picker-content.dom.test.tsx @@ -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( + ({ + ...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(); + 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(); + fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), { + target: { value: "A" }, + }); + expect(modelButtons()).toHaveLength(1); + view.rerender( + , + ); + 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(); + 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(); + fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), { + target: { value: "A" }, + }); + view.rerender(); + 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(); + fireEvent.change(screen.getByRole("combobox", { name: "Provider" }), { + target: { value: "A" }, + }); + view.rerender(); + 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)", + ); +}); diff --git a/frontend/tests/unit/components/workspace/settings/model-catalog-dialog.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/model-catalog-dialog.dom.test.tsx new file mode 100644 index 0000000000..3c71533f0f --- /dev/null +++ b/frontend/tests/unit/components/workspace/settings/model-catalog-dialog.dom.test.tsx @@ -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(); + 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(); + 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>) => void; + discover.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const view = render(); + 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(); + 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( + , + ); + 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( + , + ); + 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", + }); +}); diff --git a/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx index 1f69f46f01..f783379d66 100644 --- a/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx @@ -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"); +}); diff --git a/frontend/tests/unit/core/models/catalog.test.ts b/frontend/tests/unit/core/models/catalog.test.ts new file mode 100644 index 0000000000..c07a7f7eb5 --- /dev/null +++ b/frontend/tests/unit/core/models/catalog.test.ts @@ -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]]); + }); +}); diff --git a/frontend/tests/unit/core/models/recent.dom.test.ts b/frontend/tests/unit/core/models/recent.dom.test.ts new file mode 100644 index 0000000000..4aec2a9ccd --- /dev/null +++ b/frontend/tests/unit/core/models/recent.dom.test.ts @@ -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"]); +});