mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
8319e3384b
commit
e98179a9d3
39
README.md
39
README.md
@ -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
|
||||
|
||||
19
README_zh.md
19
README_zh.md
@ -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)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 配置
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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"] = {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
29
backend/tests/test_managed_presets_live.py
Normal file
29
backend/tests/test_managed_presets_live.py
Normal 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()
|
||||
411
backend/tests/test_managed_provider_presets.py
Normal file
411
backend/tests/test_managed_provider_presets.py
Normal 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"
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "用户管理",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user