diff --git a/README.md b/README.md index 8816f8d9b..0eef072f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 4b9e8c4a7..a6d36713f 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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 | diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 4a7e95fb0..f1097e588 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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 diff --git a/backend/app/gateway/routers/managed_models.py b/backend/app/gateway/routers/managed_models.py new file mode 100644 index 000000000..9ab72ead1 --- /dev/null +++ b/backend/app/gateway/routers/managed_models.py @@ -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"} diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index d592c6670..639a4ecc0 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 02f0cdcba..3982bed23 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -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: diff --git a/backend/packages/harness/deerflow/config/managed_models.py b/backend/packages/harness/deerflow/config/managed_models.py new file mode 100644 index 000000000..6771ea406 --- /dev/null +++ b/backend/packages/harness/deerflow/config/managed_models.py @@ -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 diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 2bf3fbf53..e61ebdf1e 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -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`. diff --git a/backend/tests/blocking_io/test_managed_models.py b/backend/tests/blocking_io/test_managed_models.py new file mode 100644 index 000000000..da7b2c949 --- /dev/null +++ b/backend/tests/blocking_io/test_managed_models.py @@ -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) diff --git a/backend/tests/test_managed_models.py b/backend/tests/test_managed_models.py new file mode 100644 index 000000000..519db0b3a --- /dev/null +++ b/backend/tests/test_managed_models.py @@ -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 diff --git a/docs/pr-evidence/model-management.png b/docs/pr-evidence/model-management.png new file mode 100644 index 000000000..4093841be Binary files /dev/null and b/docs/pr-evidence/model-management.png differ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d39c9a27e..2d6f92529 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. + +### 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. diff --git a/frontend/src/components/workspace/settings/model-settings-page.tsx b/frontend/src/components/workspace/settings/model-settings-page.tsx new file mode 100644 index 000000000..e79c7621e --- /dev/null +++ b/frontend/src/components/workspace/settings/model-settings-page.tsx @@ -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(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 ( + + {!canManage ? ( +

{text.adminOnly}

+ ) : ( +
+
+ + +
+ {catalog.isLoading &&

{text.loading}

} + {catalog.error && ( +
+

{text.failed}

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

{text.empty}

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

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

+

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

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

{text.conflict}

+ )} +
+ {model.source === "managed" && ( +
+ + +
+ )} +
+ ))} + {editing && ( + setEditing(null)} + saved={refresh} + /> + )} +
+ )} +
+ ); +} + +function ModelEditor({ + model, + close, + saved, +}: { + model?: ManagedModel; + close: () => void; + saved: () => Promise; +}) { + 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(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 ( + { + if (!open && !pending) close(); + }} + > + + + {model ? text.edit : text.add} + {text.formDescription} + +
{ + event.preventDefault(); + void submit(false); + }} + > +
+
+

{text.provider}: OpenAI compatible

+ + + + + + {model?.has_api_key && ( + + )} + + + +
+
+ {result && ( +

+ {result} +

+ )} + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index 2f454db96..355ea3420 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -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) {
+ {activeSection === "models" && } {activeSection === "account" && } {activeSection === "appearance" && } {activeSection === "memory" && } diff --git a/frontend/src/components/workspace/workspace-settings-deep-link.tsx b/frontend/src/components/workspace/workspace-settings-deep-link.tsx index deda4936a..90bcf69f2 100644 --- a/frontend/src/components/workspace/workspace-settings-deep-link.tsx +++ b/frontend/src/components/workspace/workspace-settings-deep-link.tsx @@ -10,6 +10,7 @@ import { } from "./settings"; const SETTINGS_SECTIONS = new Set([ + "models", "account", "appearance", "channels", diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index de0f2cafc..23737fb4a 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index c7c0d3adc..eb5a96f6b 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 127d09c08..ef478e388 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "渠道", diff --git a/frontend/src/core/models/management.ts b/frontend/src/core/models/management.ts new file mode 100644 index 000000000..e68fd4284 --- /dev/null +++ b/frontend/src/core/models/management.ts @@ -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 & { + 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( + suffix: string, + method: string, + body?: SaveModelRequest, + signal?: AbortSignal, +): Promise { + 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; +} + +export const loadManagedModels = (signal?: AbortSignal) => + request<{ models: (ManagedModel | ConfigModel)[] }>( + "", + "GET", + undefined, + signal, + ); +export const saveManagedModel = (body: SaveModelRequest) => + request("", "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, + }; +} diff --git a/frontend/tests/e2e/model-management.spec.ts b/frontend/tests/e2e/model-management.spec.ts new file mode 100644 index 000000000..508d98d35 --- /dev/null +++ b/frontend/tests/e2e/model-management.spec.ts @@ -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); +}); diff --git a/frontend/tests/unit/components/workspace/lazy-panels.test.ts b/frontend/tests/unit/components/workspace/lazy-panels.test.ts index 3c7097c9e..17cdd1c56 100644 --- a/frontend/tests/unit/components/workspace/lazy-panels.test.ts +++ b/frontend/tests/unit/components/workspace/lazy-panels.test.ts @@ -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\//, ); diff --git a/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx new file mode 100644 index 000000000..91634e3bc --- /dev/null +++ b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx @@ -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("@/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( + + + , + ); + 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("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("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("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(); +});