mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
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:
parent
4e8e2ce691
commit
8ef58eaa90
25
README.md
25
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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
94
backend/app/gateway/routers/managed_models.py
Normal file
94
backend/app/gateway/routers/managed_models.py
Normal 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"}
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
147
backend/packages/harness/deerflow/config/managed_models.py
Normal file
147
backend/packages/harness/deerflow/config/managed_models.py
Normal 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
|
||||
@ -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`.
|
||||
|
||||
23
backend/tests/blocking_io/test_managed_models.py
Normal file
23
backend/tests/blocking_io/test_managed_models.py
Normal 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)
|
||||
212
backend/tests/test_managed_models.py
Normal file
212
backend/tests/test_managed_models.py
Normal 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
|
||||
BIN
docs/pr-evidence/model-management.png
Normal file
BIN
docs/pr-evidence/model-management.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "./settings";
|
||||
|
||||
const SETTINGS_SECTIONS = new Set<SettingsSection>([
|
||||
"models",
|
||||
"account",
|
||||
"appearance",
|
||||
"channels",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "渠道",
|
||||
|
||||
89
frontend/src/core/models/management.ts
Normal file
89
frontend/src/core/models/management.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
frontend/tests/e2e/model-management.spec.ts
Normal file
85
frontend/tests/e2e/model-management.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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\//,
|
||||
);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user