feat(models): manage shared models from Settings (#5596)

* feat(models): add admin UI for shared model management

* docs(gateway): keep model guidance within size budget
This commit is contained in:
Xuehao Xu 2026-09-20 16:13:58 +08:00 committed by GitHub
parent 4e8e2ce691
commit 8ef58eaa90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1370 additions and 3 deletions

View File

@ -155,6 +155,31 @@ It is disabled by default; see the linked guide to enable it.
DeerFlow disables Console cost estimates when currencies are mixed rather
than presenting an invalid aggregate.
Administrators can also open **Settings → Models** to add, edit, test, and
enable/disable shared OpenAI-compatible Chat Completions models without editing
`config.yaml`. Enter a unique name, base URL, model ID, and optional API key;
saving refreshes the chat model list. Connection testing sends a short streaming
tool-call request and may incur provider charges. It does not save the draft or
verify image support; set image support and token limits from provider documentation.
Native provider adapters and advanced reasoning settings remain YAML-configured.
YAML models remain read-only in this page and take precedence on name conflicts.
Managed models are appended after YAML models; edits apply to new configuration
snapshots, while active runs retain their existing snapshot. Disabling a model
removes it from future selection/resolution, so update any custom-agent or scheduled
task definitions that explicitly reference it before disabling it.
Managed models are shared by the deployment, not personal API-key profiles, and
remain subject to the existing model authorization policy.
The encrypted catalog and a generated local encryption key are stored in
`$DEER_FLOW_HOME/managed-models/` (default `.deer-flow/managed-models/`). Persist
and back up the **whole directory**, restrict filesystem access, and share it
across Gateway workers/replicas that should use the same catalog. The local key
is protected by filesystem permissions; encryption does not protect against
someone who can read both files. Losing the key requires restoring the backup.
Reads and writes fail if the catalog cannot be decrypted, rather than replacing it.
This storage is independent of the SQL backend and works with read-only YAML mounts.
When several models are configured, open either model picker and use the
star beside a model to favorite it. Favorites appear first in both the main
chat and Side Chat pickers without changing either chat's selected or

View File

@ -91,6 +91,7 @@ owner-scoped assistant version selection remains enabled.
| Router | Endpoints |
|--------|-----------|
| **Managed Models** (`/api/managed-models`) | Admin-only GET catalog, PUT create/update with revision checks, POST `/test` streaming tool-call probe. YAML names are reserved; credentials stay private. |
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **Features** (`/api/features`) | `GET /` - UI capabilities: hot-reloaded agents, guarded browser, startup MCP tasks, separate batch repository/worker states so history stays readable without a worker, `conversation_references` (whether `read_conversation` is configured, plus the per-run reference cap), and knowledge scope selection |
| **Knowledge** (`/api/knowledge/retrieval-catalog`) | Authenticated, allowlist-safe, read-only dataset/document catalog used only by main and custom-agent chat scope selection; knowledge management remains in RAGFlow |

View File

@ -905,6 +905,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Include routers
# Models API is mounted at /api/models
from app.gateway.routers import managed_models
app.include_router(managed_models.router)
app.include_router(models.router)
# Features API is mounted at /api/features

View File

@ -0,0 +1,94 @@
"""Admin-only shared model management. Credentials never leave the server."""
import asyncio
from fastapi import APIRouter, HTTPException, Request
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict
from app.gateway.deps import require_admin_user
from deerflow.config.app_config import get_app_config
from deerflow.config.managed_models import ManagedModel, ManagedModelStore
router = APIRouter(prefix="/api/managed-models", tags=["models"])
_ADMIN = "Admin privileges are required to manage shared models."
class SaveModelRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
config: ManagedModel
expected_revision: str | None = None
def _catalog():
config = get_app_config()
yaml_models = [item for item in config.models if item.name not in config._managed_model_names]
yaml_names = {item.name for item in yaml_models}
return {
"models": [{"name": item.name, "display_name": item.display_name or item.name, "model": item.model, "source": "config", "enabled": True} for item in yaml_models]
+ [{**item.public(), "conflict": item.name in yaml_names} for item in ManagedModelStore().list()]
}
@router.get("")
async def list_managed_models(request: Request):
await require_admin_user(request, detail=_ADMIN)
try:
return await asyncio.to_thread(_catalog)
except ValueError:
raise HTTPException(503, "Managed model storage is unavailable; check the catalog and encryption key") from None
def _save(body: SaveModelRequest):
config = get_app_config()
if any(item.name == body.config.name and item.name not in config._managed_model_names for item in config.models):
raise HTTPException(409, "This model name is reserved by config.yaml")
try:
return ManagedModelStore().save(body.config, expected_revision=body.expected_revision).public()
except FileExistsError:
raise HTTPException(409, "Model changed or already exists; reload before saving") from None
except FileNotFoundError:
raise HTTPException(404, "Model no longer exists") from None
except (ValueError, OSError):
raise HTTPException(503, "Managed model storage is unavailable") from None
@router.put("")
async def save_model(request: Request, body: SaveModelRequest):
await require_admin_user(request, detail=_ADMIN)
return await asyncio.to_thread(_save, body)
def _probe_config(body: SaveModelRequest):
profile = body.config
if body.expected_revision is not None:
previous = next((item for item in ManagedModelStore().list() if item.name == profile.name), None)
if previous is None or previous.revision != body.expected_revision:
raise HTTPException(409, "Model changed; reload before testing")
if profile.api_key is None:
profile = profile.model_copy(update={"api_key": previous.api_key})
return profile.runtime_config()
@router.post("/test")
async def test_model(request: Request, body: SaveModelRequest):
"""Send a bounded streaming tool-call probe without saving the profile."""
await require_admin_user(request, detail=_ADMIN)
try:
config = await asyncio.to_thread(_probe_config, body)
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model=config.model, base_url=config.base_url, api_key=config.api_key, timeout=15, max_retries=0, max_tokens=32)
probe = model.bind_tools([{"type": "function", "function": {"name": "connection_check", "description": "Check the connection", "parameters": {"type": "object", "properties": {}}}}], tool_choice="connection_check")
response = None
async with asyncio.timeout(20):
async for chunk in probe.astream([HumanMessage(content="Call connection_check.")], config={"callbacks": []}):
response = chunk if response is None else response + chunk
if response is None or not any(call["name"] == "connection_check" and call["args"] == {} for call in response.tool_calls):
return {"ok": False, "message": "tool_call_missing"}
return {"ok": True, "message": "success"}
except HTTPException:
raise
except Exception:
# Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "connection_failed"}

View File

@ -107,3 +107,9 @@ Extensions are optional only in the fallback *search* mode (priority 3-4 above):
Gateway API endpoints and `DeerFlowClient` methods can modify MCP servers and skill state at runtime; their `extensions_config.json` writes use the shared atomic replacement helper, while `middlewares` remains an operator-controlled config-file extension point.
Values beginning with `$` are resolved from the environment when the file is loaded, and an unset variable becomes `""`. Runtime writers (MCP router, skill toggle, `DeerFlowClient`) therefore read the raw file with `read_raw_extensions_config`, merge into it (`set_raw_skill_enabled` for skill state), check the candidate with `validate_raw_extensions_config`, and write that raw dict. They never serialize an `ExtensionsConfig` model back to disk: its resolved values would persist secrets in plaintext and erase the references. When the file does not exist yet, the Gateway skill toggle seeds only the cached skill states. `tests/test_extensions_config_raw_writes.py` and the placeholder tests in `tests/test_client.py` pin this.
The file-backed singleton entrypoints additionally merge administrator-managed shared
models from the encrypted runtime-home catalog. YAML entries win name conflicts;
managed changes create new effective snapshots and do not alter an active runtime
or an explicitly injected AppConfig. See `../models/AGENTS.md` for storage and reload
boundaries. `AppConfig.from_file()` remains YAML-only.

View File

@ -358,6 +358,7 @@ class AppConfig(BaseModel):
# ``_build_name_indexes``. They make ``get_model_config`` / ``get_tool_config``
# / ``get_tool_group_config`` O(1) instead of an O(n) ``next(...)`` scan per
# call. Private attrs are excluded from serialization.
_managed_model_names: set[str] = PrivateAttr(default_factory=set)
_models_by_name: dict[str, ModelConfig] = PrivateAttr(default_factory=dict)
_tools_by_name: dict[str, ToolConfig] = PrivateAttr(default_factory=dict)
_tool_groups_by_name: dict[str, ToolGroupConfig] = PrivateAttr(default_factory=dict)
@ -717,7 +718,9 @@ def get_app_config() -> AppConfig:
elif _app_config_path == resolved_path and _app_config_signature != current_signature:
logger.info("Config file content signature changed, reloading AppConfig")
_load_and_cache_app_config(str(resolved_path))
return _app_config
from deerflow.config.managed_models import merge_managed_models
return merge_managed_models(_app_config)
def reload_app_config(config_path: str | None = None) -> AppConfig:
@ -733,7 +736,9 @@ def reload_app_config(config_path: str | None = None) -> AppConfig:
Returns:
The newly loaded AppConfig instance.
"""
return _load_and_cache_app_config(config_path)
from deerflow.config.managed_models import merge_managed_models
return merge_managed_models(_load_and_cache_app_config(config_path))
def reset_app_config() -> None:

View File

@ -0,0 +1,147 @@
"""Administrator-managed OpenAI-compatible model profiles, separate from YAML.
The encrypted catalog and its local key live in the persistent runtime home.
Writers are serialized across threads/processes; readers see atomic snapshots.
"""
from __future__ import annotations
import json
import os
import tempfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from deerflow.config.extensions_config import extensions_config_file_lock
from deerflow.config.file_signature import get_config_signature
from deerflow.config.model_config import ModelConfig
from deerflow.config.runtime_paths import runtime_home
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
_lock = threading.RLock()
_cache: tuple | None = None
class ManagedModel(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$")
provider: Literal["openai-compatible"] = "openai-compatible"
display_name: str = Field(default="", max_length=100)
model: str = Field(min_length=1, max_length=200)
base_url: str = Field(max_length=2048)
api_key: SecretStr | None = None
enabled: bool = True
supports_vision: bool = False
context_window: int | None = Field(default=None, gt=0)
max_tokens: int | None = Field(default=None, gt=0)
revision: str | None = None
@field_validator("base_url")
@classmethod
def valid_endpoint(cls, value: str) -> str:
try:
url = urlsplit(value)
port = url.port
except ValueError:
raise ValueError("Invalid endpoint URL") from None
if url.scheme not in {"https", "http"} or not url.hostname or url.username is not None or url.password is not None or url.query or url.fragment or (port is not None and port == 0):
raise ValueError("Use an HTTP(S) base URL without credentials, query or fragment")
return value.rstrip("/")
def public(self) -> dict:
return {**self.model_dump(exclude={"api_key"}), "has_api_key": bool(self.api_key and self.api_key.get_secret_value()), "source": "managed"}
def runtime_config(self) -> ModelConfig:
return ModelConfig(
name=self.name,
display_name=self.display_name or self.name,
use="langchain_openai:ChatOpenAI",
model=self.model,
base_url=self.base_url,
api_key=self.api_key.get_secret_value() if self.api_key and self.api_key.get_secret_value() else "not-required",
supports_vision=self.supports_vision,
context_window=self.context_window,
max_tokens=self.max_tokens,
)
class ManagedModelStore:
def __init__(self):
self.path = runtime_home() / "managed-models" / "catalog.enc"
self.key_path = self.path.with_name("key")
def _cipher(self, *, create: bool = False):
from cryptography.fernet import Fernet
if not self.key_path.exists():
if not create or self.path.exists():
raise ValueError("Managed model encryption key is missing; restore it from backup")
self._write(self.key_path, Fernet.generate_key())
return Fernet(self.key_path.read_bytes())
def list(self) -> list[ManagedModel]:
if not self.path.exists():
return []
try:
raw = json.loads(self._cipher().decrypt(self.path.read_bytes()))
return [ManagedModel.model_validate(item) for item in raw]
except Exception:
# 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) -> ManagedModel:
with _lock, extensions_config_file_lock(self.path):
records = self.list()
previous = next((item for item in records if item.name == config.name), None)
if previous is None and expected_revision is not None:
raise FileNotFoundError("Model no longer exists")
if previous is not None and (expected_revision is None or previous.revision != expected_revision):
raise FileExistsError("Model changed or already exists; reload before saving")
secret = config.api_key if config.api_key is not None else (previous.api_key if previous else None)
saved = config.model_copy(update={"api_key": secret, "revision": uuid4().hex})
records = [saved if item.name == saved.name else item for item in records] if previous else [*records, saved]
payload = [{**item.model_dump(exclude={"api_key"}), "api_key": item.api_key.get_secret_value() if item.api_key else None} for item in records]
cipher = self._cipher(create=True)
self._write(self.path, cipher.encrypt(json.dumps(payload).encode("utf-8")))
return saved
@staticmethod
def _write(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as handle:
temporary = Path(handle.name)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def merge_managed_models(config: AppConfig) -> AppConfig:
"""Return a fresh effective snapshot on changes; never mutate an active run."""
global _cache
store = ManagedModelStore()
signature = (str(store.path), get_config_signature(store.path), get_config_signature(store.key_path))
if signature[1] is None:
return config
with _lock:
if _cache is not None and _cache[0] is config and _cache[1] == signature:
return _cache[2]
yaml_names = {item.name for item in config.models}
managed = [item.runtime_config() for item in store.list() if item.enabled and item.name not in yaml_names]
result = config.model_copy(update={"models": [*config.models, *managed]})
result._models_by_name = {**config._models_by_name, **{item.name: item for item in managed}}
result._managed_model_names = {item.name for item in managed}
_cache = (config, signature, result)
return result

View File

@ -28,3 +28,26 @@ while handing off to the wait queue.
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
- `cumulative_stream_usage` is an opt-in model setting (default `false`) for endpoints that repeat cumulative token totals on each streaming chunk. The provider converts snapshots to deltas only when a stable completion id is present, isolates interleaved streams by id, and leaves the original usage untouched otherwise. Per-model tracking is lock-protected and cleared on the trailing empty-`choices` frame whether or not that frame carries usage. A soft cap of 1024 ids evicts only entries idle for at least one hour; active streams may temporarily exceed the cap so eviction cannot corrupt their deltas. Regression coverage lives in `tests/test_vllm_provider.py`.
### Managed shared models (`config/managed_models.py`)
`ManagedModelStore` persists a Fernet-encrypted catalog plus its generated local key
under `runtime_home()/managed-models`. Files are atomically replaced with temporary
file permissions; complete read/modify/write transactions hold the process lock and
cross-process sidecar lock. Missing keys and invalid catalogs fail closed. Backups
and shared deployments must include both files. SQL storage does not replicate this
catalog. Admin-supplied endpoints can address local providers; only trusted admins
may create or probe them.
`get_app_config()` and `reload_app_config()` merge enabled managed models after YAML
profiles, with YAML names winning conflicts. A cached effective snapshot uses the
base config identity and content signatures of both files. Never mutate a previously
returned AppConfig: runtime-scoped and explicitly injected configurations remain
authoritative. `_managed_model_names` is private source metadata, not provider kwargs.
Direct `AppConfig.from_file()` continues to read only operator configuration.
The MVP uses only the registered `langchain_openai:ChatOpenAI` adapter. Full updates
require the current revision; omission means create, an omitted API key retains the
saved key and an empty string clears it. Never serialize SecretStr masking as a saved
key. Read APIs return `has_api_key`, never a credential. Tests live in
`tests/test_managed_models.py` and `tests/blocking_io/test_managed_models.py`.

View File

@ -0,0 +1,23 @@
"""Managed model encryption, persistence and config merging stay off the loop."""
from types import SimpleNamespace
import pytest
from app.gateway.routers import managed_models as router
from deerflow.config.app_config import AppConfig
from deerflow.config.managed_models import ManagedModel
@pytest.mark.asyncio
async def test_admin_catalog_round_trip_offloads_storage(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
monkeypatch.setattr(router, "get_app_config", lambda: config)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
body = router.SaveModelRequest(config=ManagedModel(name="test", model="test", base_url="https://example.com/v1", api_key="secret"))
result = await router.save_model(request, body)
assert result["has_api_key"] is True
catalog = await router.list_managed_models(request)
assert catalog["models"][0]["name"] == "test"
assert "secret" not in str(catalog)

View File

@ -0,0 +1,212 @@
"""Managed models: persistence, snapshot resolution and administrator boundaries."""
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from deerflow.config.app_config import AppConfig
from deerflow.config.managed_models import ManagedModel, ManagedModelStore, merge_managed_models
def profile(**kwargs):
return ManagedModel(name="managed-test", model="test-model", base_url="https://example.com/v1", api_key="test-secret", **kwargs)
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
return ManagedModelStore()
def test_persist_encrypt_preserve_secret_and_revision(store):
with pytest.raises(FileNotFoundError):
store.save(profile(), expected_revision="missing")
first = store.save(profile(), expected_revision=None)
assert b"test-secret" not in store.path.read_bytes()
assert ManagedModelStore().list()[0].api_key.get_secret_value() == "test-secret"
updated = store.save(profile(enabled=False).model_copy(update={"api_key": None}), expected_revision=first.revision)
assert updated.api_key.get_secret_value() == "test-secret"
assert updated.revision != first.revision
with pytest.raises(FileExistsError):
store.save(profile(), expected_revision=first.revision)
with pytest.raises(FileExistsError):
store.save(profile(), expected_revision=None)
def test_snapshot_merge_yaml_precedence_and_disable(store):
base = AppConfig.model_validate({"sandbox": {"use": "test"}, "models": [{"name": "yaml", "model": "yaml", "use": "test"}]})
first = store.save(profile(), expected_revision=None)
merged = merge_managed_models(base)
assert [m.name for m in merged.models] == ["yaml", "managed-test"]
assert merged.get_model_config("managed-test").api_key == "test-secret"
assert base.get_model_config("managed-test") is None
store.save(profile(enabled=False), expected_revision=first.revision)
assert merge_managed_models(base).get_model_config("managed-test") is None
assert merged.get_model_config("managed-test") is not None
store.save(profile().model_copy(update={"name": "yaml"}), expected_revision=None)
assert merge_managed_models(base).get_model_config("yaml").use == "test"
def test_missing_encryption_key_never_replaced(store):
store.save(profile(), expected_revision=None)
store.key_path.unlink()
with pytest.raises(ValueError, match="key"):
store.list()
assert not store.key_path.exists()
@pytest.mark.parametrize("url", ["file:///tmp/test", "https://user:pass@example.com/v1", "https://example.com/v1?key=abc", "https://example.com/#fragment"])
def test_endpoint_validation(url):
with pytest.raises(ValidationError):
ManagedModel(name="test", model="test", base_url=url)
@pytest.mark.asyncio
async def test_admin_gate_and_response_redaction(store, monkeypatch):
from app.gateway.routers import managed_models as router
config = AppConfig.model_validate({"sandbox": {"use": "test"}})
monkeypatch.setattr(router, "get_app_config", lambda: config)
admin = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
member = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="user")))
body = router.SaveModelRequest(config=profile())
with pytest.raises(HTTPException) as exc:
await router.save_model(member, body)
assert exc.value.status_code == 403
result = await router.save_model(admin, body)
assert result["has_api_key"] is True
assert "api_key" not in result
assert "test-secret" not in str(await router.list_managed_models(admin))
with pytest.raises(HTTPException) as exc:
await router.list_managed_models(member)
assert exc.value.status_code == 403
def test_config_loader_sees_changes_without_yaml_write(store, tmp_path, monkeypatch):
from deerflow.config.app_config import get_app_config, reset_app_config
path = tmp_path / "config.yaml"
original = "sandbox:\n use: test\nmodels: []\n"
path.write_text(original, encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(path))
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", raising=False)
reset_app_config()
try:
assert not get_app_config().models
saved = store.save(profile(), expected_revision=None)
snapshot = get_app_config()
assert snapshot.get_model_config("managed-test").model == "test-model"
assert get_app_config() is snapshot
store.save(profile(enabled=False), expected_revision=saved.revision)
assert not get_app_config().models
assert snapshot.get_model_config("managed-test") is not None
assert path.read_text(encoding="utf-8") == original
finally:
reset_app_config()
def test_parallel_writes_preserve_all_models(store):
from concurrent.futures import ThreadPoolExecutor
def save(index):
store.save(profile().model_copy(update={"name": f"model-{index}"}), expected_revision=None)
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(save, range(16)))
assert len(store.list()) == 16
def test_clear_key_and_corrupt_catalog_fail_closed(store):
from pydantic import SecretStr
previous = store.save(profile(), expected_revision=None)
saved = store.save(profile().model_copy(update={"api_key": SecretStr("")}), expected_revision=previous.revision)
assert saved.public()["has_api_key"] is False
assert store.list()[0].runtime_config().api_key == "not-required"
store.path.write_bytes(b"broken")
with pytest.raises(ValueError):
store.save(profile(), expected_revision=None)
assert store.path.read_bytes() == b"broken"
@pytest.mark.asyncio
async def test_yaml_name_reserved_and_pat_denied(store, monkeypatch):
from app.gateway.auth_disabled import AUTH_SOURCE_PAT
from app.gateway.routers import managed_models as router
config = AppConfig.model_validate({"sandbox": {"use": "test"}, "models": [{"name": "managed-test", "model": "yaml", "use": "test"}]})
monkeypatch.setattr(router, "get_app_config", lambda: config)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
body = router.SaveModelRequest(config=profile())
with pytest.raises(HTTPException) as exc:
await router.save_model(request, body)
assert exc.value.status_code == 409
request.state.auth_source = AUTH_SOURCE_PAT
for operation in (router.save_model, router.test_model):
with pytest.raises(HTTPException) as exc:
await operation(request, body)
assert exc.value.status_code == 403
assert not store.path.exists()
@pytest.mark.asyncio
@pytest.mark.parametrize("mode,expected", [("tool", "success"), ("text", "tool_call_missing"), ("error", "connection_failed")])
async def test_connection_probe_is_bounded_redacted_and_does_not_save(store, monkeypatch, mode, expected):
import langchain_openai
from langchain_core.messages import AIMessageChunk
from app.gateway.routers import managed_models as router
captured = {}
class FakeModel:
def __init__(self, **kwargs):
captured.update(kwargs)
def bind_tools(self, tools, **kwargs):
assert kwargs["tool_choice"] == "connection_check"
return self
async def astream(self, *args, **kwargs):
if mode == "error":
raise RuntimeError("test-secret should never be returned")
yield AIMessageChunk(content="", tool_call_chunks=[{"name": "connection_check", "args": "{}", "id": "call-1", "index": 0}] if mode == "tool" else [])
monkeypatch.setattr(langchain_openai, "ChatOpenAI", FakeModel)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
result = await router.test_model(request, router.SaveModelRequest(config=profile()))
assert result["message"] == expected
assert "test-secret" not in str(result)
assert captured["max_retries"] == 0
assert captured["timeout"] == 15
assert not store.path.exists()
def test_separate_process_writers_share_catalog_lock(store):
import os
import subprocess
import sys
script = """
import sys
from deerflow.config.managed_models import ManagedModelStore, ManagedModel
store = ManagedModelStore()
for index in range(5):
store.save(ManagedModel(name=f'{sys.argv[1]}-{index}', model='test', base_url='https://example.com/v1', api_key='secret'), expected_revision=None)
"""
processes = [subprocess.Popen([sys.executable, "-c", script, str(index)], env=os.environ.copy(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) for index in range(3)]
try:
for process in processes:
output, errors = process.communicate(timeout=30)
assert process.returncode == 0, (output, errors)
finally:
for process in processes:
if process.poll() is None:
process.kill()
process.wait()
assert len(store.list()) == 15
if os.name == "posix":
assert store.path.stat().st_mode & 0o777 == 0o600
assert store.key_path.stat().st_mode & 0o777 == 0o600

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@ -247,3 +247,13 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
### Shared model settings
Settings → Models (`?settings=models`) offers administrator-only catalog management
through `/api/managed-models`. YAML entries are read-only. `core/models/management.ts`
whitelists editable fields so source metadata and `has_api_key` never get posted.
Draft credentials stay in editor state, never query cache or browser storage; blank
keeps the saved key, explicit removal sends an empty key. Saving invalidates both the
admin catalog and `MODELS_QUERY_KEY`. Editor unmount aborts probes and fences late
callbacks. Static demos and non-admin users must not query the management API.

View File

@ -0,0 +1,377 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { MODELS_QUERY_KEY } from "@/core/models/hooks";
import {
loadManagedModels,
modelDraft,
saveManagedModel,
testManagedModel,
type ManagedModel,
} from "@/core/models/management";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { SettingsSection } from "./settings-section";
export function ModelSettingsPage() {
const { user } = useAuth();
const { t } = useI18n();
const text = t.settings.models;
const client = useQueryClient();
const canManage = user?.system_role === "admin" && !isStaticWebsiteOnly();
const queryKey = ["managed-models", user?.id];
const catalog = useQuery({
queryKey,
queryFn: ({ signal }) => loadManagedModels(signal),
enabled: canManage,
});
const [editing, setEditing] = useState<ManagedModel | "new" | null>(null);
const [pending, setPending] = useState(false);
async function refresh() {
await Promise.all([
client.invalidateQueries({ queryKey: ["managed-models"] }),
client.invalidateQueries({ queryKey: MODELS_QUERY_KEY }),
]);
}
async function toggle(model: ManagedModel) {
setPending(true);
try {
await saveManagedModel({
config: { ...modelDraft(model), enabled: !model.enabled },
expected_revision: model.revision,
});
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : text.failed);
} finally {
setPending(false);
}
}
return (
<SettingsSection title={text.title} description={text.description}>
{!canManage ? (
<p>{text.adminOnly}</p>
) : (
<div className="space-y-4">
<div className="flex gap-2">
<Button disabled={pending} onClick={() => setEditing("new")}>
{text.add}
</Button>
<Button
variant="outline"
disabled={pending || catalog.isFetching}
onClick={() => void catalog.refetch()}
>
{text.reload}
</Button>
</div>
{catalog.isLoading && <p role="status">{text.loading}</p>}
{catalog.error && (
<div role="alert">
<p>{text.failed}</p>
</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.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>
))}
{editing && (
<ModelEditor
key={`${user?.id}:${editing === "new" ? "new" : editing.name}`}
model={editing === "new" ? undefined : editing}
close={() => setEditing(null)}
saved={refresh}
/>
)}
</div>
)}
</SettingsSection>
);
}
function ModelEditor({
model,
close,
saved,
}: {
model?: ManagedModel;
close: () => void;
saved: () => Promise<void>;
}) {
const { t } = useI18n();
const text = t.settings.models;
const [draft, setDraft] = useState(() => modelDraft(model));
const [key, setKey] = useState("");
const [clearKey, setClearKey] = useState(false);
const [pending, setPending] = useState(false);
const [result, setResult] = useState("");
const active = useRef(true);
const abort = useRef<AbortController | null>(null);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
abort.current?.abort();
};
}, []);
function body() {
return {
config: {
...draft,
...(clearKey ? { api_key: "" } : key ? { api_key: key } : {}),
},
expected_revision: model?.revision ?? null,
};
}
async function submit(test: boolean) {
setPending(true);
setResult("");
try {
if (test) {
abort.current = new AbortController();
const response = await testManagedModel(body(), abort.current.signal);
if (active.current) setResult(text[response.message]);
} else {
await saveManagedModel(body());
await saved();
if (active.current) {
toast.success(text.saved);
close();
}
}
} catch (error) {
if (active.current)
setResult(error instanceof Error ? error.message : text.failed);
} finally {
if (active.current) setPending(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) close();
}}
>
<DialogContent className="flex h-[90vh] flex-col overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{model ? text.edit : text.add}</DialogTitle>
<DialogDescription>{text.formDescription}</DialogDescription>
</DialogHeader>
<form
className="flex min-h-0 flex-1 flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
void submit(false);
}}
>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<fieldset disabled={pending} className="space-y-3">
<p className="text-sm">{text.provider}: OpenAI compatible</p>
<label className="block space-y-1">
<span>{text.name}</span>
<Input
required
pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,99}"
value={draft.name}
disabled={!!model}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</label>
<label className="block space-y-1">
<span>{text.displayName}</span>
<Input
maxLength={100}
value={draft.display_name}
onChange={(e) =>
setDraft({ ...draft, display_name: e.target.value })
}
/>
</label>
<label className="block space-y-1">
<span>{text.endpoint}</span>
<Input
type="url"
required
placeholder="https://api.example.com/v1"
value={draft.base_url}
onChange={(e) => {
setDraft({ ...draft, base_url: e.target.value });
setResult("");
}}
/>
</label>
<label className="block space-y-1">
<span>{text.modelId}</span>
<Input
required
maxLength={200}
value={draft.model}
onChange={(e) => {
setDraft({ ...draft, model: e.target.value });
setResult("");
}}
/>
</label>
<label className="block space-y-1">
<span>API Key</span>
<Input
type="password"
autoComplete="new-password"
disabled={clearKey}
value={key}
placeholder={
model?.has_api_key ? text.keepKey : text.optionalKey
}
onChange={(e) => {
setKey(e.target.value);
setResult("");
}}
/>
</label>
{model?.has_api_key && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={clearKey}
onChange={(e) => {
setClearKey(e.target.checked);
setKey("");
setResult("");
}}
/>
{text.clearKey}
</label>
)}
<label className="block space-y-1">
<span>{text.contextWindow}</span>
<Input
type="number"
min={1}
step={1}
value={draft.context_window ?? ""}
onChange={(e) =>
setDraft({
...draft,
context_window: e.target.value
? Number(e.target.value)
: null,
})
}
/>
</label>
<label className="block space-y-1">
<span>{text.maxTokens}</span>
<Input
type="number"
min={1}
step={1}
value={draft.max_tokens ?? ""}
onChange={(e) =>
setDraft({
...draft,
max_tokens: e.target.value
? Number(e.target.value)
: null,
})
}
/>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={draft.supports_vision}
onChange={(e) =>
setDraft({ ...draft, supports_vision: e.target.checked })
}
/>
{text.vision}
</label>
</fieldset>
</div>
{result && (
<p role="status" className="text-sm">
{result}
</p>
)}
<DialogFooter className="shrink-0">
<Button
type="button"
variant="outline"
disabled={pending}
onClick={close}
>
{text.cancel}
</Button>
<Button
type="button"
variant="outline"
disabled={
pending || !draft.name || !draft.model || !draft.base_url
}
onClick={() => void submit(true)}
>
{text.test}
</Button>
<Button type="submit" disabled={pending}>
{pending ? text.working : text.save}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import {
BotIcon,
BellIcon,
CableIcon,
InfoIcon,
@ -72,6 +73,11 @@ const SubagentSettingsPage = dynamic(
),
{ loading: SettingsPageLoading },
);
const ModelSettingsPage = dynamic(
() =>
import("./model-settings-page").then((module) => module.ModelSettingsPage),
{ loading: SettingsPageLoading },
);
const AboutSettingsPage = dynamic(
() =>
import("./about-settings-page").then((module) => module.AboutSettingsPage),
@ -79,6 +85,7 @@ const AboutSettingsPage = dynamic(
);
export type SettingsSection =
| "models"
| "account"
| "appearance"
| "channels"
@ -107,6 +114,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
const sections = useMemo(
() => [
{ id: "models", label: t.settings.sections.models, icon: BotIcon },
{
id: "account",
label: t.settings.sections.account,
@ -140,6 +148,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
t.settings.sections.models,
t.settings.sections.account,
t.settings.sections.appearance,
t.settings.sections.channels,
@ -191,6 +200,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
</nav>
<ScrollArea className="h-full min-h-0 rounded-lg border">
<div className="space-y-8 p-6">
{activeSection === "models" && <ModelSettingsPage />}
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}

View File

@ -10,6 +10,7 @@ import {
} from "./settings";
const SETTINGS_SECTIONS = new Set<SettingsSection>([
"models",
"account",
"appearance",
"channels",

View File

@ -1209,7 +1209,50 @@ export const enUS: Translations = {
settings: {
title: "Settings",
description: "Adjust how DeerFlow looks and behaves for you.",
models: {
title: "Models",
description:
"Manage shared models available to users. Models from the server configuration are read-only.",
adminOnly:
"Only administrators can manage shared models. This feature is unavailable in demos.",
add: "Add model",
loading: "Loading models…",
failed: "Could not complete the request.",
reload: "Reload",
empty: "No models configured.",
yaml: "Server configuration · read-only",
enabled: "Enabled",
disabled: "Disabled",
conflict: "This name is reserved by the server configuration.",
edit: "Edit model",
enable: "Enable",
disable: "Disable",
formDescription:
"Connect an OpenAI-compatible endpoint. Testing sends a short streaming tool-call request and may incur provider charges.",
provider: "Provider",
name: "Unique name",
displayName: "Display name",
endpoint: "Base URL",
modelId: "Model ID",
keepKey: "Leave blank to keep the saved key",
optionalKey: "Optional for endpoints without authentication",
clearKey: "Remove the saved API key",
contextWindow: "Context window (optional)",
maxTokens: "Maximum output tokens (optional)",
vision: "Supports image input",
cancel: "Cancel",
test: "Test connection",
working: "Working…",
save: "Save",
saved: "Model saved",
success: "Streaming and tool-call test passed.",
tool_call_missing:
"The endpoint responded, but did not return a tool call. Check the model’s tool support.",
connection_failed:
"Connection test failed. Check the endpoint, credentials, model ID and streaming/tool support.",
},
sections: {
models: "Models",
account: "Account",
appearance: "Appearance",
channels: "Channels",

View File

@ -1022,7 +1022,45 @@ export interface Translations {
settings: {
title: string;
description: string;
models: {
title: string;
description: string;
adminOnly: string;
add: string;
loading: string;
failed: string;
reload: string;
empty: string;
yaml: string;
enabled: string;
disabled: string;
conflict: string;
edit: string;
enable: string;
disable: string;
formDescription: string;
provider: string;
name: string;
displayName: string;
endpoint: string;
modelId: string;
keepKey: string;
optionalKey: string;
clearKey: string;
contextWindow: string;
maxTokens: string;
vision: string;
cancel: string;
test: string;
working: string;
save: string;
saved: string;
success: string;
tool_call_missing: string;
connection_failed: string;
};
sections: {
models: string;
account: string;
appearance: string;
channels: string;

View File

@ -1140,7 +1140,48 @@ export const zhCN: Translations = {
settings: {
title: "设置",
description: "根据你的偏好调整 DeerFlow 的界面和行为。",
models: {
title: "模型",
description: "管理供用户选择的共享模型。服务器配置中的模型为只读。",
adminOnly: "只有管理员可以管理共享模型,演示模式不支持此功能。",
add: "添加模型",
loading: "正在加载模型…",
failed: "请求未能完成。",
reload: "重新加载",
empty: "尚未配置模型。",
yaml: "服务器配置 · 只读",
enabled: "已启用",
disabled: "已停用",
conflict: "此名称已被服务器配置占用。",
edit: "编辑模型",
enable: "启用",
disable: "停用",
formDescription:
"接入 OpenAI 兼容接口。测试将发送简短的流式工具调用请求,可能产生模型调用费用。",
provider: "接口类型",
name: "唯一名称",
displayName: "显示名称",
endpoint: "接口基础地址",
modelId: "模型 ID",
keepKey: "留空以保留已保存的密钥",
optionalKey: "无认证接口可以留空",
clearKey: "移除已保存的 API Key",
contextWindow: "上下文窗口(可选)",
maxTokens: "最大输出 Token 数(可选)",
vision: "支持图片输入",
cancel: "取消",
test: "测试连接",
working: "处理中…",
save: "保存",
saved: "模型已保存",
success: "流式输出和工具调用测试通过。",
tool_call_missing:
"接口已响应,但未返回工具调用,请检查模型的工具调用能力。",
connection_failed:
"连接测试失败,请检查接口地址、凭据、模型 ID 以及流式输出和工具调用支持。",
},
sections: {
models: "模型",
account: "账号",
appearance: "外观",
channels: "渠道",

View File

@ -0,0 +1,89 @@
import { throwGatewayApiError } from "@/core/api/errors";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
export type ModelDraft = {
name: string;
display_name: string;
provider: "openai-compatible";
model: string;
base_url: string;
api_key?: string;
enabled: boolean;
supports_vision: boolean;
context_window: number | null;
max_tokens: number | null;
};
export type ManagedModel = Omit<ModelDraft, "api_key"> & {
revision: string;
source: "managed";
has_api_key: boolean;
conflict?: boolean;
};
export type ConfigModel = {
name: string;
display_name: string;
model: string;
source: "config";
enabled: boolean;
};
export type SaveModelRequest = {
config: ModelDraft;
expected_revision: string | null;
};
async function request<T>(
suffix: string,
method: string,
body?: SaveModelRequest,
signal?: AbortSignal,
): Promise<T> {
const response = await fetch(
`${getBackendBaseURL()}/api/managed-models${suffix}`,
{
method,
signal,
...(body
? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
: {}),
},
);
if (!response.ok)
await throwGatewayApiError(response, "Model management request failed");
return response.json() as Promise<T>;
}
export const loadManagedModels = (signal?: AbortSignal) =>
request<{ models: (ManagedModel | ConfigModel)[] }>(
"",
"GET",
undefined,
signal,
);
export const saveManagedModel = (body: SaveModelRequest) =>
request<ManagedModel>("", "PUT", body);
export const testManagedModel = (
body: SaveModelRequest,
signal?: AbortSignal,
) =>
request<{
ok: boolean;
message: "success" | "tool_call_missing" | "connection_failed";
}>("/test", "POST", body, signal);
export function modelDraft(model?: ManagedModel): ModelDraft {
return {
name: model?.name ?? "",
display_name: model?.display_name ?? "",
provider: "openai-compatible",
model: model?.model ?? "",
base_url: model?.base_url ?? "",
enabled: model?.enabled ?? true,
supports_vision: model?.supports_vision ?? false,
context_window: model?.context_window ?? null,
max_tokens: model?.max_tokens ?? null,
};
}

View File

@ -0,0 +1,85 @@
import { expect, test } from "@playwright/test";
import type { ManagedModel, SaveModelRequest } from "@/core/models/management";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
test("administrator adds, tests, edits and disables a shared model", async ({
page,
}, testInfo) => {
mockLangGraphAPI(page, { threads: [{ thread_id: MOCK_THREAD_ID }] });
let models: ManagedModel[] = [];
let probes = 0;
let catalogReads = 0;
const saves: SaveModelRequest[] = [];
await page.route("**/api/models", (route) => {
catalogReads++;
return route.fulfill({
json: {
models: models.filter((model) => model.enabled),
token_usage: { enabled: false },
},
});
});
await page.route("**/api/managed-models", async (route) => {
if (route.request().method() === "GET")
return route.fulfill({ json: { models } });
const body = route.request().postDataJSON() as SaveModelRequest;
saves.push(body);
const { api_key, ...config } = body.config;
models = [
{
...config,
source: "managed",
has_api_key:
api_key === undefined ? (models[0]?.has_api_key ?? false) : !!api_key,
revision: String(saves.length),
},
];
await route.fulfill({ json: models[0] });
});
await page.route("**/api/managed-models/test", (route) => {
probes++;
return route.fulfill({ json: { ok: true, message: "success" } });
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}?settings=models`);
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByLabel("Unique name").fill("demo-model");
await page.getByLabel("Display name").fill("Demo model");
await page.getByLabel("Base URL").fill("https://example.com/v1");
await page.getByLabel("Model ID").fill("demo");
await page.getByLabel("API Key", { exact: true }).fill("demo-key");
await page
.getByRole("button", { name: "Test connection", exact: true })
.click();
await expect(
page.getByText("Streaming and tool-call test passed."),
).toBeVisible();
expect(probes).toBe(1);
expect(saves).toHaveLength(0);
await page.screenshot({ path: testInfo.outputPath("model-editor.png") });
const readsBeforeSave = catalogReads;
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(
page
.getByRole("dialog", { name: "Settings", exact: true })
.getByText("Demo model", { exact: true }),
).toBeVisible();
await expect.poll(() => catalogReads).toBeGreaterThan(readsBeforeSave);
await page.getByRole("button", { name: "Edit model", exact: true }).click();
await expect(page.getByLabel("API Key", { exact: true })).toHaveValue("");
await page.getByLabel("Display name").fill("Updated model");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(
page
.getByRole("dialog", { name: "Settings", exact: true })
.getByText("Updated model", { exact: true }),
).toBeVisible();
expect(saves[1]?.config).not.toHaveProperty("api_key");
expect(saves[1]?.expected_revision).toBe("1");
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(
page.getByRole("button", { name: "Enable", exact: true }),
).toBeVisible();
expect(saves[2]?.config.enabled).toBe(false);
});

View File

@ -23,7 +23,7 @@ describe("interaction-only bundle boundaries", () => {
const dialog = read(
"src/components/workspace/settings/settings-dialog.tsx",
);
expect(dialog.match(/dynamic\(/g)).toHaveLength(7);
expect(dialog.match(/dynamic\(/g)).toHaveLength(8);
expect(dialog).not.toMatch(
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
);

View File

@ -0,0 +1,134 @@
import { afterEach, beforeEach, expect, test, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { ModelSettingsPage } from "@/components/workspace/settings/model-settings-page";
import { useAuth } from "@/core/auth/AuthProvider";
import { enUS } from "@/core/i18n/locales/en-US";
import type * as Management from "@/core/models/management";
import {
loadManagedModels,
modelDraft,
saveManagedModel,
testManagedModel,
type ManagedModel,
} from "@/core/models/management";
rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: rs.fn() }));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
rs.mock("@/core/models/management", () => ({
...rs.requireActual<typeof Management>("@/core/models/management"),
loadManagedModels: rs.fn(),
saveManagedModel: rs.fn(),
testManagedModel: rs.fn(),
}));
const existing: ManagedModel = {
...modelDraft(),
name: "custom",
model: "test",
base_url: "https://example.com/v1",
display_name: "Custom",
source: "managed",
has_api_key: true,
revision: "v1",
};
const auth = (role: "admin" | "user") =>
({ user: { id: "admin-id", system_role: role } }) as ReturnType<
typeof useAuth
>;
beforeEach(() => {
rs.mocked(useAuth).mockReturnValue(auth("admin"));
rs.mocked(loadManagedModels).mockResolvedValue({ models: [existing] });
rs.mocked(saveManagedModel).mockResolvedValue({
...existing,
revision: "v2",
});
});
afterEach(() => {
cleanup();
rs.clearAllMocks();
});
function mount() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["models"], { models: [] });
render(
<QueryClientProvider client={client}>
<ModelSettingsPage />
</QueryClientProvider>,
);
return client;
}
test("non-admin cannot load credentials configuration or add models", () => {
rs.mocked(useAuth).mockReturnValue(auth("user"));
mount();
expect(screen.queryByRole("button", { name: "Add model" })).toBeNull();
expect(loadManagedModels).not.toHaveBeenCalled();
});
test("editing preserves saved key and refreshes chat catalog after saving", async () => {
const client = mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
const input = screen.getByLabelText<HTMLInputElement>("API Key");
expect(input.value).toBe("");
fireEvent.change(screen.getByLabelText("Display name"), {
target: { value: "Updated" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]?.[0]).toMatchObject({
config: { display_name: "Updated" },
expected_revision: "v1",
});
expect(
rs.mocked(saveManagedModel).mock.calls[0]?.[0].config,
).not.toHaveProperty("api_key");
await waitFor(() =>
expect(client.getQueryState(["models"])?.isInvalidated).toBe(true),
);
});
test("failed save keeps draft open and displays conflict", async () => {
rs.mocked(saveManagedModel).mockRejectedValueOnce(
new Error("Model changed; reload before saving"),
);
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.change(screen.getByLabelText<HTMLInputElement>("API Key"), {
target: { value: "replacement" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(
await screen.findByText("Model changed; reload before saving"),
).toBeTruthy();
expect(screen.getByLabelText<HTMLInputElement>("API Key").value).toBe(
"replacement",
);
});
test("connection test never saves and supports explicit key removal", async () => {
rs.mocked(testManagedModel).mockResolvedValueOnce({
ok: true,
message: "success",
});
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.click(screen.getByLabelText("Remove the saved API key"));
fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
expect(await screen.findByText(enUS.settings.models.success)).toBeTruthy();
expect(rs.mocked(testManagedModel).mock.calls[0]?.[0].config.api_key).toBe(
"",
);
expect(saveManagedModel).not.toHaveBeenCalled();
});