mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-07 19:20:34 +00:00
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:
parent
a46d9221f8
commit
2145c9ab1f
@ -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
|
||||
|
||||
78
backend/app/gateway/model_discovery.py
Normal file
78
backend/app/gateway/model_discovery.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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",
|
||||
|
||||
247
backend/tests/test_model_catalog.py
Normal file
247
backend/tests/test_model_catalog.py
Normal 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
|
||||
13
frontend/src/components/workspace/AGENTS.md
Normal file
13
frontend/src/components/workspace/AGENTS.md
Normal 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.
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "只有管理员可以管理共享模型,演示模式不支持此功能。",
|
||||
|
||||
4
frontend/src/core/models/AGENTS.md
Normal file
4
frontend/src/core/models/AGENTS.md
Normal 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.
|
||||
30
frontend/src/core/models/catalog.ts
Normal file
30
frontend/src/core/models/catalog.ts
Normal 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] : [];
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
45
frontend/src/core/models/recent.ts
Normal file
45
frontend/src/core/models/recent.ts
Normal 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]);
|
||||
}
|
||||
@ -22,6 +22,7 @@ export interface ModelReasoningCapabilities {
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
provider?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
|
||||
268
frontend/tests/e2e/model-catalog.spec.ts
Normal file
268
frontend/tests/e2e/model-catalog.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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)",
|
||||
);
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
|
||||
34
frontend/tests/unit/core/models/catalog.test.ts
Normal file
34
frontend/tests/unit/core/models/catalog.test.ts
Normal 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]]);
|
||||
});
|
||||
});
|
||||
45
frontend/tests/unit/core/models/recent.dom.test.ts
Normal file
45
frontend/tests/unit/core/models/recent.dom.test.ts
Normal 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"]);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user