feat(models): register managed models with provider presets (#6757)

* feat(models): register managed models with provider presets

* docs: add PR 6757 UI comparison screenshots

* test: fix model management CI checks

* docs: remove PR 6757 screenshots

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Eilen Shin 2026-10-13 08:14:23 +08:00 committed by GitHub
parent 8319e3384b
commit e98179a9d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1729 additions and 195 deletions

View File

@ -228,22 +228,29 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
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.
Official DeepSeek models at `https://api.deepseek.com` or
`https://api.deepseek.com/v1` (default HTTPS port) automatically use DeerFlow's
DeepSeek adapter, preserving reasoning content across tool calls and honoring
output token limits. Chat uses the selected thinking mode; the connection test
temporarily disables thinking because DeepSeek rejects forced tool selection
in thinking mode. The test checks streaming tool connectivity, not every agent
workflow or thinking-mode behavior. Existing saved DeepSeek profiles receive
this adapter without re-entering credentials. DeepSeek-specific settings for
third-party proxies, other native adapters, and advanced reasoning settings
remain YAML-configured.
Administrators can open **Settings → Models** to select a provider and enter
only its **API Key** and **model ID**. Endpoints, authentication and adapters are
backend presets for OpenAI, Anthropic, DeepSeek, Alibaba Cloud Bailian/Qwen,
Volcengine/Doubao, Moonshot/Kimi, Zhipu/Z.AI and MiniMax. Region and plan-specific
entries are explicitly distinguished. Arbitrary model IDs are accepted; internal
names are generated automatically and remain stable when a model ID changes.
Newly saved models are enabled and immediately refresh existing chat and Agent
model selectors, without editing YAML or restarting the Gateway.
**Custom OpenAI-compatible** still supports existing/custom base URLs and
unauthenticated endpoints. **Advanced settings** retains display name, token
limits and vision overrides; unknown IDs do not receive invented capabilities.
Leave the key blank on edit to retain it for the same connection. Switching
provider or custom endpoint requires a new key, and preset models must be
disabled before clearing their key. YAML models remain read-only.
Connection testing uses the same model factory and protocol as chat, sends a
bounded streaming tool-call request and never saves the draft. It may incur
provider charges and does not verify every agent, vision or thinking workflow.
Official DeepSeek custom endpoints retain their existing adapter; a proxy does
not become DeepSeek merely because of its model ID. See the
[Web model registration reference](backend/docs/CONFIGURATION.md#models-registered-through-the-web)
for presets, compatibility, extension instructions and backup requirements.
DeepSeek regression tests run offline with the normal backend suite. To verify
the real provider explicitly, set `DEEPSEEK_TEST_API_KEY` in your environment

View File

@ -101,6 +101,25 @@ DeerFlow 新近集成了 BytePlus 自研的智能搜索与抓取工具集——[
这条提示词是给 coding agent 用的。它会在需要时先 clone 仓库,优先选择 Docker,完成初始化,并在结束时告诉你下一条启动命令,以及还缺哪些配置需要你补充。
### 通过 Web 注册共享模型
管理员可在 **设置 → 模型 → 添加模型** 中选择厂商,仅填写 **API Key** 和
**模型 ID**。后端预置 OpenAI、Anthropic、DeepSeek、百炼/通义千问、火山/豆包、
Moonshot/Kimi、智谱/Z.AI 和 MiniMax 的端点、认证与适配器,地域和 Coding Plan
使用明确区分的选项。模型 ID 不受内置枚举限制;内部名称自动生成且保持稳定。
新模型默认启用,保存后自动刷新聊天及 Agent 的模型选择器,无需重启 Gateway。
已有地址继续通过 **自定义 OpenAI-compatible** 查看、编辑和使用,也支持无认证
接口。高级设置保留显示名称、上下文窗口、输出限制和视觉能力覆盖;未知模型不
自动声明能力。编辑时 API Key 留空表示保留同一连接的密钥,切换厂商或地址须
重新填写。清除预设模型密钥前应先停用模型。连接测试不保存草稿,并使用与聊天
相同的协议及模型工厂;真实请求可能产生费用。
YAML 同名模型优先,已有任务保留原配置快照。托管模型使用原有加密目录,必须
备份整个 `$DEER_FLOW_HOME/managed-models/`,同时保存匹配的 `catalog.enc` 和
`key`;丢失密钥应恢复备份,不要删除目录或重建密钥。
详见[配置参考](backend/docs/CONFIGURATION.md#models-registered-through-the-web)。
## 快速开始
### 配置

View File

@ -230,7 +230,7 @@ failure policy rather than exposing the unfiltered user-scoped catalog.
Cap edit reads at `MAX_EDITABLE_ARTIFACT_BYTES + 1` despite stale `lstat`.
### Route and skill-listing authorization
### Route and skill authorization
`authz.authorize_model_use` shares the `model:use` decision between model details
and follow-up suggestions, including the factory's first configured model when
@ -238,10 +238,14 @@ the caller omits `model_name`. Suggestions check admission before their
best-effort LLM error handler: explicit denies return 403 without invoking the
model, while provider failures honor `authorization.fail_closed`.
`authz.py::resolve_route_permissions()` is shared by HTTP, decorator-only auth, and Live Browser WebSocket admission. It evaluates full `resource:action` route targets; provider errors follow `authorization.fail_closed`, while disabled authorization preserves the legacy permission set. Owner/admin checks stay separate. Live requires `threads:write` with `is_internal=False` after login/Origin checks but before acceptance or session acquisition; denial closes 4403 and unexpected setup errors close 4501. Admission is connection-scoped. On disconnect, Live drains owned `stop_screencast()` cleanup across cancellation before releasing the session lease, so cancellation cannot strand a manager pin and consume browser-session capacity. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py`.
`authz.py::resolve_route_permissions()` is shared by HTTP, decorator-only auth, and Live Browser WebSocket admission. It evaluates full `resource:action` targets; provider errors follow `authorization.fail_closed`, while disabled authorization preserves legacy permissions. Owner/admin checks stay separate. Live requires `threads:write` with `is_internal=False` after login/Origin checks; denial closes 4403 and setup errors close 4501. Admission is connection-scoped. On disconnect, Live drains owned `stop_screencast()` cleanup before releasing the session lease. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.
Batch workers pin `app.state.extensions`; never persist snapshots.
Personal MCP uses owner-only files/calls; platform MCP stays shared.
Personal MCP is owner-only; platform MCP shared.
### Web model provider presets
Managed model routes expose provider metadata; routing stays server-owned. Align validation, probing, storage, keys, and revisions.

View File

@ -2,19 +2,41 @@
import asyncio
import logging
from contextlib import aclosing
from fastapi import APIRouter, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator
from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write
from deerflow.config.app_config import get_app_config
from deerflow.config.managed_models import ManagedModel, ManagedModelStore
from deerflow.reflection import resolve_class
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.managed_model_providers import PROVIDERS
from deerflow.config.managed_models import ManagedModel, ManagedModelInputError, ManagedModelStore, retained_api_key
from deerflow.models.factory import create_chat_model
from deerflow.models.reasoning import resolve_reasoning_contract
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/managed-models", tags=["models"])
class RedactedModelRoute(APIRoute):
"""Pydantic model-level errors can echo an entire draft, including its key."""
def get_route_handler(self):
original = super().get_route_handler()
async def handler(request: Request):
try:
return await original(request)
except RequestValidationError:
raise HTTPException(422, {"code": "invalid_model", "message": "Invalid model configuration; check provider, model ID and capability fields"}) from None
return handler
router = APIRouter(prefix="/api/managed-models", tags=["models"], route_class=RedactedModelRoute)
_ADMIN = "Admin privileges are required to manage shared models."
@ -23,6 +45,15 @@ class SaveModelRequest(BaseModel):
config: ManagedModel
expected_revision: str | None = None
@model_validator(mode="before")
@classmethod
def reject_preset_routes(cls, value):
if isinstance(value, dict):
config = value.get("config")
if isinstance(config, dict) and config.get("provider", "openai-compatible") != "openai-compatible" and "base_url" in config:
raise ValueError("Preset endpoints cannot be overridden")
return value
def _catalog():
config = get_app_config()
@ -34,6 +65,12 @@ def _catalog():
}
@router.get("/providers")
async def list_model_providers(request: Request):
await require_admin_user(request, detail=_ADMIN)
return {"providers": [provider.public() for provider in PROVIDERS]}
@router.get("")
async def list_managed_models(request: Request):
await require_admin_user(request, detail=_ADMIN)
@ -47,8 +84,11 @@ 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")
reserved_names = {item.name for item in config.models if item.name not in config._managed_model_names}
try:
return ManagedModelStore().save(body.config, expected_revision=body.expected_revision).public()
return ManagedModelStore().save(body.config, expected_revision=body.expected_revision, reserved_names=reserved_names).public()
except ManagedModelInputError as exc:
raise HTTPException(422, {"code": "model_key_or_capabilities", "message": str(exc)}) from None
except FileExistsError:
raise HTTPException(409, "Model changed or already exists; reload before saving") from None
except FileNotFoundError:
@ -79,25 +119,38 @@ async def save_model(request: Request, body: SaveModelRequest):
def _probe_config(body: SaveModelRequest):
profile = body.config
previous = None
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})
profile = profile.model_copy(update={"api_key": retained_api_key(profile, previous)})
if profile.provider != "openai-compatible" and not (profile.api_key and profile.api_key.get_secret_value()):
raise ManagedModelInputError("This provider requires an API key")
return profile.runtime_config()
def _build_probe(body: SaveModelRequest):
"""Resolve credentials and construct provider clients off the event loop."""
"""Use exactly the same resolution, reasoning transforms and factory as chat."""
config = _probe_config(body)
settings = config.model_dump(include={"model", "api_key", "base_url", "api_base"}, exclude_none=True)
# Forced tool selection requires thinking off on DeepSeek. Reuse the resolved
# profile's disable settings without changing the saved chat configuration.
settings.update(config.when_thinking_disabled or {})
settings.update(timeout=15, max_retries=0, max_tokens=32)
model = resolve_class(config.use)(**settings)
return model.bind_tools([{"type": "function", "function": {"name": "connection_check", "description": "Check the connection", "parameters": {"type": "object", "properties": {}}}}], tool_choice="connection_check")
contract = resolve_reasoning_contract(config)
# Required-thinking models cannot accept forced tool selection. Keep the
# bounded probe honest: auto selection may report tool_call_missing.
required = contract.thinking_required
# For an unrecognized model on a third-party preset, tool_choice forcing
# is not a verified capability. Use auto instead of inventing a contract.
force_tool = not required and (body.config.provider in {"openai-compatible", "openai", "anthropic"} or config.when_thinking_disabled is not None)
app_config = AppConfig.model_validate({"models": [config], "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}})
model = create_chat_model(
name=config.name,
app_config=app_config,
thinking_enabled=False,
attach_tracing=False,
model_overrides={"timeout": 15, "max_retries": 0, "max_tokens": 2048 if required else 32},
)
return model.bind_tools(
[{"type": "function", "function": {"name": "connection_check", "description": "Check the connection", "parameters": {"type": "object", "properties": {}}}}], tool_choice="connection_check" if force_tool else "auto"
)
@router.post("/test")
@ -105,16 +158,25 @@ 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:
probe = await asyncio.to_thread(_build_probe, body)
response = None
size = 0
chunks = 0
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
probe = await asyncio.to_thread(_build_probe, body)
async with aclosing(probe.astream([HumanMessage(content="Call connection_check.")], config={"callbacks": []})) as stream:
async for chunk in stream:
size += len(str(chunk.content)) + len(str(chunk.tool_call_chunks)) + len(str(chunk.additional_kwargs))
chunks += 1
if size > 16384 or chunks > 256:
return {"ok": False, "message": "response_limit"}
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:
except ManagedModelInputError:
return {"ok": False, "message": "missing_api_key"}
except Exception as exc:
# Provider exception strings may contain keys, URLs or response bodies.
return {"ok": False, "message": "connection_failed"}
return {"ok": False, "message": "authentication_failed" if getattr(exc, "status_code", None) in (401, 403) else "connection_failed"}

View File

@ -282,6 +282,167 @@ models:
type: enabled
```
#### Models registered through the Web
An administrator can open **Settings → Models → Add model**, select a provider,
and enter its **API Key** and **model ID**. No endpoint, protocol, Python import
path or thinking template is needed. The model ID is not an enum: any nonempty
ID of up to 200 Unicode characters, without control characters, is accepted.
The provider still decides whether that ID exists and is available to the key.
No model discovery or external model directory is queried.
New entries are enabled by default. The server generates an ASCII internal name
(up to 100 characters) from a bounded slug and a random suffix, checking for
collisions while holding the existing catalog lock. Slashes, spaces, Unicode,
long IDs and identical model IDs do not collide or become filesystem paths.
The default display name combines the provider and model ID. The internal name
is immutable in the Web editor; changing a model ID keeps Agent/thread references
stable. Display name, enabled state, context window, output token cap and vision
support remain available under **Advanced settings**. Vision supports an explicit
on/off override or the provider rule; unknown models default to no advertised
vision/thinking/effort and no assumed context window. This conservative metadata
is not a claim that an unknown model lacks those features. A verified `reasoning`
contract can also be supplied by administrator API clients; it is validated by
`ModelConfig` and interpreted by the existing model factory. Managed effort paths
are restricted to request effort parameters (`reasoning_effort`,
`extra_body.reasoning_effort`, `output_config.effort`, `effort`); they cannot address
SDK credentials, endpoints or adapter fields. Operator YAML contracts are unchanged.
Provider routing has one backend owner,
`packages/harness/deerflow/config/managed_model_providers.py`. The admin-only
`GET /api/managed-models/providers` returns IDs, labels and form requirements,
without maintaining another endpoint map in the frontend. The initial presets
are:
| Provider ID | Service / credential scope | Endpoint | Protocol / authentication |
| --- | --- | --- | --- |
| `openai` | OpenAI API | `https://api.openai.com/v1` | Chat Completions / Bearer |
| `anthropic` | Anthropic API | `https://api.anthropic.com` | Native Messages `/v1/messages` / `x-api-key` |
| `deepseek` | DeepSeek API | `https://api.deepseek.com` | Chat Completions / Bearer |
| `qwen-cn` | Alibaba Cloud Bailian / Qwen, China | `https://dashscope.aliyuncs.com/compatible-mode/v1` | Chat Completions / Bearer |
| `doubao` | Volcengine Beijing, ordinary API | `https://ark.cn-beijing.volces.com/api/v3` | Chat Completions / Bearer |
| `doubao-coding` | Volcengine Coding Plan, separate key/plan | `https://ark.cn-beijing.volces.com/api/coding/v3` | Chat Completions / Bearer |
| `moonshot-cn` | Moonshot / Kimi, China API | `https://api.moonshot.cn/v1` | Chat Completions / Bearer |
| `zhipu-cn` | Zhipu, China ordinary API | `https://open.bigmodel.cn/api/paas/v4` | Chat Completions / Bearer |
| `zai` | Z.AI, international ordinary API | `https://api.z.ai/api/paas/v4` | Chat Completions / Bearer |
| `minimax` | MiniMax, international API | `https://api.minimax.io/v1` | Chat Completions / Bearer |
| `minimax-cn` | MiniMax, China API | `https://api.minimaxi.com/v1` | Chat Completions / Bearer |
| `openai-compatible` | Custom OpenAI-compatible, including unauthenticated local endpoints | Administrator-supplied HTTP(S) base URL | Chat Completions / optional key |
Keys for different regions and Coding Plans are not interchangeable. These
presets do not route China keys to international endpoints or infer a Coding
Plan from the model name. Plans/regions without a preset can use the custom
OpenAI-compatible option if their documented protocol is compatible; native
protocols need a verified backend preset or operator YAML configuration.
Endpoint/auth sources used for these presets include the official
[OpenAI SDK](https://github.com/openai/openai-python/blob/main/src/openai/_client.py),
[Anthropic SDK](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_client.py),
[DeepSeek API docs](https://api-docs.deepseek.com/),
[Alibaba Model Studio OpenAI compatibility docs](https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope)
and official [DashScope SDK](https://pypi.org/project/dashscope/),
[Volcengine Ark SDK](https://github.com/volcengine/volcengine-python-sdk/tree/master/volcenginesdkarkruntime),
[Moonshot API docs](https://platform.moonshot.cn/docs/guide/start-using-kimi-api),
[Z.AI SDK](https://pypi.org/project/zai-sdk/) (`zai/_client.py` distinguishes the
China and international endpoints), and
[MiniMax OpenAI API docs](https://platform.minimax.io/docs/api-reference/text-openai-api)
with the official [MiniMax MCP region settings](https://github.com/MiniMax-AI/MiniMax-MCP).
The existing `config.example.yaml` documents Ark Coding Plan and the model-specific
DeepSeek, Kimi, GLM, Anthropic, Doubao and MiniMax parameters. During implementation,
restricted documentation hosts were not reachable; accessible official SDK
source/package metadata and repository examples were checked instead. Vendor
availability and live credentials must be verified separately.
All selected adapters and SDK dependencies are already installed by the locked
harness dependency set (`langchain-openai`, `langchain-anthropic`,
`langchain-deepseek`). Anthropic uses its native SDK. The existing patched
DeepSeek adapter is also used as a Chat Completions adapter where reasoning
history and `max_tokens` must be preserved; this does not select a DeepSeek
endpoint or infer DeepSeek capabilities for those vendors. MiniMax keeps its
existing adapter for reasoning fields and user-message name normalization and
uses its documented `max_tokens` wire parameter. MiniMax defaults to temperature
1.0; Anthropic has a default output budget of 4096 because Messages requires an
output cap. These request defaults are separate from model capability rules.
To add a preset, append a stable ID and verified endpoint, adapter, protocol,
authentication/SDK parameter mapping and required request defaults to `PROVIDERS`.
Add model capability rules only for explicit verified IDs in `_capabilities`;
never use a substring match or enable vision/thinking for an entire vendor.
Keep unknown models conservative. Add an offline real-SDK transport regression
for routing, authentication, streaming, tool choice and token limits, plus
positive and negative capability-rule cases. The frontend discovers the new
entry from `/providers`; it should not gain a routing map.
`PUT /api/managed-models` and `POST /api/managed-models/test` keep their existing
`{config, expected_revision}` envelope. A preset create needs only
`config.provider`, `config.model` and `config.api_key`; omitted `name` and
`display_name` are generated. User capability overrides are optional. Preset
requests reject endpoint fields (even `base_url: null`), `use`, protocol and
other routing overrides. Updates carry the unchanged internal name and current
revision; stale updates return 409. Validation responses do not echo input
objects, keys, headers or provider response bodies.
An omitted API key on edit retains the existing key only for the same provider
and custom endpoint. An empty string explicitly clears it; password masks are
rejected. Preset models require a key to be enabled, so disable the model before
clearing its key. A provider/custom endpoint switch clears the test result in the
Web editor and requires a new key for authenticated presets. The server also
refuses to inherit the old connection's credentials. Unauthenticated custom
endpoints can continue to use no key. Do not send catalog responses back as PUT
payloads: use the editable fields and `expected_revision`, omitting secret status,
source metadata and preset endpoints.
Old `provider: openai-compatible` records retain their names, revisions, encrypted
keys and endpoint semantics. Official HTTPS DeepSeek endpoints (root or `/v1`,
default port) continue to use the legacy DeepSeek adapter. A proxy with a
DeepSeek-like model ID remains a generic custom OpenAI-compatible endpoint.
Reads and startup do not rewrite or migrate the encrypted catalog.
Managed entries are merged after YAML models. YAML names win and conflicts are
shown by the management list; YAML and `$ENV_VAR` references are unchanged.
Disabled entries are omitted from future model lists. Saving changes the catalog
signature, so subsequent requests get a fresh `AppConfig` without a Gateway
restart. Already-returned snapshots, active runs and explicitly injected configs
remain authoritative. The Web editor invalidates both the management query and
`MODELS_QUERY_KEY`, refreshing the existing chat and Agent pickers. Ordinary
users retain the existing model-use authorization; only administrators can
manage the shared catalog. Static/demo mode disables management.
Testing never saves the draft. It constructs the same resolved `ModelConfig`
through `create_chat_model` as chat, with tracing disabled, no SDK retries, a
15-second SDK timeout and a 20-second overall deadline. Optional thinking is
disabled for the short probe. Required-thinking models use automatic tool
selection and a bounded 2048-token budget; ordinary probes use 32 output tokens.
For an unknown third-party preset, automatic selection avoids assuming forced
tool support. Responses are limited to 256 chunks and 16384 accumulated content
and tool-argument characters. The result distinguishes missing keys,
authentication failure, connection failure, absent tool calls and output limits.
An edit can use its saved key only with the current revision and same connection.
A passing probe establishes streaming/tool connectivity, not every agent or
vision/reasoning workflow. Missing preset credentials stop before network access.
Offline tests replace only HTTP transport, using actual LangChain/vendor SDK
serialization. Live tests are opt-in, skipped without credentials and disabled
in CI. To test a preset manually, securely export `MANAGED_MODEL_TEST_API_KEY`,
`MANAGED_MODEL_TEST_PROVIDER` and `MANAGED_MODEL_TEST_MODEL`, then run in `backend/`:
```bash
DEER_FLOW_RUN_LIVE_TESTS=1 uv run --no-sync pytest tests/test_managed_presets_live.py -q
```
These requests may incur provider charges and do not save credentials. Keep the
existing DeepSeek live suite for the legacy custom endpoint and tool-history path.
**Backup and recovery:** keep the entire `$DEER_FLOW_HOME/managed-models/`
directory on persistent storage, including the matched `catalog.enc` and `key`.
Take a consistent backup while writes are paused (or through a filesystem
snapshot); restore the pair together with restrictive filesystem permissions.
Share the same directory across workers/replicas that need the same catalog.
The existing thread/process locks, atomic writes and Fernet encryption remain;
SQL does not replicate these files. Anyone able to read both files can decrypt
the credentials. A missing/wrong key or corrupt catalog fails closed; restore a
matched backup instead of generating a new key or deleting the encrypted file.
#### MindIE XML tool arguments
The MindIE adapter (`deerflow.models.mindie_provider:MindIEChatModel`) parses XML

View File

@ -206,3 +206,7 @@ or an explicitly injected AppConfig. See `../models/AGENTS.md` for storage and r
boundaries. `managed_model_providers.py` derives provider defaults from validated
endpoints; `ManagedModel.runtime_config()` combines them with profile fields.
`AppConfig.from_file()` remains YAML-only.
### Web model provider presets
Managed model provider presets live only in `managed_model_providers.py`. `managed_models.py` reads legacy custom encrypted records compatibly and converts presets into ModelConfig without changing YAML, previous snapshots or injected configurations. See the managed-model section in `../models/AGENTS.md` and the Web registration section of `backend/docs/CONFIGURATION.md`.

View File

@ -1,8 +1,59 @@
"""Provider defaults for administrator-managed OpenAI-compatible endpoints."""
"""Server-owned routing for managed models; never infer a provider from its ID.
Endpoints are verified against the vendors' SDKs and operator examples; see the
source links in CONFIGURATION.md. Catalog entries contain no routing overrides.
"""
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlsplit
_OPENAI = "langchain_openai:ChatOpenAI"
_DEEPSEEK = "deerflow.models.patched_deepseek:PatchedChatDeepSeek"
_MINIMAX = "deerflow.models.patched_minimax:PatchedChatMiniMax"
@dataclass(frozen=True)
class ManagedModelProvider:
id: str
display_name: str
endpoint: str | None
adapter: str = _OPENAI
protocol: str = "openai-chat"
endpoint_parameter: str = "base_url"
auth_parameter: str = "api_key"
defaults: dict[str, Any] = field(default_factory=dict)
authentication: str = "bearer"
def public(self) -> dict[str, Any]:
# Only form metadata is public. Routing/adapter/defaults stay server-owned.
return {"id": self.id, "display_name": self.display_name, "requires_api_key": self.endpoint is not None, "custom_endpoint": self.endpoint is None}
PROVIDERS = (
ManagedModelProvider("openai", "OpenAI", "https://api.openai.com/v1"),
ManagedModelProvider("anthropic", "Anthropic", "https://api.anthropic.com", "langchain_anthropic:ChatAnthropic", "anthropic-messages", defaults={"max_tokens": 4096}, authentication="x-api-key"),
ManagedModelProvider("deepseek", "DeepSeek", "https://api.deepseek.com", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("qwen-cn", "Alibaba Cloud Bailian / Qwen (China)", "https://dashscope.aliyuncs.com/compatible-mode/v1", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("doubao", "Volcengine / Doubao (Beijing, API)", "https://ark.cn-beijing.volces.com/api/v3", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("doubao-coding", "Volcengine (Coding Plan)", "https://ark.cn-beijing.volces.com/api/coding/v3", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("moonshot-cn", "Moonshot / Kimi (China)", "https://api.moonshot.cn/v1", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("zhipu-cn", "Zhipu (China, API)", "https://open.bigmodel.cn/api/paas/v4", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("zai", "Z.AI (International, API)", "https://api.z.ai/api/paas/v4", _DEEPSEEK, endpoint_parameter="api_base"),
ManagedModelProvider("minimax", "MiniMax (International, API)", "https://api.minimax.io/v1", _MINIMAX, defaults={"temperature": 1.0}),
ManagedModelProvider("minimax-cn", "MiniMax (China, API)", "https://api.minimaxi.com/v1", _MINIMAX, defaults={"temperature": 1.0}),
ManagedModelProvider("openai-compatible", "Custom OpenAI-compatible", None),
)
_PROVIDER_BY_ID = {provider.id: provider for provider in PROVIDERS}
def get_managed_model_provider(provider: str) -> ManagedModelProvider:
try:
return _PROVIDER_BY_ID[provider]
except KeyError:
raise ValueError("Unknown model provider") from None
def _is_official_deepseek_endpoint(base_url: str) -> bool:
endpoint = urlsplit(base_url)
@ -12,18 +63,74 @@ def _is_official_deepseek_endpoint(base_url: str) -> bool:
def resolve_managed_model_provider(base_url: str) -> dict[str, Any]:
"""Derive settings from a validated endpoint, never from the model name.
Only the official Chat Completions endpoint opts into DeepSeek semantics;
third-party proxies retain the generic OpenAI-compatible contract.
"""
"""Legacy endpoint contract, kept intact for old encrypted custom profiles."""
if _is_official_deepseek_endpoint(base_url):
return {
"use": "deerflow.models.patched_deepseek:PatchedChatDeepSeek",
"use": _DEEPSEEK,
"api_base": base_url,
"supports_thinking": True,
"supports_reasoning_effort": True,
"when_thinking_enabled": {"extra_body": {"thinking": {"type": "enabled"}}},
"when_thinking_disabled": {"extra_body": {"thinking": {"type": "disabled"}}},
}
return {"use": "langchain_openai:ChatOpenAI", "base_url": base_url}
return {"use": _OPENAI, "base_url": base_url}
def _capabilities(provider: str, model: str) -> dict[str, Any]:
"""Exact opt-in rules. Unknown IDs get no fabricated window or capabilities.
Adapter choice is provider-wide because it preserves wire/history fields;
thinking/vision/effort is model-specific. Do not broaden these to substrings.
"""
result: dict[str, Any] = {"reasoning": {"thinking": "unsupported", "dialect": "none"}}
if provider == "openai" and model in {"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"}:
result["supports_vision"] = True
elif provider == "anthropic" and model in {"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-sonnet-4-5-20250929"}:
result.update(
supports_vision=True, reasoning={"thinking": "optional", "dialect": "anthropic"}, when_thinking_enabled={"thinking": {"type": "enabled", "budget_tokens": 1024}}, when_thinking_disabled={"thinking": {"type": "disabled"}}
)
elif provider == "deepseek" and model in {"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-flash"}:
result.update(
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "history": "preserve"},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}},
)
result["reasoning"]["effort"] = {"values": ["high", "max"], "default": "high", "aliases": {"minimal": "high", "low": "high", "medium": "high"}}
elif provider in {"zai", "zhipu-cn"} and model in {"glm-4.5", "glm-4.6", "glm-4.7", "glm-5.3-flash"}:
result.update(
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "history": "preserve"},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}},
)
elif provider == "doubao" and model == "doubao-seed-1-8-251228":
result.update(
supports_vision=True,
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "history": "preserve"},
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}},
when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}},
)
elif provider == "moonshot-cn" and model == "kimi-k2.5":
result.update(
supports_vision=True,
reasoning={"thinking": "optional", "dialect": "openai_extra_body", "history": "preserve"},
temperature=1.0,
when_thinking_enabled={"extra_body": {"thinking": {"type": "enabled"}}, "temperature": 1.0},
when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}, "temperature": 0.6},
)
elif provider in {"minimax", "minimax-cn"} and model in {"MiniMax-M2.7", "MiniMax-M2.7-highspeed"}:
result["reasoning"] = {"thinking": "required", "dialect": "none", "history": "preserve"}
elif provider in {"minimax", "minimax-cn"} and model == "MiniMax-M3":
result.update(
supports_vision=True,
reasoning={"thinking": "optional", "dialect": "none", "history": "preserve"},
when_thinking_enabled={"extra_body": {"thinking": {"type": "adaptive"}}},
when_thinking_disabled={"extra_body": {"thinking": {"type": "disabled"}}},
)
return result
def resolve_preset_model(provider: str, model: str, api_key: str) -> dict[str, Any]:
preset = get_managed_model_provider(provider)
if preset.endpoint is None:
raise ValueError("Custom providers require an endpoint")
return {"use": preset.adapter, preset.endpoint_parameter: preset.endpoint, preset.auth_parameter: api_key, **deepcopy(preset.defaults), **_capabilities(provider, model)}

View File

@ -1,4 +1,4 @@
"""Administrator-managed OpenAI-compatible model profiles, separate from YAML.
"""Administrator-managed 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.
@ -8,19 +8,21 @@ from __future__ import annotations
import json
import os
import re
import tempfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationInfo, field_validator, model_validator
from deerflow.config._boolean_guards import reject_boolean
from deerflow.config.extensions_config import extensions_config_file_lock
from deerflow.config.file_signature import get_config_signature
from deerflow.config.managed_model_providers import resolve_managed_model_provider
from deerflow.config.model_config import ModelConfig
from deerflow.config.managed_model_providers import get_managed_model_provider, resolve_managed_model_provider, resolve_preset_model
from deerflow.config.model_config import ModelConfig, ReasoningCapabilities
from deerflow.config.runtime_paths import runtime_home
from deerflow.config.secret_file import read_or_create_secret_file
@ -33,21 +35,24 @@ _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"
name: str = Field(default="", pattern=r"^(?:[A-Za-z0-9][A-Za-z0-9_.-]{0,99})?$")
provider: str = "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)
base_url: str | None = Field(default=None, max_length=2048)
api_key: SecretStr | None = None
enabled: bool = True
supports_vision: bool = False
supports_vision: bool | None = None
reasoning: ReasoningCapabilities | None = None
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:
def valid_endpoint(cls, value: str | None) -> str | None:
if value is None:
return value
try:
url = urlsplit(value)
port = url.port
@ -60,20 +65,88 @@ class ManagedModel(BaseModel):
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"}
@field_validator("context_window", "max_tokens", mode="before")
@classmethod
def numeric_capabilities(cls, value: object, info: ValidationInfo) -> object:
return reject_boolean(value, info, kind="an integer")
@field_validator("provider")
@classmethod
def valid_provider(cls, value: str) -> str:
get_managed_model_provider(value)
return value
@field_validator("model")
@classmethod
def valid_model_id(cls, value: str) -> str:
if any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value):
raise ValueError("Model IDs cannot contain control characters")
return value
@field_validator("api_key")
@classmethod
def reject_mask(cls, value: SecretStr | None) -> SecretStr | None:
if value is not None and re.fullmatch(r"[**•●]+", value.get_secret_value()):
raise ValueError("Enter a real API key, not a password mask")
if value is not None and value.get_secret_value() and not value.get_secret_value().strip():
raise ValueError("API keys cannot consist only of whitespace")
return value
@model_validator(mode="after")
def validate_routing(self) -> ManagedModel:
if self.provider == "openai-compatible":
if not self.base_url:
raise ValueError("Custom providers require a base URL")
elif self.base_url is not None:
raise ValueError("Preset endpoints cannot be overridden")
if self.reasoning and self.reasoning.effort and self.reasoning.effort.path not in {"reasoning_effort", "extra_body.reasoning_effort", "output_config.effort", "effort"}:
raise ValueError("Managed reasoning effort must target a request effort parameter")
return self
def runtime_config(self) -> ModelConfig:
api_key = self.api_key.get_secret_value() if self.api_key else None
if self.provider == "openai-compatible":
settings = {**resolve_managed_model_provider(self.base_url), "api_key": api_key or "not-required"}
else:
if not api_key:
raise ValueError("This provider requires an API key")
settings = resolve_preset_model(self.provider, self.model, api_key)
if self.reasoning is not None:
# Remove derived legacy fields/templates when an admin supplies a
# contract. Protocol dialect is still interpreted by the factory.
for field in ("reasoning", "supports_thinking", "supports_reasoning_effort"):
settings.pop(field, None)
if self.reasoning.thinking == "unsupported":
settings.pop("when_thinking_enabled", None)
if self.reasoning.thinking == "required":
settings.pop("when_thinking_disabled", None)
settings["reasoning"] = self.reasoning
if self.supports_vision is not None:
settings["supports_vision"] = self.supports_vision
if self.max_tokens is not None:
settings["max_tokens"] = self.max_tokens
return ModelConfig(
name=self.name,
display_name=self.display_name or self.name,
name=self.name or "connection-check",
display_name=self.display_name or f"{get_managed_model_provider(self.provider).display_name} · {self.model}"[:100],
model=self.model,
api_key=api_key or "not-required",
supports_vision=self.supports_vision,
context_window=self.context_window,
max_tokens=self.max_tokens,
**resolve_managed_model_provider(self.base_url),
**settings,
)
class ManagedModelInputError(ValueError):
"""Safe user input errors, separate from encryption/storage failures."""
def retained_api_key(config: ManagedModel, previous: ManagedModel | None) -> SecretStr | None:
"""Never reuse credentials after changing the provider or custom endpoint."""
if config.api_key is not None:
return config.api_key
if previous and (previous.provider, previous.base_url) == (config.provider, config.base_url):
return previous.api_key
return None
class ManagedModelStore:
def __init__(self):
self.path = runtime_home() / "managed-models" / "catalog.enc"
@ -102,16 +175,36 @@ class ManagedModelStore:
# Never include provider secrets or decrypted validation inputs.
raise ValueError("Cannot read managed models; check the catalog and encryption key") from None
def save(self, config: ManagedModel, *, expected_revision: str | None) -> ManagedModel:
def save(self, config: ManagedModel, *, expected_revision: str | None, reserved_names: set[str] | None = None) -> ManagedModel:
with _lock, extensions_config_file_lock(self.path):
records = self.list()
if not config.name:
if expected_revision is not None:
raise ManagedModelInputError("An existing model name is required when editing")
slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", config.model).strip("-._") or "model"
names = {item.name for item in records} | (reserved_names or set())
while True:
name = f"{config.provider}-{slug}"[:85].rstrip("-._") + "-" + uuid4().hex[:12]
if name not in names:
break
config = config.model_copy(update={"name": name})
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})
secret = retained_api_key(config, previous)
if config.provider != "openai-compatible" and config.enabled and not (secret and secret.get_secret_value()):
raise ManagedModelInputError("This provider requires an API key; enter a key or disable the model")
display_name = config.display_name or f"{get_managed_model_provider(config.provider).display_name} · {config.model}"[:100]
# Validate capability overrides before any encrypted write.
candidate = config.model_copy(update={"api_key": secret})
if config.enabled:
try:
candidate.runtime_config()
except ValueError:
raise ManagedModelInputError("Invalid model capability overrides") from None
saved = config.model_copy(update={"api_key": secret, "display_name": display_name, "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)

View File

@ -203,22 +203,31 @@ 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.
`config/managed_model_providers.py` owns endpoint detection and provider defaults.
Managed profiles normally use `langchain_openai:ChatOpenAI`. Official HTTPS
`api.deepseek.com` endpoints (default port, root or `/v1` path) instead resolve to
`PatchedChatDeepSeek` with explicit thinking on/off settings and reasoning-effort
support. Resolve from the parsed endpoint, never a model-name substring; proxies,
lookalike hosts and other paths retain the generic contract. This is derived runtime
configuration: no catalog migration or new API fields. The native `api_base` field
preserves the administrator's endpoint; the adapter preserves `reasoning_content`
and sends `max_tokens` rather than OpenAI's `max_completion_tokens`.
`config/managed_model_providers.py` is the sole registry for provider IDs, labels,
endpoints, protocols, authentication parameter mappings, adapters and request
defaults. Capability rules match exact verified model IDs; unknown preset IDs
have an unsupported-thinking contract, no vision and no inferred context window.
Preset drafts cannot supply endpoints or adapter paths. Legacy custom
`openai-compatible` records retain endpoint-based official DeepSeek detection;
never infer that adapter from a model-name substring. Reads do not migrate the
catalog. New names are allocated under the existing catalog lock with bounded
ASCII slugs and random suffixes; edits preserve names and references.
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.
The Gateway probe constructs the resolved class off-loop and applies the profile's
`when_thinking_disabled` settings to its bounded forced-tool request. It does not
repeat provider selection or persist the probe override.
Tests: `test_managed_models.py`, `test_managed_deepseek.py` (real SDK serialization
with an HTTP double), opt-in `test_managed_deepseek_live.py`, and
The admin-only `/api/managed-models/providers` exposes form metadata, not routing.
Full updates require the current revision. Omitted keys are retained only for
an unchanged provider/custom endpoint, an empty string clears the key and password
masks are rejected. Presets must have a key while enabled; disable before clearing.
Responses return `has_api_key`, never credentials, and request validation must
not echo sensitive input dictionaries. Keep the existing storage/lock/signature
mechanisms rather than adding a key store or writing YAML.
The Gateway probe uses `create_chat_model` with a fresh explicit AppConfig,
tracing off, bounded timeout/output and retries disabled. It uses the same
provider settings/reasoning transforms as chat. Disable optional thinking for
short probes; required thinking and unknown third-party models use automatic
tool choice. MiniMax's existing adapter normalizes the OpenAI SDK's
`max_completion_tokens` into MiniMax's documented `max_tokens` for both paths.
Tests: `test_managed_models.py`, `test_managed_deepseek.py`,
`test_managed_provider_presets.py` (real vendor SDKs with HTTP doubles), opt-in
`test_managed_deepseek_live.py` / `test_managed_presets_live.py`, and
`tests/blocking_io/test_managed_models.py`.

View File

@ -117,6 +117,11 @@ class PatchedChatMiniMax(ChatOpenAI):
**kwargs: Any,
) -> dict:
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
# MiniMax's documented OpenAI-compatible API takes max_tokens. Stock
# ChatOpenAI renames it to OpenAI's newer max_completion_tokens, which
# would lose the output bound on both probes and ordinary chat calls.
if "max_completion_tokens" in payload:
payload["max_tokens"] = payload.pop("max_completion_tokens")
extra_body = payload.get("extra_body")
if isinstance(extra_body, dict):
payload["extra_body"] = {

View File

@ -274,3 +274,7 @@ Writable-directory cases remain portable.
of an existing entry on failure. A cleanup failure must preserve the original
publish exception, including interruption. Import the scanner during fixture
setup, after the autouse fixtures initialize runtime, to avoid the package cycle.
### Web model provider presets
`test_managed_provider_presets.py` covers encrypted compatibility, auto IDs, key switching, snapshot isolation and native/compatible protocols. Its HTTP double is below actual LangChain/vendor SDK serialization; do not replace these adapters with successful stubs. `test_managed_presets_live.py` requires explicit opt-in and vendor credentials, and remains skipped in CI.

View File

@ -72,3 +72,40 @@ async def test_deepseek_probe_offloads_credentials_and_client_construction(tmp_p
assert result == {"ok": True, "message": "success"}
assert constructed == ["PatchedChatDeepSeek"]
assert await asyncio.to_thread(store.path.read_bytes) == original_catalog
@pytest.mark.asyncio
async def test_preset_catalog_registration_and_native_client_stay_off_loop(tmp_path, monkeypatch):
from langchain_anthropic import ChatAnthropic
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setenv("LANGSMITH_TRACING", "false")
base = AppConfig.model_validate({"sandbox": {"use": "test"}})
monkeypatch.setattr(router, "get_app_config", lambda: base)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
providers = await router.list_model_providers(request)
assert any(item["id"] == "anthropic" for item in providers["providers"])
result = await router.save_model(request, router.SaveModelRequest(config=ManagedModel(provider="anthropic", model="arbitrary", api_key="test-secret")))
assert result["name"] and result["provider"] == "anthropic"
assert "test-secret" not in str(await router.list_managed_models(request))
original = ChatAnthropic.__init__
built = []
def initialize(self, *args, **kwargs):
with pytest.raises(RuntimeError, match="no running event loop"):
asyncio.get_running_loop()
original(self, *args, **kwargs)
built.append(True)
monkeypatch.setattr(ChatAnthropic, "__init__", initialize)
# Stop at the wire boundary; constructing the actual native SDK is the
# assertion here. The real streaming wire test lives in the offline suite.
async def send(client, outbound, **kwargs):
assert outbound.headers["x-api-key"] == "test-secret"
return httpx.Response(401, request=outbound, json={"error": {"type": "authentication_error", "message": "private provider detail"}})
monkeypatch.setattr(httpx.AsyncClient, "send", send)
profile = ManagedModel(name=result["name"], provider="anthropic", model="arbitrary")
assert await router.test_model(request, router.SaveModelRequest(config=profile, expected_revision=result["revision"])) == {"ok": False, "message": "authentication_failed"}
assert built == [True]

View File

@ -117,7 +117,7 @@ async def test_probe_requires_the_expected_tool_and_empty_arguments(store, provi
async def test_probe_does_not_retry_or_expose_provider_errors(store, provider, status):
provider.status = status
result = await router.test_model(_ADMIN, router.SaveModelRequest(config=_profile()))
assert result == {"ok": False, "message": "connection_failed"}
assert result == {"ok": False, "message": "authentication_failed" if status in (401, 403) else "connection_failed"}
assert _KEY not in json.dumps(result)
assert len(provider.requests) == 1
assert not store.path.exists()
@ -140,7 +140,7 @@ async def test_probe_uses_draft_or_saved_key_without_persisting(store, provider,
original_catalog = store.path.read_bytes()
draft = _profile(api_key={"retained": None, "replacement": _KEY, "cleared": ""}[key_mode])
result = await router.test_model(_ADMIN, router.SaveModelRequest(config=draft, expected_revision=saved.revision))
assert result == ({"ok": False, "message": "connection_failed"} if key_mode == "cleared" else {"ok": True, "message": "success"})
assert result == ({"ok": False, "message": "authentication_failed"} if key_mode == "cleared" else {"ok": True, "message": "success"})
assert len(provider.requests) == 1
expected_key = "not-required" if key_mode == "cleared" else _KEY
assert provider.requests[0][0].headers["authorization"] == f"Bearer {expected_key}"
@ -250,7 +250,7 @@ async def test_new_draft_without_key_does_not_use_environment_credentials(store,
monkeypatch.setenv("DEEPSEEK_API_KEY", _KEY)
monkeypatch.setenv("OPENAI_API_KEY", _KEY)
result = await router.test_model(_ADMIN, router.SaveModelRequest(config=_profile(api_key=None)))
assert result == {"ok": False, "message": "connection_failed"}
assert result == {"ok": False, "message": "authentication_failed"}
assert provider.requests[0][0].headers["authorization"] == "Bearer not-required"
assert not store.path.exists()

View File

@ -283,7 +283,6 @@ async def test_yaml_name_reserved_and_pat_denied(store, monkeypatch):
@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
@ -303,7 +302,12 @@ async def test_connection_probe_is_bounded_redacted_and_does_not_save(store, mon
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)
def fake_factory(**kwargs):
assert kwargs["attach_tracing"] is False
assert kwargs["app_config"].models[0].use == "langchain_openai:ChatOpenAI"
return FakeModel(**kwargs["model_overrides"])
monkeypatch.setattr(router, "create_chat_model", fake_factory)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
result = await router.test_model(request, router.SaveModelRequest(config=profile()))
assert result["message"] == expected

View File

@ -0,0 +1,29 @@
"""Opt-in real vendor probe. No discovery or credentials in test output."""
import os
from types import SimpleNamespace
import pytest
from app.gateway.routers import managed_models as router
from deerflow.config.managed_models import ManagedModel
pytestmark = [
pytest.mark.live,
pytest.mark.skipif(
os.getenv("DEER_FLOW_RUN_LIVE_TESTS") != "1" or not all(os.getenv(name) for name in ("MANAGED_MODEL_TEST_PROVIDER", "MANAGED_MODEL_TEST_MODEL", "MANAGED_MODEL_TEST_API_KEY")) or bool(os.getenv("CI")),
reason="Requires explicit live opt-in and vendor test settings; never runs in CI",
),
]
@pytest.mark.asyncio
async def test_preset_connection_live(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setenv("LANGSMITH_TRACING", "false")
profile = ManagedModel(provider=os.environ["MANAGED_MODEL_TEST_PROVIDER"], model=os.environ["MANAGED_MODEL_TEST_MODEL"], api_key=os.environ["MANAGED_MODEL_TEST_API_KEY"])
assert profile.provider != "openai-compatible", "Use the existing custom endpoint test for legacy profiles"
admin = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
result = await router.test_model(admin, router.SaveModelRequest(config=profile))
assert result["ok"], result["message"]
assert not (tmp_path / "managed-models").exists()

View File

@ -0,0 +1,411 @@
"""Provider registration tests use real configuration and encrypted persistence."""
import asyncio
import json
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI, HTTPException
from langchain_core.messages import HumanMessage
from pydantic import SecretStr, ValidationError
from app.gateway.routers import managed_models as router
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config, set_app_config
from deerflow.config.managed_model_providers import PROVIDERS
from deerflow.config.managed_models import ManagedModel, ManagedModelInputError, ManagedModelStore, merge_managed_models
from deerflow.models.factory import create_chat_model
@pytest.fixture
def store(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
return ManagedModelStore()
@pytest.mark.parametrize(
"provider,endpoint,adapter",
[
("openai", "https://api.openai.com/v1", "langchain_openai:ChatOpenAI"),
("anthropic", "https://api.anthropic.com", "langchain_anthropic:ChatAnthropic"),
("deepseek", "https://api.deepseek.com", "deerflow.models.patched_deepseek:PatchedChatDeepSeek"),
("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1", "deerflow.models.patched_deepseek:PatchedChatDeepSeek"),
("doubao", "https://ark.cn-beijing.volces.com/api/v3", "deerflow.models.patched_deepseek:PatchedChatDeepSeek"),
("moonshot-cn", "https://api.moonshot.cn/v1", "deerflow.models.patched_deepseek:PatchedChatDeepSeek"),
("zai", "https://api.z.ai/api/paas/v4", "deerflow.models.patched_deepseek:PatchedChatDeepSeek"),
("minimax", "https://api.minimax.io/v1", "deerflow.models.patched_minimax:PatchedChatMiniMax"),
],
)
def test_preset_routing_and_conservative_unknown_capabilities(provider, endpoint, adapter):
profile = ManagedModel(provider=provider, model="unknown/model 名称", api_key="test-key")
config = profile.runtime_config()
assert config.use == adapter
assert (config.model_extra.get("base_url") or config.model_extra.get("api_base")) == endpoint
assert not config.supports_thinking
assert not config.supports_reasoning_effort
assert not config.supports_vision
assert config.context_window is None
def test_automatic_names_are_valid_unique_and_stable(store):
for model in ["a/b c 中文", "中" * 200, "same", "same"]:
saved = store.save(ManagedModel(provider="openai", model=model, api_key="test"), expected_revision=None)
assert 0 < len(saved.name) <= 100
assert saved.name.isascii()
updated = store.save(saved.model_copy(update={"model": "changed", "api_key": None}), expected_revision=saved.revision)
assert updated.name == saved.name
assert len({m.name for m in store.list()}) == 4
_ADMIN = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
_TOOL = {"type": "function", "function": {"name": "connection_check", "description": "Check the connection", "parameters": {"type": "object", "properties": {}}}}
@pytest.mark.parametrize("field,value", [("base_url", "https://evil.invalid/v1"), ("base_url", None), ("use", "evil:Code"), ("protocol", "other"), ("api_base", "https://evil.invalid")])
def test_preset_drafts_reject_route_overrides(field, value):
with pytest.raises(ValidationError):
router.SaveModelRequest.model_validate({"config": {"provider": "openai", "model": "any", "api_key": "private-test-key", field: value}})
@pytest.mark.parametrize("provider", ["unknown", "OPENAI", "", "openai/../../"])
def test_unknown_provider_rejected(provider):
with pytest.raises(ValidationError):
ManagedModel(provider=provider, model="any", api_key="test")
def test_key_requirements_and_switching_do_not_reuse_credentials(store):
original = store.save(ManagedModel(provider="openai", model="any", api_key="old"), expected_revision=None)
before = store.path.read_bytes()
with pytest.raises(ManagedModelInputError, match="API key"):
store.save(original.model_copy(update={"provider": "anthropic", "api_key": None}), expected_revision=original.revision)
assert store.path.read_bytes() == before
updated = store.save(original.model_copy(update={"provider": "anthropic", "api_key": SecretStr("new")}), expected_revision=original.revision)
assert updated.name == original.name
assert updated.api_key.get_secret_value() == "new"
with pytest.raises(ManagedModelInputError):
store.save(updated.model_copy(update={"api_key": SecretStr("")}), expected_revision=updated.revision)
cleared = store.save(updated.model_copy(update={"api_key": SecretStr(""), "enabled": False}), expected_revision=updated.revision)
assert not cleared.public()["has_api_key"]
assert merge_managed_models(AppConfig.model_validate({"sandbox": {"use": "test"}})).models == []
def test_old_catalog_is_compatible_without_rewriting_and_preserves_snapshot(store):
from cryptography.fernet import Fernet
store.path.parent.mkdir(parents=True)
key = Fernet.generate_key()
store.key_path.write_bytes(key)
old = {
"name": "old-proxy",
"provider": "openai-compatible",
"model": "deepseek-v4-pro",
"display_name": "Old",
"base_url": "https://proxy.example.com/v1",
"api_key": "secret-old",
"enabled": True,
"supports_vision": False,
"context_window": 32768,
"max_tokens": 100,
"revision": "legacy",
}
encrypted = Fernet(key).encrypt(json.dumps([old]).encode())
store.path.write_bytes(encrypted)
base = AppConfig.model_validate({"sandbox": {"use": "test"}})
snapshot = merge_managed_models(base)
assert snapshot.models[0].use == "langchain_openai:ChatOpenAI"
assert snapshot.models[0].api_key == "secret-old"
assert store.path.read_bytes() == encrypted
saved = store.save(store.list()[0].model_copy(update={"model": "new", "api_key": None}), expected_revision="legacy")
assert saved.name == "old-proxy"
assert saved.api_key.get_secret_value() == "secret-old"
assert snapshot.models[0].model == "deepseek-v4-pro"
assert merge_managed_models(base).models[0].model == "new"
def test_auto_names_are_allocated_inside_cross_writer_lock(store):
def save(_):
return store.save(ManagedModel(provider="openai", model="重复/name", api_key="test"), expected_revision=None).name
with ThreadPoolExecutor(max_workers=4) as workers:
names = list(workers.map(save, range(12)))
assert len(set(names)) == len(store.list()) == 12
def test_preset_overrides_and_injected_config_remain_authoritative(store):
saved = store.save(ManagedModel(provider="openai", model="unknown", api_key="test", supports_vision=True, context_window=12345, max_tokens=123), expected_revision=None)
base = AppConfig.model_validate({"sandbox": {"use": "test"}})
merged = merge_managed_models(base)
assert merged.models[0].context_window == 12345
assert merged.models[0].supports_vision is True
set_app_config(base)
try:
assert get_app_config() is base
assert get_app_config().models == []
finally:
reset_app_config()
assert store.list()[0].name == saved.name
@pytest.mark.asyncio
async def test_provider_metadata_requires_admin():
catalog = await router.list_model_providers(_ADMIN)
assert {p["id"] for p in catalog["providers"]} == {p.id for p in PROVIDERS}
assert all(set(p) == {"id", "display_name", "requires_api_key", "custom_endpoint"} for p in catalog["providers"])
with pytest.raises(HTTPException) as exc:
await router.list_model_providers(SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="user"))))
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_http_validation_never_echoes_draft_keys():
app = FastAPI()
app.include_router(router.router)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
for config in [
{"provider": "openai", "model": "x", "base_url": "https://evil.invalid"},
{"provider": "unknown", "model": "x"},
{"provider": "openai", "model": "x", "api_key": "**********"},
{"provider": "openai", "model": "x", "use": "evil"},
]:
config.setdefault("api_key", "private-test-key")
response = await client.put("/api/managed-models", json={"config": config})
assert response.status_code == 422
assert "private-test-key" not in response.text
assert "**********" not in response.text
assert "evil" not in response.text
@pytest.fixture
def wire(monkeypatch):
"""Only replace HTTP transport; run the real adapters and vendor SDKs."""
state = SimpleNamespace(requests=[], status=200, mode="tool")
async def send(client, request, **kwargs):
body = json.loads(request.content)
state.requests.append((request, body))
if state.status != 200:
return httpx.Response(state.status, request=request, json={"error": {"type": "authentication_error", "message": "secret-test-key private provider body"}})
if request.url.host == "api.anthropic.com":
events = [
(
"message_start",
{
"type": "message_start",
"message": {"id": "msg-test", "type": "message", "role": "assistant", "content": [], "model": body["model"], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 0}},
},
),
("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "call-test", "name": "connection_check", "input": {}}}),
("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{}"}}),
("content_block_stop", {"type": "content_block_stop", "index": 0}),
("message_delta", {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": None}, "usage": {"output_tokens": 1}}),
("message_stop", {"type": "message_stop"}),
]
content = "".join(f"event: {event}\ndata: {json.dumps(data)}\n\n" for event, data in events)
else:
delta = {"role": "assistant", "content": ""}
if state.mode == "tool":
delta["tool_calls"] = [{"index": 0, "id": "call-test", "type": "function", "function": {"name": "connection_check", "arguments": "{}"}}]
else:
delta["content"] = "x" * (20000 if state.mode == "huge" else 2)
chunk = {"id": "chat-test", "object": "chat.completion.chunk", "created": 1, "model": body["model"], "choices": [{"index": 0, "delta": delta, "finish_reason": "tool_calls" if state.mode == "tool" else "stop"}]}
content = f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n"
return httpx.Response(200, request=request, headers={"content-type": "text/event-stream"}, content=content.encode())
monkeypatch.setattr(httpx.AsyncClient, "send", send)
return state
@pytest.mark.asyncio
@pytest.mark.parametrize("provider", [p.id for p in PROVIDERS if p.endpoint])
async def test_probe_and_chat_share_real_sdk_protocol_and_auth(store, wire, provider):
profile = ManagedModel(provider=provider, model="unlisted-model", api_key="secret-test-key")
result = await router.test_model(_ADMIN, router.SaveModelRequest(config=profile))
assert result == {"ok": True, "message": "success"}
assert not store.path.exists()
saved = store.save(profile, expected_revision=None)
app_config = merge_managed_models(AppConfig.model_validate({"sandbox": {"use": "test"}}))
model = create_chat_model(saved.name, app_config=app_config, attach_tracing=False, model_overrides={"max_tokens": 64})
response = None
async for chunk in model.bind_tools([_TOOL], tool_choice="connection_check").astream([HumanMessage(content="Call connection_check.")]):
response = chunk if response is None else response + chunk
assert response.tool_calls[0]["name"] == "connection_check"
assert len(wire.requests) == 2
probe_request, probe_body = wire.requests[0]
chat_request, chat_body = wire.requests[1]
assert probe_request.url == chat_request.url
assert probe_request.extensions["timeout"]["read"] == 15
assert "thinking" not in probe_body
assert "reasoning_effort" not in probe_body
if provider == "anthropic":
assert str(probe_request.url) == "https://api.anthropic.com/v1/messages"
assert probe_request.headers["x-api-key"] == "secret-test-key"
assert "authorization" not in probe_request.headers
assert probe_body["tools"][0]["input_schema"] == {"type": "object", "properties": {}}
assert probe_body["tool_choice"] == {"type": "tool", "name": "connection_check"}
assert probe_body["max_tokens"] == 32
assert chat_body["max_tokens"] == 64
assert "stream_options" not in probe_body
else:
assert str(probe_request.url).endswith("/chat/completions")
assert probe_request.headers["authorization"] == "Bearer secret-test-key"
if provider == "openai":
assert probe_body["tool_choice"]["function"]["name"] == "connection_check"
else:
assert probe_body["tool_choice"] == "auto"
token_field = "max_completion_tokens" if provider == "openai" else "max_tokens"
assert probe_body[token_field] == 32
assert chat_body[token_field] == 64
@pytest.mark.asyncio
@pytest.mark.parametrize("provider,model", [("deepseek", "deepseek-v4-flash"), ("anthropic", "claude-sonnet-4-5-20250929"), ("minimax", "MiniMax-M3"), ("moonshot-cn", "kimi-k2.5")])
async def test_known_optional_thinking_disabled_only_for_probe(store, wire, provider, model):
profile = ManagedModel(provider=provider, model=model, api_key="secret-test-key")
config = profile.runtime_config()
assert config.supports_thinking
assert await router.test_model(_ADMIN, router.SaveModelRequest(config=profile)) == {"ok": True, "message": "success"}
assert wire.requests[0][1]["thinking"] == {"type": "disabled"}
assert config.when_thinking_enabled
assert not store.path.exists()
@pytest.mark.asyncio
async def test_required_thinking_uses_auto_selection_and_bounded_budget(store, wire):
profile = ManagedModel(provider="minimax", model="MiniMax-M2.7", api_key="secret-test-key")
assert await router.test_model(_ADMIN, router.SaveModelRequest(config=profile)) == {"ok": True, "message": "success"}
body = wire.requests[0][1]
assert body["tool_choice"] == "auto"
assert "thinking" not in body
assert body["max_tokens"] == 2048
@pytest.mark.asyncio
@pytest.mark.parametrize("status,message", [(401, "authentication_failed"), (403, "authentication_failed"), (500, "connection_failed")])
@pytest.mark.parametrize("provider", ["openai", "anthropic"])
async def test_native_and_compatible_errors_are_redacted_and_not_retried(store, wire, provider, status, message):
wire.status = status
assert await router.test_model(_ADMIN, router.SaveModelRequest(config=ManagedModel(provider=provider, model="x", api_key="secret-test-key"))) == {"ok": False, "message": message}
assert len(wire.requests) == 1
assert not store.path.exists()
@pytest.mark.asyncio
async def test_missing_preset_key_never_contacts_provider(store, monkeypatch):
async def forbidden(*args, **kwargs):
pytest.fail("Missing credentials must not make a network request")
monkeypatch.setattr(httpx.AsyncClient, "send", forbidden)
result = await router.test_model(_ADMIN, router.SaveModelRequest(config=ManagedModel(provider="openai", model="x")))
assert result == {"ok": False, "message": "missing_api_key"}
assert not store.path.exists()
@pytest.mark.asyncio
async def test_revision_credential_retention_and_provider_switch(store, wire):
saved = store.save(ManagedModel(provider="openai", model="x", api_key="secret-test-key"), expected_revision=None)
body = router.SaveModelRequest(config=saved.model_copy(update={"api_key": None}), expected_revision=saved.revision)
assert await router.test_model(_ADMIN, body) == {"ok": True, "message": "success"}
switched = body.model_copy(update={"config": body.config.model_copy(update={"provider": "anthropic"})})
assert await router.test_model(_ADMIN, switched) == {"ok": False, "message": "missing_api_key"}
stale = body.model_copy(update={"expected_revision": "stale"})
with pytest.raises(HTTPException) as exc:
await router.test_model(_ADMIN, stale)
assert exc.value.status_code == 409
assert len(wire.requests) == 1
assert store.list()[0].revision == saved.revision
@pytest.mark.asyncio
async def test_probe_limits_response_size(store, wire):
wire.mode = "huge"
assert await router.test_model(_ADMIN, router.SaveModelRequest(config=ManagedModel(provider="openai", model="x", api_key="secret-test-key"))) == {"ok": False, "message": "response_limit"}
def test_generated_name_collision_with_yaml_is_retried(store, monkeypatch):
from deerflow.config import managed_models
tokens = iter(["a" * 32, "b" * 32, "c" * 32])
monkeypatch.setattr(managed_models, "uuid4", lambda: SimpleNamespace(hex=next(tokens)))
saved = store.save(ManagedModel(provider="openai", model="x", api_key="test"), expected_revision=None, reserved_names={"openai-x-" + "a" * 12})
assert saved.name == "openai-x-" + "b" * 12
assert saved.revision == "c" * 32
@pytest.mark.asyncio
async def test_preset_api_save_refresh_yaml_conflict_and_disable(store, tmp_path, monkeypatch):
from unittest.mock import AsyncMock
from app.gateway.routers import models as models_router
path = tmp_path / "config.yaml"
original = "sandbox:\n use: test\nmodels:\n - name: operator\n model: yaml-model\n use: langchain_openai:ChatOpenAI\n api_key: $MANAGED_TEST_YAML_KEY\n"
path.write_text(original, encoding="utf-8")
monkeypatch.setenv("MANAGED_TEST_YAML_KEY", "yaml-test-key")
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(path))
monkeypatch.delenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", raising=False)
reset_app_config()
app = FastAPI()
app.include_router(router.router)
app.include_router(models_router.router)
app.middleware("http")(_admin_request)
async def current_config():
return await asyncio.to_thread(get_app_config)
app.dependency_overrides[models_router.get_config] = current_config
monkeypatch.setattr(models_router, "get_optional_user_from_request", AsyncMock(return_value=None))
snapshot = get_app_config()
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
create = await client.put("/api/managed-models", json={"config": {"provider": "openai", "model": "org/新 模型", "api_key": "private-test-key"}})
assert create.status_code == 200
saved = create.json()
assert saved["enabled"] and saved["has_api_key"]
assert "private-test-key" not in create.text
listing = await client.get("/api/models")
assert [item["name"] for item in listing.json()["models"]] == ["operator", saved["name"]]
assert "private-test-key" not in listing.text and "yaml-test-key" not in listing.text
next_snapshot = get_app_config()
assert snapshot.models[0].api_key == "yaml-test-key"
assert len(snapshot.models) == 1
assert len(next_snapshot.models) == 2
edit = {"config": {"name": saved["name"], "provider": "openai", "model": "new-model"}, "expected_revision": saved["revision"]}
changed = await client.put("/api/managed-models", json=edit)
assert changed.status_code == 200
assert changed.json()["name"] == saved["name"]
assert get_app_config().models[1].model == "new-model"
assert next_snapshot.models[1].model == "org/新 模型"
assert (await client.put("/api/managed-models", json=edit)).status_code == 409
edit["expected_revision"] = changed.json()["revision"]
edit["config"]["enabled"] = False
assert (await client.put("/api/managed-models", json=edit)).status_code == 200
assert len((await client.get("/api/models")).json()["models"]) == 1
conflict = await client.put("/api/managed-models", json={"config": {"name": "operator", "provider": "openai", "model": "x", "api_key": "test"}})
assert conflict.status_code == 409
assert path.read_text(encoding="utf-8") == original
finally:
reset_app_config()
async def _admin_request(request, call_next):
request.state.user = _ADMIN.state.user
return await call_next(request)
@pytest.mark.parametrize("path", ["api_key", "base_url", "api_base", "use", "default_headers.Authorization", "model"])
def test_reasoning_cannot_override_preset_routing_or_credentials(path):
with pytest.raises(ValidationError):
ManagedModel(provider="openai", model="x", api_key="secret-test-key", reasoning={"thinking": "required", "effort": {"values": ["high"], "default": "high", "path": path}})
def test_reasoning_contract_override_is_preserved_and_validated(store):
profile = ManagedModel(provider="deepseek", model="deepseek-v4-pro", api_key="test", reasoning={"thinking": "unsupported", "dialect": "none"}, supports_vision=False)
saved = store.save(profile, expected_revision=None)
runtime = saved.runtime_config()
assert not runtime.supports_thinking
assert not runtime.supports_reasoning_effort
assert not runtime.supports_vision
assert runtime.when_thinking_enabled is None
assert runtime.when_thinking_disabled == {"extra_body": {"thinking": {"type": "disabled"}}}
assert store.list()[0].reasoning.thinking == "unsupported"

View File

@ -399,3 +399,7 @@ caret, thread changes and attachment fences apply to editor and picker. Commit
mention text before restoring focus synchronously; a delayed callback must not
overwrite subsequent typing or caret movement. The `@`
and attachment buttons share the picker.
### Web model provider presets
Settings → Models obtains provider form metadata from the admin-only backend `/api/managed-models/providers`; do not copy endpoints, protocols or adapter maps into the UI. New models need only provider, API key and arbitrary model ID, with server-generated stable names. Custom endpoint fields and legacy edits stay supported. Provider/endpoint changes clear test results and saved-key reuse; omitted keys retain only the same connection. Capability overrides belong in Advanced settings. Successful saves/toggles invalidate both managed-model queries and MODELS_QUERY_KEY so existing chat/Agent selectors refresh. Keep locale text and administrator/static-mode restrictions aligned.

View File

@ -14,20 +14,49 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GatewayApiError } from "@/core/api/errors";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { MODELS_QUERY_KEY } from "@/core/models/hooks";
import {
loadManagedModels,
loadManagedModelProviders,
modelDraft,
saveManagedModel,
testManagedModel,
type ManagedModel,
type ManagedModelProvider,
} from "@/core/models/management";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { SettingsSection } from "./settings-section";
function modelManagementError(
error: unknown,
text: {
failed: string;
revisionConflict: string;
keyOrCapabilities: string;
invalidModel: string;
},
) {
if (error instanceof GatewayApiError) {
if (error.status === 409) return text.revisionConflict;
if (error.code === "model_key_or_capabilities")
return text.keyOrCapabilities;
if (error.status === 422) return text.invalidModel;
return text.failed;
}
return error instanceof Error ? error.message : text.failed;
}
export function ModelSettingsPage() {
const { user } = useAuth();
const { t } = useI18n();
@ -40,6 +69,12 @@ export function ModelSettingsPage() {
queryFn: ({ signal }) => loadManagedModels(signal),
enabled: canManage,
});
const providers = useQuery({
queryKey: ["managed-model-providers", user?.id],
queryFn: ({ signal }) => loadManagedModelProviders(signal),
enabled: canManage,
staleTime: Infinity,
});
const [editing, setEditing] = useState<ManagedModel | "new" | null>(null);
const [pending, setPending] = useState(false);
async function refresh() {
@ -57,7 +92,7 @@ export function ModelSettingsPage() {
});
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : text.failed);
toast.error(modelManagementError(error, text));
} finally {
setPending(false);
}
@ -69,19 +104,27 @@ export function ModelSettingsPage() {
) : (
<div className="space-y-4">
<div className="flex gap-2">
<Button disabled={pending} onClick={() => setEditing("new")}>
<Button
disabled={pending || !providers.data}
onClick={() => setEditing("new")}
>
{text.add}
</Button>
<Button
variant="outline"
disabled={pending || catalog.isFetching}
onClick={() => void catalog.refetch()}
onClick={() => {
void catalog.refetch();
void providers.refetch();
}}
>
{text.reload}
</Button>
</div>
{catalog.isLoading && <p role="status">{text.loading}</p>}
{catalog.error && (
{(catalog.isLoading || providers.isLoading) && (
<p role="status">{text.loading}</p>
)}
{(catalog.error ?? providers.error) && (
<div role="alert">
<p>{text.failed}</p>
</div>
@ -97,6 +140,16 @@ export function ModelSettingsPage() {
{model.display_name || model.name}
</p>
<p className="text-muted-foreground text-sm">
{model.source === "managed" && (
<>
{model.provider === "openai-compatible"
? text.customProvider
: (providers.data?.providers.find(
(p) => p.id === model.provider,
)?.display_name ?? model.provider)}{" "}
·{" "}
</>
)}
{model.model} ·{" "}
{model.source === "config"
? text.yaml
@ -134,6 +187,7 @@ export function ModelSettingsPage() {
model={editing === "new" ? undefined : editing}
close={() => setEditing(null)}
saved={refresh}
providers={providers.data?.providers ?? []}
/>
)}
</div>
@ -146,16 +200,19 @@ function ModelEditor({
model,
close,
saved,
providers,
}: {
model?: ManagedModel;
close: () => void;
saved: () => Promise<void>;
providers: ManagedModelProvider[];
}) {
const { t } = useI18n();
const text = t.settings.models;
const [draft, setDraft] = useState(() => modelDraft(model));
const [key, setKey] = useState("");
const [clearKey, setClearKey] = useState(false);
const [connectionChanged, setConnectionChanged] = useState(false);
const [pending, setPending] = useState(false);
const [result, setResult] = useState("");
const active = useRef(true);
@ -167,16 +224,51 @@ function ModelEditor({
abort.current?.abort();
};
}, []);
const provider = providers.find((item) => item.id === draft.provider);
const sameConnection =
model?.provider === draft.provider &&
(draft.provider !== "openai-compatible" ||
model?.base_url === draft.base_url);
const canKeepKey = sameConnection && !connectionChanged && model?.has_api_key;
const requiresKey =
provider?.requires_api_key ?? draft.provider !== "openai-compatible";
const missingKey = requiresKey && ((!key.trim() && !canKeepKey) || clearKey);
function changeProvider(value: string) {
const rest = { ...draft };
delete rest.base_url;
delete rest.reasoning;
setDraft({
...rest,
provider: value,
supports_vision: null,
context_window: null,
max_tokens: null,
...(value === "openai-compatible" ? { base_url: "" } : {}),
});
setKey("");
setConnectionChanged(true);
setClearKey(false);
setResult("");
abort.current?.abort();
}
function body() {
return {
config: {
...draft,
...(clearKey ? { api_key: "" } : key ? { api_key: key } : {}),
...(clearKey || (connectionChanged && !key)
? { api_key: "" }
: key
? { api_key: key }
: {}),
},
expected_revision: model?.revision ?? null,
};
}
async function submit(test: boolean) {
if (missingKey && (test || draft.enabled)) {
setResult(text.missing_api_key);
return;
}
setPending(true);
setResult("");
try {
@ -193,8 +285,7 @@ function ModelEditor({
}
}
} catch (error) {
if (active.current)
setResult(error instanceof Error ? error.message : text.failed);
if (active.current) setResult(modelManagementError(error, text));
} finally {
if (active.current) setPending(false);
}
@ -220,40 +311,50 @@ function ModelEditor({
>
<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>
<div className="space-y-1">
<label id="model-provider-label">{text.provider}</label>
<Select
value={draft.provider}
onValueChange={changeProvider}
disabled={pending || providers.length === 0}
>
<SelectTrigger
aria-labelledby="model-provider-label"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{providers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.custom_endpoint
? text.customProvider
: item.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{provider?.custom_endpoint && (
<label className="block space-y-1">
<span>{text.endpoint}</span>
<Input
type="url"
required
value={draft.base_url ?? ""}
onChange={(e) => {
setDraft({ ...draft, base_url: e.target.value });
setKey("");
setConnectionChanged(true);
setClearKey(false);
setResult("");
}}
/>
</label>
)}
{model && (connectionChanged || !sameConnection) && (
<p role="status">{text.providerChanged}</p>
)}
<label className="block space-y-1">
<span>{text.modelId}</span>
<Input
@ -267,14 +368,21 @@ function ModelEditor({
/>
</label>
<label className="block space-y-1">
<span>API Key</span>
<span>{text.apiKey}</span>
<Input
type="password"
autoComplete="new-password"
disabled={clearKey}
required={
requiresKey && draft.enabled && !canKeepKey && !clearKey
}
value={key}
placeholder={
model?.has_api_key ? text.keepKey : text.optionalKey
canKeepKey
? text.keepKey
: requiresKey
? text.requiredKey
: text.optionalKey
}
onChange={(e) => {
setKey(e.target.value);
@ -282,7 +390,7 @@ function ModelEditor({
}}
/>
</label>
{model?.has_api_key && (
{canKeepKey && (
<label className="flex items-center gap-2">
<input
type="checkbox"
@ -296,50 +404,101 @@ function ModelEditor({
{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>
<details>
<summary className="cursor-pointer">{text.advanced}</summary>
<div className="space-y-3 pt-3">
{model && (
<label className="block space-y-1">
<span>{text.name}</span>
<Input value={draft.name} disabled />
</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="flex items-center gap-2">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => {
setDraft({ ...draft, enabled: e.target.checked });
setResult("");
}}
/>
{text.enabled}
</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>
<div className="space-y-1">
<label id="model-vision-label">{text.vision}</label>
<Select
value={
draft.supports_vision === null
? "auto"
: String(draft.supports_vision)
}
onValueChange={(value) => {
setDraft({
...draft,
supports_vision:
value === "auto" ? null : value === "true",
});
setResult("");
}}
>
<SelectTrigger aria-labelledby="model-vision-label">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{text.providerDefault}
</SelectItem>
<SelectItem value="true">{text.enable}</SelectItem>
<SelectItem value="false">{text.disable}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</details>
</fieldset>
</div>
{result && (
@ -360,13 +519,17 @@ function ModelEditor({
type="button"
variant="outline"
disabled={
pending || !draft.name || !draft.model || !draft.base_url
pending ||
!provider ||
!draft.model ||
missingKey ||
(provider.custom_endpoint && !draft.base_url)
}
onClick={() => void submit(true)}
>
{text.test}
</Button>
<Button type="submit" disabled={pending}>
<Button type="submit" disabled={pending || !provider}>
{pending ? text.working : text.save}
</Button>
</DialogFooter>

View File

@ -1828,7 +1828,7 @@ export const enUS: Translations = {
enable: "Enable",
disable: "Disable",
formDescription:
"Connect an OpenAI-compatible endpoint. Testing sends a short streaming tool-call request and may incur provider charges.",
"Choose a provider and enter its API key and model ID. Testing sends a short streaming tool-call request and may incur provider charges.",
provider: "Provider",
name: "Unique name",
displayName: "Display name",
@ -1850,6 +1850,24 @@ export const enUS: Translations = {
"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.",
customProvider: "Custom OpenAI-compatible",
apiKey: "API Key",
advanced: "Advanced settings",
providerDefault: "Provider default",
requiredKey: "Enter the provider API key",
providerChanged:
"Connection changed. Enter a new API key; the saved key will not be reused.",
missing_api_key:
"This provider requires an API key. Enter a key, or disable the model before clearing its key.",
authentication_failed:
"Authentication failed. Check the API key and the provider region or plan.",
response_limit: "The test response exceeded its safe output limit.",
revisionConflict:
"The model changed or its name is reserved. Reload the list and reopen the editor. Your draft has been kept.",
keyOrCapabilities:
"Check the API key and capability overrides. Preset models must be disabled before removing their key.",
invalidModel:
"Invalid model configuration. Check the provider, model ID and advanced settings.",
},
users: {
title: "User management",

View File

@ -1564,6 +1564,18 @@ export interface Translations {
success: string;
tool_call_missing: string;
connection_failed: string;
customProvider: string;
apiKey: string;
advanced: string;
providerDefault: string;
requiredKey: string;
providerChanged: string;
missing_api_key: string;
authentication_failed: string;
response_limit: string;
revisionConflict: string;
keyOrCapabilities: string;
invalidModel: string;
};
users: {
title: string;

View File

@ -1716,8 +1716,8 @@ export const zhCN: Translations = {
enable: "启用",
disable: "停用",
formDescription:
"接入 OpenAI 兼容接口。测试将发送简短的流式工具调用请求,可能产生模型调用费用。",
provider: "接口类型",
"选择厂商并填写 API Key 和模型 ID。测试将发送简短的流式工具调用请求,可能产生模型调用费用。",
provider: "模型厂商",
name: "唯一名称",
displayName: "显示名称",
endpoint: "接口基础地址",
@ -1738,6 +1738,22 @@ export const zhCN: Translations = {
"接口已响应,但未返回工具调用,请检查模型的工具调用能力。",
connection_failed:
"连接测试失败,请检查接口地址、凭据、模型 ID 以及流式输出和工具调用支持。",
customProvider: "自定义 OpenAI-compatible",
apiKey: "API Key",
advanced: "高级设置",
providerDefault: "厂商默认值",
requiredKey: "填写此厂商的 API Key",
providerChanged: "已切换连接,请重新填写 API Key;不会复用原连接的密钥。",
missing_api_key:
"此厂商需要 API Key。请填写密钥,或先停用模型再清除密钥。",
authentication_failed:
"认证失败,请检查 API Key 是否属于所选厂商、地域或套餐。",
response_limit: "测试响应超出输出限制。",
revisionConflict:
"模型已变更或名称已被占用。请重新加载列表并打开编辑器;当前草稿已保留。",
keyOrCapabilities:
"请检查 API Key 和能力覆盖配置。清除预设模型密钥前须先停用模型。",
invalidModel: "模型配置无效,请检查厂商、模型 ID 和高级设置。",
},
users: {
title: "用户管理",

View File

@ -5,12 +5,13 @@ import { getBackendBaseURL } from "@/core/config";
export type ModelDraft = {
name: string;
display_name: string;
provider: "openai-compatible";
provider: string;
model: string;
base_url: string;
base_url?: string | null;
api_key?: string;
enabled: boolean;
supports_vision: boolean;
supports_vision: boolean | null;
reasoning?: Record<string, unknown> | null;
context_window: number | null;
max_tokens: number | null;
};
@ -20,6 +21,20 @@ export type ManagedModel = Omit<ModelDraft, "api_key"> & {
has_api_key: boolean;
conflict?: boolean;
};
export type ManagedModelProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
custom_endpoint: boolean;
};
export const loadManagedModelProviders = (signal?: AbortSignal) =>
request<{ providers: ManagedModelProvider[] }>(
"/providers",
"GET",
undefined,
signal,
);
export type ConfigModel = {
name: string;
display_name: string;
@ -71,18 +86,27 @@ export const testManagedModel = (
) =>
request<{
ok: boolean;
message: "success" | "tool_call_missing" | "connection_failed";
message:
| "success"
| "tool_call_missing"
| "connection_failed"
| "authentication_failed"
| "missing_api_key"
| "response_limit";
}>("/test", "POST", body, signal);
export function modelDraft(model?: ManagedModel): ModelDraft {
return {
name: model?.name ?? "",
display_name: model?.display_name ?? "",
provider: "openai-compatible",
provider: model?.provider ?? "openai",
model: model?.model ?? "",
base_url: model?.base_url ?? "",
...(model?.provider === "openai-compatible"
? { base_url: model.base_url ?? "" }
: {}),
enabled: model?.enabled ?? true,
supports_vision: model?.supports_vision ?? false,
supports_vision: model?.supports_vision ?? null,
...(model?.reasoning ? { reasoning: model.reasoning } : {}),
context_window: model?.context_window ?? null,
max_tokens: model?.max_tokens ?? null,
};

View File

@ -21,16 +21,39 @@ test("administrator adds, tests, edits and disables a shared model", async ({
},
});
});
await page.route("**/api/managed-models/providers", (route) =>
route.fulfill({
json: {
providers: [
{
id: "openai",
display_name: "OpenAI",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "openai-compatible",
display_name: "Custom OpenAI-compatible",
requires_api_key: false,
custom_endpoint: true,
},
],
},
}),
);
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;
const name = config.name || `${config.provider}-${config.model}`;
models = [
{
...config,
name,
source: "managed",
display_name: config.display_name || `${config.provider} model`,
has_api_key:
api_key === undefined ? (models[0]?.has_api_key ?? false) : !!api_key,
revision: String(saves.length),
@ -44,11 +67,10 @@ test("administrator adds, tests, edits and disables a shared model", async ({
});
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.getByText("Advanced settings", { exact: true }).click();
await page.getByLabel("Display name").fill("Demo model");
await page
.getByRole("button", { name: "Test connection", exact: true })
.click();
@ -68,6 +90,7 @@ test("administrator adds, tests, edits and disables a shared model", async ({
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.getByText("Advanced settings", { exact: true }).click();
await page.getByLabel("Display name").fill("Updated model");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(

View File

@ -9,22 +9,30 @@ import {
} from "@testing-library/react";
import { ModelSettingsPage } from "@/components/workspace/settings/model-settings-page";
import { GatewayApiError } from "@/core/api/errors";
import { useAuth } from "@/core/auth/AuthProvider";
import { enUS } from "@/core/i18n/locales/en-US";
import { loadModels } from "@/core/models/api";
import { useModels } from "@/core/models/hooks";
import type * as Management from "@/core/models/management";
import {
loadManagedModels,
loadManagedModelProviders,
modelDraft,
saveManagedModel,
testManagedModel,
type ManagedModel,
} from "@/core/models/management";
import { isStaticWebsiteOnly } from "@/core/static-mode";
rs.mock("@/core/models/api", () => ({ loadModels: rs.fn() }));
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: rs.fn() }));
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(),
loadManagedModelProviders: rs.fn(),
saveManagedModel: rs.fn(),
testManagedModel: rs.fn(),
}));
@ -32,6 +40,7 @@ rs.mock("@/core/models/management", () => ({
const existing: ManagedModel = {
...modelDraft(),
name: "custom",
provider: "openai-compatible",
model: "test",
base_url: "https://example.com/v1",
display_name: "Custom",
@ -45,7 +54,34 @@ const auth = (role: "admin" | "user") =>
>;
beforeEach(() => {
rs.mocked(isStaticWebsiteOnly).mockReturnValue(false);
rs.mocked(loadModels).mockResolvedValue({
models: [],
token_usage: { enabled: false },
});
rs.mocked(useAuth).mockReturnValue(auth("admin"));
rs.mocked(loadManagedModelProviders).mockResolvedValue({
providers: [
{
id: "openai",
display_name: "OpenAI",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "anthropic",
display_name: "Anthropic",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "openai-compatible",
display_name: "Custom OpenAI-compatible",
requires_api_key: false,
custom_endpoint: true,
},
],
});
rs.mocked(loadManagedModels).mockResolvedValue({ models: [existing] });
rs.mocked(saveManagedModel).mockResolvedValue({
...existing,
@ -57,14 +93,29 @@ afterEach(() => {
rs.clearAllMocks();
});
function mount() {
function CurrentModelPicker() {
const { models } = useModels();
return (
<select aria-label="Chat model picker">
{models.map((model) => (
<option key={model.name}>{model.display_name ?? model.name}</option>
))}
</select>
);
}
function mount(withPicker = false) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["models"], { models: [] });
client.setQueryData(["models"], {
models: [],
token_usage: { enabled: false },
});
render(
<QueryClientProvider client={client}>
<ModelSettingsPage />
{withPicker && <CurrentModelPicker />}
</QueryClientProvider>,
);
return client;
@ -75,6 +126,7 @@ test("non-admin cannot load credentials configuration or add models", () => {
mount();
expect(screen.queryByRole("button", { name: "Add model" })).toBeNull();
expect(loadManagedModels).not.toHaveBeenCalled();
expect(loadManagedModelProviders).not.toHaveBeenCalled();
});
test("editing preserves saved key and refreshes chat catalog after saving", async () => {
@ -132,3 +184,269 @@ test("connection test never saves and supports explicit key removal", async () =
);
expect(saveManagedModel).not.toHaveBeenCalled();
});
test("new preset needs only provider, API key and arbitrary model ID", async () => {
const client = mount();
await waitFor(() =>
expect(
screen
.getByRole("button", { name: "Add model" })
.hasAttribute("disabled"),
).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Add model" }));
expect(screen.getByRole("combobox", { name: "Provider" }).textContent).toBe(
"OpenAI",
);
expect(screen.queryByLabelText("Base URL")).toBeNull();
expect(screen.queryByLabelText("Unique name")).toBeNull();
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
fireEvent.change(screen.getByLabelText("Model ID"), {
target: { value: "org/任意 model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
const body = rs.mocked(saveManagedModel).mock.calls[0]![0];
expect(body.config).toMatchObject({
provider: "openai",
api_key: "test-key",
model: "org/任意 model",
enabled: true,
});
expect(body.config).not.toHaveProperty("base_url");
await waitFor(() =>
expect(client.getQueryState(["models"])?.isInvalidated).toBe(true),
);
});
async function selectProvider(name: string) {
fireEvent.pointerDown(screen.getByRole("combobox", { name: "Provider" }), {
button: 0,
ctrlKey: false,
pointerType: "mouse",
});
fireEvent.click(await screen.findByRole("option", { name }));
}
test("switching providers clears test status and cannot reuse a saved key", async () => {
rs.mocked(testManagedModel).mockResolvedValue({
ok: true,
message: "success",
});
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
await screen.findByText(enUS.settings.models.success);
await selectProvider("Anthropic");
expect(screen.queryByText(enUS.settings.models.success)).toBeNull();
expect(screen.queryByLabelText("Base URL")).toBeNull();
expect(screen.getByLabelText<HTMLInputElement>("API Key").value).toBe("");
expect(
screen.queryByPlaceholderText(enUS.settings.models.keepKey),
).toBeNull();
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "new-provider-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0]).toMatchObject({
config: {
name: "custom",
provider: "anthropic",
api_key: "new-provider-key",
},
expected_revision: "v1",
});
expect(
rs.mocked(saveManagedModel).mock.calls[0]![0].config,
).not.toHaveProperty("base_url");
});
test("custom endpoint is separate and allows no authentication", async () => {
mount();
await waitFor(() =>
expect(
screen
.getByRole("button", { name: "Add model" })
.hasAttribute("disabled"),
).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Add model" }));
await selectProvider("Custom OpenAI-compatible");
fireEvent.change(screen.getByLabelText("Base URL"), {
target: { value: "http://localhost:9000/v1" },
});
fireEvent.change(screen.getByLabelText("Model ID"), {
target: { value: "local-model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0].config).toMatchObject({
provider: "openai-compatible",
base_url: "http://localhost:9000/v1",
});
});
test("preset edits retain stable ID, leave key blank and preserve advanced overrides", async () => {
rs.mocked(loadManagedModels).mockResolvedValue({
models: [
{
...existing,
provider: "openai",
base_url: null,
context_window: 12345,
supports_vision: true,
},
],
});
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
expect(screen.getByLabelText<HTMLInputElement>("Unique name").disabled).toBe(
true,
);
expect(screen.queryByLabelText("Base URL")).toBeNull();
fireEvent.change(screen.getByLabelText("Model ID"), {
target: { value: "new/arbitrary-model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0].config).toMatchObject({
name: "custom",
model: "new/arbitrary-model",
supports_vision: true,
context_window: 12345,
});
expect(
rs.mocked(saveManagedModel).mock.calls[0]![0].config,
).not.toHaveProperty("api_key");
});
test("preset key clearing requires disabling and does not save a password mask", async () => {
rs.mocked(loadManagedModels).mockResolvedValue({
models: [{ ...existing, provider: "openai", base_url: null }],
});
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: "Save" }));
expect(
await screen.findByText(enUS.settings.models.missing_api_key),
).toBeTruthy();
expect(saveManagedModel).not.toHaveBeenCalled();
fireEvent.click(screen.getByLabelText("Enabled"));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0].config).toMatchObject({
api_key: "",
enabled: false,
});
});
test("enable/disable uses the existing revision and invalidates both catalogs", async () => {
const client = mount();
fireEvent.click(await screen.findByRole("button", { name: "Disable" }));
await waitFor(() => expect(saveManagedModel).toHaveBeenCalledTimes(1));
expect(rs.mocked(saveManagedModel).mock.calls[0]![0]).toMatchObject({
config: { name: "custom", enabled: false },
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),
);
expect(loadManagedModels).toHaveBeenCalledTimes(2);
});
test("provider loading failures are visible and retryable", async () => {
rs.mocked(loadManagedModelProviders).mockRejectedValueOnce(
new Error("offline"),
);
mount();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(
screen.getByRole<HTMLButtonElement>("button", { name: "Add model" })
.disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
await waitFor(() =>
expect(
screen.getByRole<HTMLButtonElement>("button", { name: "Add model" })
.disabled,
).toBe(false),
);
});
test("test failures display localized protocol feedback without saving", async () => {
rs.mocked(testManagedModel).mockResolvedValueOnce({
ok: false,
message: "authentication_failed",
});
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
expect(
await screen.findByText(enUS.settings.models.authentication_failed),
).toBeTruthy();
expect(saveManagedModel).not.toHaveBeenCalled();
});
test("saving refreshes an active existing model selector", async () => {
mount(true);
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
rs.mocked(loadModels).mockResolvedValue({
models: [
{
name: "custom",
model: "test",
display_name: "Updated picker model",
id: "custom",
supports_thinking: false,
supports_reasoning_effort: false,
},
],
token_usage: { enabled: false },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(
await screen.findByRole("option", { name: "Updated picker model" }),
).toBeTruthy();
expect(loadModels).toHaveBeenCalled();
expect(loadManagedModels).toHaveBeenCalledTimes(2);
});
test("static mode does not request management or provider metadata", () => {
rs.mocked(isStaticWebsiteOnly).mockReturnValue(true);
mount();
expect(loadManagedModels).not.toHaveBeenCalled();
expect(loadManagedModelProviders).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "Add model" })).toBeNull();
});
test("revision/name conflicts are localized and the unsaved draft survives", async () => {
rs.mocked(saveManagedModel).mockRejectedValueOnce(
new GatewayApiError({
message: "private server detail",
rawMessage: "private server detail",
status: 409,
code: null,
params: {},
}),
);
mount();
fireEvent.click(await screen.findByRole("button", { name: "Edit model" }));
fireEvent.change(screen.getByLabelText("Model ID"), {
target: { value: "draft-model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(
await screen.findByText(enUS.settings.models.revisionConflict),
).toBeTruthy();
expect(screen.getByLabelText<HTMLInputElement>("Model ID").value).toBe(
"draft-model",
);
expect(screen.queryByText("private server detail")).toBeNull();
});