From e98179a9d33e7d5f2f85e1a8da46638fd80d8a18 Mon Sep 17 00:00:00 2001 From: Eilen Shin <136898293+Eilen6316@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:14:23 +0800 Subject: [PATCH] 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 --- README.md | 39 +- README_zh.md | 19 + backend/app/gateway/AGENTS.md | 10 +- backend/app/gateway/routers/managed_models.py | 104 ++++- backend/docs/CONFIGURATION.md | 161 +++++++ .../harness/deerflow/config/AGENTS.md | 4 + .../config/managed_model_providers.py | 123 +++++- .../harness/deerflow/config/managed_models.py | 131 +++++- .../harness/deerflow/models/AGENTS.md | 43 +- .../deerflow/models/patched_minimax.py | 5 + backend/tests/AGENTS.md | 4 + .../tests/blocking_io/test_managed_models.py | 37 ++ backend/tests/test_managed_deepseek.py | 6 +- backend/tests/test_managed_models.py | 8 +- backend/tests/test_managed_presets_live.py | 29 ++ .../tests/test_managed_provider_presets.py | 411 ++++++++++++++++++ frontend/AGENTS.md | 4 + .../settings/model-settings-page.tsx | 345 +++++++++++---- frontend/src/core/i18n/locales/en-US.ts | 20 +- frontend/src/core/i18n/locales/types.ts | 12 + frontend/src/core/i18n/locales/zh-CN.ts | 20 +- frontend/src/core/models/management.ts | 38 +- frontend/tests/e2e/model-management.spec.ts | 29 +- .../settings/model-settings-page.dom.test.tsx | 322 +++++++++++++- 24 files changed, 1729 insertions(+), 195 deletions(-) create mode 100644 backend/tests/test_managed_presets_live.py create mode 100644 backend/tests/test_managed_provider_presets.py diff --git a/README.md b/README.md index 525b916c7c..5f6fcec002 100644 --- a/README.md +++ b/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 diff --git a/README_zh.md b/README_zh.md index 437351e457..8b8abd95da 100644 --- a/README_zh.md +++ b/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)。 + ## 快速开始 ### 配置 diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 62a2e72d9c..c06e6e66e1 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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. diff --git a/backend/app/gateway/routers/managed_models.py b/backend/app/gateway/routers/managed_models.py index 4ca5eaa1cb..8496e1f0d6 100644 --- a/backend/app/gateway/routers/managed_models.py +++ b/backend/app/gateway/routers/managed_models.py @@ -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"} diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 212e1e00db..53a33f76ee 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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 diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 443f8aaef6..7d7bdba07e 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -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`. diff --git a/backend/packages/harness/deerflow/config/managed_model_providers.py b/backend/packages/harness/deerflow/config/managed_model_providers.py index 81c6ebfbb7..a69e143ccb 100644 --- a/backend/packages/harness/deerflow/config/managed_model_providers.py +++ b/backend/packages/harness/deerflow/config/managed_model_providers.py @@ -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)} diff --git a/backend/packages/harness/deerflow/config/managed_models.py b/backend/packages/harness/deerflow/config/managed_models.py index cfa01479ef..0190a059ee 100644 --- a/backend/packages/harness/deerflow/config/managed_models.py +++ b/backend/packages/harness/deerflow/config/managed_models.py @@ -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) diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 2e80a14ed4..56fcf757a5 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -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`. diff --git a/backend/packages/harness/deerflow/models/patched_minimax.py b/backend/packages/harness/deerflow/models/patched_minimax.py index 2e45d0430e..81486b46be 100644 --- a/backend/packages/harness/deerflow/models/patched_minimax.py +++ b/backend/packages/harness/deerflow/models/patched_minimax.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"] = { diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index 5bf609c5c7..6e57519a0e 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -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. diff --git a/backend/tests/blocking_io/test_managed_models.py b/backend/tests/blocking_io/test_managed_models.py index bbade961c0..f2b564b20a 100644 --- a/backend/tests/blocking_io/test_managed_models.py +++ b/backend/tests/blocking_io/test_managed_models.py @@ -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] diff --git a/backend/tests/test_managed_deepseek.py b/backend/tests/test_managed_deepseek.py index d787da4977..0c5129db9a 100644 --- a/backend/tests/test_managed_deepseek.py +++ b/backend/tests/test_managed_deepseek.py @@ -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() diff --git a/backend/tests/test_managed_models.py b/backend/tests/test_managed_models.py index 73fa921c06..b937a2fc9d 100644 --- a/backend/tests/test_managed_models.py +++ b/backend/tests/test_managed_models.py @@ -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 diff --git a/backend/tests/test_managed_presets_live.py b/backend/tests/test_managed_presets_live.py new file mode 100644 index 0000000000..df32863660 --- /dev/null +++ b/backend/tests/test_managed_presets_live.py @@ -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() diff --git a/backend/tests/test_managed_provider_presets.py b/backend/tests/test_managed_provider_presets.py new file mode 100644 index 0000000000..605a3a561b --- /dev/null +++ b/backend/tests/test_managed_provider_presets.py @@ -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" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index febafb7d32..de600cfb93 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/src/components/workspace/settings/model-settings-page.tsx b/frontend/src/components/workspace/settings/model-settings-page.tsx index e79c7621e2..871d3612ad 100644 --- a/frontend/src/components/workspace/settings/model-settings-page.tsx +++ b/frontend/src/components/workspace/settings/model-settings-page.tsx @@ -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(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() { ) : (
-
- {catalog.isLoading &&

{text.loading}

} - {catalog.error && ( + {(catalog.isLoading || providers.isLoading) && ( +

{text.loading}

+ )} + {(catalog.error ?? providers.error) && (

{text.failed}

@@ -97,6 +140,16 @@ export function ModelSettingsPage() { {model.display_name || model.name}

+ {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 ?? []} /> )}

@@ -146,16 +200,19 @@ function ModelEditor({ model, close, saved, + providers, }: { model?: ManagedModel; close: () => void; saved: () => Promise; + 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({ >
-

{text.provider}: OpenAI compatible

- - - +
+ + +
+ {provider?.custom_endpoint && ( + + )} + {model && (connectionChanged || !sameConnection) && ( +

{text.providerChanged}

+ )} - {model?.has_api_key && ( + {canKeepKey && (
{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} - diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 188f9b481c..ede77b9012 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index b9fb96d188..5842bc1b14 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index ff23cfea3c..0bed60f582 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "用户管理", diff --git a/frontend/src/core/models/management.ts b/frontend/src/core/models/management.ts index e68fd4284f..f419d014ce 100644 --- a/frontend/src/core/models/management.ts +++ b/frontend/src/core/models/management.ts @@ -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 | null; context_window: number | null; max_tokens: number | null; }; @@ -20,6 +21,20 @@ export type ManagedModel = Omit & { 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, }; diff --git a/frontend/tests/e2e/model-management.spec.ts b/frontend/tests/e2e/model-management.spec.ts index 508d98d359..e2e3803060 100644 --- a/frontend/tests/e2e/model-management.spec.ts +++ b/frontend/tests/e2e/model-management.spec.ts @@ -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( diff --git a/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx index 91634e3bc8..aadbdc72ed 100644 --- a/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/settings/model-settings-page.dom.test.tsx @@ -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("@/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 ( + + ); +} + +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( + {withPicker && } , ); 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("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("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("button", { name: "Add model" }) + .disabled, + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Reload" })); + await waitFor(() => + expect( + screen.getByRole("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("Model ID").value).toBe( + "draft-model", + ); + expect(screen.queryByText("private server detail")).toBeNull(); +});