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
|
DeerFlow disables Console cost estimates when currencies are mixed rather
|
||||||
than presenting an invalid aggregate.
|
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
|
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
|
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
|
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 |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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
|
# Include routers
|
||||||
# Models API is mounted at /api/models
|
# 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)
|
app.include_router(models.router)
|
||||||
|
|
||||||
# Features API is mounted at /api/features
|
# 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.
|
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.
|
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``
|
# ``_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
|
# / ``get_tool_group_config`` O(1) instead of an O(n) ``next(...)`` scan per
|
||||||
# call. Private attrs are excluded from serialization.
|
# 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)
|
_models_by_name: dict[str, ModelConfig] = PrivateAttr(default_factory=dict)
|
||||||
_tools_by_name: dict[str, ToolConfig] = 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)
|
_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:
|
elif _app_config_path == resolved_path and _app_config_signature != current_signature:
|
||||||
logger.info("Config file content signature changed, reloading AppConfig")
|
logger.info("Config file content signature changed, reloading AppConfig")
|
||||||
_load_and_cache_app_config(str(resolved_path))
|
_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:
|
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:
|
Returns:
|
||||||
The newly loaded AppConfig instance.
|
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:
|
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
|
- 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
|
- 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`.
|
- `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.
|
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 -->
|
<!-- 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";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BotIcon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
CableIcon,
|
CableIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@ -72,6 +73,11 @@ const SubagentSettingsPage = dynamic(
|
|||||||
),
|
),
|
||||||
{ loading: SettingsPageLoading },
|
{ loading: SettingsPageLoading },
|
||||||
);
|
);
|
||||||
|
const ModelSettingsPage = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./model-settings-page").then((module) => module.ModelSettingsPage),
|
||||||
|
{ loading: SettingsPageLoading },
|
||||||
|
);
|
||||||
const AboutSettingsPage = dynamic(
|
const AboutSettingsPage = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import("./about-settings-page").then((module) => module.AboutSettingsPage),
|
import("./about-settings-page").then((module) => module.AboutSettingsPage),
|
||||||
@ -79,6 +85,7 @@ const AboutSettingsPage = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type SettingsSection =
|
export type SettingsSection =
|
||||||
|
| "models"
|
||||||
| "account"
|
| "account"
|
||||||
| "appearance"
|
| "appearance"
|
||||||
| "channels"
|
| "channels"
|
||||||
@ -107,6 +114,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
|
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{ id: "models", label: t.settings.sections.models, icon: BotIcon },
|
||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
label: t.settings.sections.account,
|
label: t.settings.sections.account,
|
||||||
@ -140,6 +148,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
t.settings.sections.models,
|
||||||
t.settings.sections.account,
|
t.settings.sections.account,
|
||||||
t.settings.sections.appearance,
|
t.settings.sections.appearance,
|
||||||
t.settings.sections.channels,
|
t.settings.sections.channels,
|
||||||
@ -191,6 +200,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
<ScrollArea className="h-full min-h-0 rounded-lg border">
|
<ScrollArea className="h-full min-h-0 rounded-lg border">
|
||||||
<div className="space-y-8 p-6">
|
<div className="space-y-8 p-6">
|
||||||
|
{activeSection === "models" && <ModelSettingsPage />}
|
||||||
{activeSection === "account" && <AccountSettingsPage />}
|
{activeSection === "account" && <AccountSettingsPage />}
|
||||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||||
{activeSection === "memory" && <MemorySettingsPage />}
|
{activeSection === "memory" && <MemorySettingsPage />}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from "./settings";
|
} from "./settings";
|
||||||
|
|
||||||
const SETTINGS_SECTIONS = new Set<SettingsSection>([
|
const SETTINGS_SECTIONS = new Set<SettingsSection>([
|
||||||
|
"models",
|
||||||
"account",
|
"account",
|
||||||
"appearance",
|
"appearance",
|
||||||
"channels",
|
"channels",
|
||||||
|
|||||||
@ -1209,7 +1209,50 @@ export const enUS: Translations = {
|
|||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
description: "Adjust how DeerFlow looks and behaves for you.",
|
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: {
|
sections: {
|
||||||
|
models: "Models",
|
||||||
account: "Account",
|
account: "Account",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
channels: "Channels",
|
channels: "Channels",
|
||||||
|
|||||||
@ -1022,7 +1022,45 @@ export interface Translations {
|
|||||||
settings: {
|
settings: {
|
||||||
title: string;
|
title: string;
|
||||||
description: 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: {
|
sections: {
|
||||||
|
models: string;
|
||||||
account: string;
|
account: string;
|
||||||
appearance: string;
|
appearance: string;
|
||||||
channels: string;
|
channels: string;
|
||||||
|
|||||||
@ -1140,7 +1140,48 @@ export const zhCN: Translations = {
|
|||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
description: "根据你的偏好调整 DeerFlow 的界面和行为。",
|
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: {
|
sections: {
|
||||||
|
models: "模型",
|
||||||
account: "账号",
|
account: "账号",
|
||||||
appearance: "外观",
|
appearance: "外观",
|
||||||
channels: "渠道",
|
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(
|
const dialog = read(
|
||||||
"src/components/workspace/settings/settings-dialog.tsx",
|
"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(
|
expect(dialog).not.toMatch(
|
||||||
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
|
/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