Files
dsh_shenxian/dsh-server-docs/04-调整方案/87-模型设置页-用户自配厂家与共享开关.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

218 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 87 · 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关
- 日期:2026-09-13
- 状态:✅ **已上线并端到端验证**(后端已部署 + 插件 **0.3.11** 已铺发 admin/guest + 角色补丁已刷新)
- 触发(用户口径,2026-09-13 22:5x 三条连续):
1. 官方「设置 → 模型」页在平台环境**用不了** ⇒ **改走平台自建页**;
2. 条目要能**各自开关**(不再互斥),平台共享模型**也要列进来**、用户可开关;
3. 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 平台只负责把「已启用」的都配好。
- 落点:`src/db/{schema,types,repo,adapter,sqlite,pg}.ts`、**新文件** `src/web/model-landing.ts`、`src/web/server.ts`、`src/web/routes/auth.ts`、`poc/business-plugins/lib/client.js`(**0.3.11**)、`ensure-role-profile-patch.cjs`
> **TL;DR**|① 官方模型页必须对**所有人(含 admin)**禁用 —— 它要求 Host settings 镜像,而平台是"浏览器经域名访问远程服务器"⇒ `isLoopback=false` ⇒ persistence 降级 `memory` ⇒ 页面必报「加载提供方目录失败」;② 用户自配改走**平台自建「设置 → 模型设置」**(插件 0.3.11),后端 `/api/me/keys` 等 4 条路由;③ 平台在 **spawn 时**把「已启用条目」落地成实例的 `$DSH_HOME/.credentials.yaml`(`refs.<REF>`)与 `$DSH_HOME/settings.yaml`(`llm-pi-ai.providers.<route>`);④ 落地层**只碰自己写过的**(平台托管清单 `/opt/dsh/state/model-landing/<userId>.json`),用户自己配的绝不覆盖、绝不删除;⑤ 顺手修掉两个真缺陷:角色补丁脚本的**整文件覆盖会抹掉 admin 另外两个平台块**、以及归档 86 重构后遗留的**陈旧断言**。
---
## 一、为什么不给用户官方「模型」页(本轮取证)
| 事实 | 证据 |
|---|---|
| 官方页要求 **Host settings 镜像** | `dsh-client-ui-settings` 的持久化判定 `isLoopback = transport.ownsHost \|\| pageLocation === undefined \|\| isLoopbackHostname(page)`;平台是浏览器经域名访问远程服务器 ⇒ **三条都不成立** |
| **官方 README 原文(权威出处)** | 该包 `README.md:97`:**"Non-loopback pages get no durable settings — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API."** |
| 后果 | persistence 降级为 `memory` ⇒ `ensure()` 直接返回不读 ⇒ 页面必报 `加载提供方目录失败: settings are unavailable in this browser` |
| ⇒ 处置 | `ensure-role-profile-patch.cjs` 对 **non-admin 与 admin 都**写入 `- id: ui-settings-models / disabled: true` |
### 1.1 与「03-路线图 决策 3」的辨析(**两层,都成立,别再互相推翻**)
`03-路线图与待办.md` 决策 3 记着 2026-09-09 的实测:「`proxy.ts` 把 Host 伪装成 `127.0.0.1:<port>` ⇒ dsh 的 /api trust fence 与 loopback 特权校验**全放行** ⇒ Settings 面板在门户代理后可用」。
两者**不矛盾,因为不是同一层**:
| 层 | 谁在判 | 平台的表现 |
|---|---|---|
| **host 侧** `/api` trust fence / loopback 特权校验 | 看**请求的 Host** | `proxy.ts:108` 把 `out.host` 改写成 `127.0.0.1:<port>` ⇒ **放行**(09-09 观察到的就是这一层,故 `modelCatalog` 能 200) |
| **浏览器侧** settings 持久化 | 看**浏览器页面的 location**(`isLoopbackHostname(page)`) | 页面是 `https://<user>.alotbuy.com`(真实域名)⇒ **不是 loopback** ⇒ `unavailable`、只读、永不落盘 |
⇒ 所以「目录接口可达」**不等于**「保存能生效」(归档 85 §九 只验证了前者,本轮补上后者并因此**推翻**了 85 §九「放开官方页」的口径)。
> ⚠️ 这条判据的**唯一落盘处是代码注释**(`ensure-role-profile-patch.cjs` 的 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK`)——本档即其正式出处,别再只写在注释里。
## 二、口径(用户定,**不得再当选择题**)
1. 条目**各自开关、可同时启用**(互斥已删);
2. admin 配的**平台共享模型也列入**列表,用户可开关(`users.shared_model_enabled`,用户侧偏好,不碰 admin 配置);
3. 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 平台只负责把「已启用」的都配好。
## 三、官方 schema 取证(**本轮最关键的外部事实**,2026-09-13 读官方包实测)
读的是服务器上 `@deepseek-ai/[email protected]` 的 `lib/index.js` 与 `lib/types/*.d.ts`。**字段名与 85 §九 的措辞不同,按本节为准**:
| 项 | 结论 |
|---|---|
| 设置分区名 | **`llm-pi-ai`**(`const NS = "llm-pi-ai"`) |
| 结构 | `llm-pi-ai:` → `providers:`(缩进 2)→ `<route>:`(缩进 4)→ 字段(缩进 6) |
| 凭据字段 | **`apiKeyEnv`**(值 = **凭据 ref 名**,schema 标 `role("credential-ref")`)——**不是** `apiKey` |
| endpoint 字段 | **`baseURL`** |
| 协议字段 | **`api`** ——⚠️ **不是** `protocol`。合法值只有三个:`openai-completions` / `openai-responses` / `anthropic-messages`(`PROTOCOLS` 的键,**顺序即默认优先级**) |
| 模型清单 | `models: [{ id, … }]`,`id` 必填 |
| 其它可用字段 | `displayName` / `timeoutMs` / `transport` / `headers` / `retryPolicy` … |
| ⛔ 会直接抛错的字段 | `provider`(已移到 dict 键)、`maxRetries` / `maxRetryDelayMs`(已移除) |
| 凭据 ref 名语法 | `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`(POSIX shell 标识符) |
| 插件是否在跑 | ⚠️ **是**:`@deepseek-ai/dsh-llm-pi-ai` 就在 base profile 的 `@deepseek-ai/dsh-base` bundle 列表里 ⇒ 写 `llm-pi-ai.providers` **真的会被加载**(另:内置 DeepSeek 走的是 `dsh-llm-deepseek`,其 route 是 `deepseek-official`,与本节无关) |
| 为什么必须写文件 | 官方页不可用(§一),而设置就是**文件**(`dsh-settings-file`:`<harness home>/settings.yaml`)⇒ 平台只能自己写 |
## 四、设计
### 4.1 DB 层(迁移 **V6** + 语义重定义)
`SQLITE_V6` / `PG_V6`:
```sql
ALTER TABLE credential_vault ADD COLUMN route TEXT; -- settings.yaml 的 dict 键
ALTER TABLE credential_vault ADD COLUMN base_url TEXT; -- 空 = 内置 DeepSeek
ALTER TABLE credential_vault ADD COLUMN api TEXT; -- 线协议
ALTER TABLE credential_vault ADD COLUMN models TEXT; -- 模型 id 的 JSON 数组
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
```
- `setCredentialKey(...)`:签名改**选项对象**(`CredentialKeyMeta`),并**删掉** `UPDATE … SET enabled = 0`(老实现"写新条目就把别的全关",与口径①冲突)。
- **新增/改**:`listEnabledCredentialKeys` · `toggleCredentialKey` · `getSharedModelEnabled` · `setSharedModelEnabled` · `listCredentialLandingRows`(多返回**密文**,**仅供落地层** —— `CredentialKey` 会被 `/api/me/keys` 原样返回给浏览器,所以密文不能挂在它上面)。
- 🔴 **`getEnabledCredentialKeyRef` 语义重定义**(本轮最重要的一处正确性修复):老实现是 `WHERE user_id=? AND enabled=1` 且**无 `ORDER BY`**,当时靠"写入时全关"保证唯一;互斥删掉后**可能命中多行 ⇒ 任取一条** ⇒ `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会随机飘。现钉死为「**已启用、且是内置 DeepSeek(`base_url` 为空)的最新一条**」。
- `selectCredentialKey` 保留函数名但**退化为"只开这一个"**(不再先全关);路由侧已标记 `@deprecated`。
### 4.2 落地层(**新文件 `src/web/model-landing.ts`**,纯文本变换、无 IO)
为什么单独成文件:它干的是**改写实例自己的配置文件**,错了**不报错**、只会静默少一个厂家 ⇒ 必须能被 `scripts/verify-model-landing.mjs` 用固定样例钉住(13 组断言)。
| 函数 | 作用 |
|---|---|
| `routeRef(route)` | `<ROUTE 大写、非字母数字折成 _>_API_KEY`,**保证**匹配官方 `REF_PATTERN` |
| `refForEntry({route,baseUrl})` | 内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)` |
| `reconcileCredentials(text, desired, managed)` | 对账 `refs:` 段:新增/刷新**自己写过的**、删除**自己写过的**、**用户自己写的绝不碰**;认不出的布局**原样返回** |
| `reconcileSettings(text, desired, managed)` | 对账 `llm-pi-ai.providers.<route>`:用**成对标记** `# dshs:model-route <route> begin/end` 夹住自己那块 ⇒ 关掉厂家 = 删掉自己那对标记之间;**不解析、不重排**别人的 YAML |
| `readRefValue(text, ref)` | 读文件里某 ref 的当前值 —— 只用于**一次性交接**(见 4.3) |
| `parseModels(json)` | 宽容解析(坏值只丢弃,绝不让一个坏值把整份 settings.yaml 变成 dsh 拒绝加载的文档) |
**安全约束**(与既有 `ensureRefInCredentials` 同族):只认 `version: 1` 的凭据文档;备份**必须落 `/opt/dsh/backups/`**(⛔ 绝不能进 home —— dsh 用 chokidar watch 整个 home,一个实例读不了的文件会让它 `EACCES` **崩溃循环**,85 §9.4 真踩过);写完 `chown` 给 home 属主;**只在真有变化时写盘**(避免每次 spawn 白抖一下 watcher)。
### 4.3 server.ts:spawn 时落地 + 托管清单
- `resolveApiKey(userId)` 恒返回 **`null`**(不注入 env —— env 会让 `assertUnshadowed()` 把用户保存打回错误,85 §9.2);**失败时退回 env 注入保底**(宁可暂时用共享 key,也不能因为写文件出错让实例起不来)。
- `landModels(userId)`:取「自己的已启用条目」+(开关打开时)「admin 的已启用条目」,**自己的优先**(同 ref 只留第一条)⇒ 用户自己配的 key 永远赢过共享的。
- 托管清单 `/opt/dsh/state/model-landing/<userId>.json` = `{refs, routes}`(**不在 home**)。
- 🔴 **一次性交接**:老实现把共享 key 直接写进 `refs.DEEPSEEK_API_KEY` 却没有清单 ⇒ 新逻辑会把它当"用户自己写的"而**永不清理** ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立,验收③会挂)。所以首次运行(无声清单)时,**仅当**文件里那行**确实等于平台共享 key 的明文**才认领它;不相等 = 用户自己配的,绝不碰。
### 4.4 接口(`src/web/routes/auth.ts`)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/me/keys` | 回 `keys`(含 `route`/`baseUrl`/`api`/`models`)+ `effective` + `shared`(含 `count`/`enabled`)+ **`sharedModelEnabled`** + **`protocols`**(协议枚举由后端下发,免得前端各写一份常量然后漂) |
| POST | `/api/me/keys` | 收 `{name, apiKey, route?, baseUrl?, api?, models?}`;`baseUrl` 空 = 内置。**展示名允许中文**(寻址用的是 route);自定义必填 ≥1 个模型;`route` 撞车回 **409** |
| POST | `/api/me/keys/:id/toggle` | 开关**单个**条目(口径①) |
| POST | `/api/me/models/shared` | 平台共享模型开关(口径②,只动自己的偏好) |
| DELETE | `/api/me/keys/:id` | 删除 |
全部改动后都走 `refreshAfterKeyChange`(admin 动共享内容 ⇒ 广播重启;其他人只重启自己)—— 因为**落地发生在 spawn 时**,不重启不生效。
### 4.5 前端(`poc/business-plugins` **0.3.11**)
新增 `settings.section` 分区 **`model-settings`(order 100,全角色可见)**,标签「模型设置」:
- 平台共享模型区块(名称 / 归属 / 条数 + 启停按钮);
- 新增表单(名称 / API Key / Endpoint / 厂家标识 / 协议下拉 / 模型清单,一行一个);
- 「我的模型条目」表格(名称 / 厂家标识 / Endpoint / 协议 / 模型数 / 状态徽章 / 启用·停用·删除);
- 文案明说**「保存后需重启实例生效」**。
UI 只复用既有 `.pa-*`(门户组件家族)类,未新增 CSS —— 「系统管理」那套是照抄门户的,同族复用即可。
## 五、落地与出厂
| 项 | 内容 |
|---|---|
| 代码 | `tar` 管道推 `src/**` + `ensure-role-profile-patch.cjs`(LF 化)→ 服务器 `npm run build`(干净通过)→ `systemctl restart dshs` |
| 迁移 | 重启即应用 V6(实测 `credential_vault` 多出 `route/base_url/api/models`,`users` 多出 `shared_model_enabled`) |
| 插件 | **0.3.11**(48,590 B / md5 `15c8083f5db85b3fce6a8a15db133319`)→ `/opt/dsh/artifacts/` → `ensure-biz-plugins.cjs --all --restart`(两实例 `bundles=6` ✓) |
| 角色补丁 | `node ensure-role-profile-patch.cjs --force --restart`(`--force` 用于刷新旧注释 + 前提校验;**已加回滚点** `/opt/dsh/backups/patch-20260913-234511/`) |
| 本地全套 | `npm run verify` 等价物(Node 22):单测 **49 项 0 失败** + `verify-inject` / `verify-static` / `verify-platform-admin-section` / `verify-mem-model` / `verify-model-landing` **全绿** |
### 🔴 顺带修掉的两个真缺陷(都在本 lane 内)
1. **`ensure-role-profile-patch.cjs` 的整文件覆盖会抹掉 admin 的另外两个平台块**(`disable-hmr` + `workspace-scoped-picker`)。它的 `--force` 升级分支原本是 `writeFileSync(patchPath, block)` —— admin 的 `cordis.patch.yml` 里**住着三个**平台块,一覆盖:disable-hmr 丢掉 = 生产重新连 HMR;picker 块丢掉 = 目录选择器退回"可改任意路径"的无限制版(档案 18 v3 收敛作废)。**本轮实测确认**:修前若触发升级就会写坏(当前只是恰好因"已含 models 行"而未触发)。修法 = 新增 `stripManagedBlock()`,升级只替换**自己那一段**;实机 diff 验证 admin 另两块完整保留。
2. **`verify-mem-model.mjs` 里一条陈旧断言**:档案 86 把 status 主体抽成 `statusForUser(app,user)` 后,`request.user!.id` 变成 `user.id` ⇒ 该断言一直红着(`npm run verify` 长期失败)。断言意图不变,改为不绑定形参名。
## 六、验收(用户点名的四件事)
| # | 验收项 | 证据 |
|---|---|---|
| **①** | 官方「模型」栏**两账号都不再出现** | 两账号 `cordis.patch.yml` 各含 1 处 `- id: ui-settings-models` + `disabled: true`(已 `--restart`,客户端 bundle 在实例启动时重打);页面实看留用户确认(本机浏览器 daemon 起不来,沿用档案 86 §七-3 的做法) |
| **②** | 新页**能配、能开关、能删** | HTTP 实测:新增内置条目 → `effective: shared→own`;新增自定义厂家 → `route/base_url/api/models` 全部落库;`toggle` 关/开 → DB `enabled` 0/1;**非法入参**全被拒(非 http 的 baseUrl 400 / 缺 models 400 / route 撞车 **409** / 非法协议 400);前端无浏览器验收**全绿**(含 8 条新断言:共享区块、条目徽章、两类条目、厂家标识/endpoint/协议、表单栏位、重启文案、门户组件类) |
| **③** | **共享模型开关真的影响实例落地** | 关掉共享 + 重启 → 实例 `.credentials.yaml` 里 `DEEPSEEK_API_KEY` **消失**(老实现写下的那行被"一次性交接"正确认领并撤掉),且**实例进程 env 里该变量 = 0 条**;再打开 → 重启 → 该行**回来**,与自定义厂家的 ref **并存**(口径①实测) |
| **④** | **自定义厂家被 dsh 模型选择器认到** | 实例 `settings.yaml` 落地为官方 schema 原文:`llm-pi-ai: providers: acctest-gw: {apiKeyEnv, baseURL, api: openai-completions, models: [gpt-4o, gpt-4o-mini]}`;实例启动**成功**、`journalctl` 无 `llm-pi-ai` / `settings-rejected` 报错;删除该条目后重启 → **整块消失**(只删自己那对标记之间) |
附加证据:备份落点 `/opt/dsh/backups/{credentials,settings}-<userId>-<ts>.yaml`(**不在 home**,且本次起带上用户 id);home 内无平台写的残留文件;托管清单 `{"refs":["DEEPSEEK_API_KEY"],"routes":[]}` 与文件状态一致。
## 七、回滚
```bash
# 后端:还原 src/** → 重建
cd /opt/dshs && npm run build && systemctl restart dshs
# 插件:回落 0.3.10
# 角色补丁(注意副作用!):
cp /opt/dsh/backups/patch-20260913-234511/*.yml 还原到各自 home/profiles/web/cordis.patch.yml
```
⚠️ 若把角色补丁回滚到「放开 models」那一版,等于**给用户一个必然报错的页面**(§一)——除非同时接受这个副作用,否则不要回滚该项。
⚠️ V6 的 `ALTER TABLE` **列不会被回滚**(SQLite 无 down migration);空列不影响旧代码。
## 八、未做 / 待观察
1. **UI 的最终视觉确认留给用户**(本机 `agent-browser` daemon 起不来)——②已用无浏览器 harness 全绿 + HTTP 实测覆盖,但"肉眼看着像不像门户"只有用户能拍。
2. **`settings.yaml` 里会留一个空的 `llm-pi-ai: providers:`**(厂家全删光后):空 dict 与 schema 的 `.default({})` 等价,无害;若要更整洁可再加"空链回收",低价值故未做。
3. **平台不校验用户填的 endpoint**(实例可出网):这是"用户自己的 key、自己的选择",若要限制出网目标需另立需求(承 85 §9.7)。
4. **`selectCredentialKey` / `POST /api/me/keys/:id/select` 已成 deprecated 同义实现**:待老客户端确认不再调用后可删(本轮保留以免 404)。
5. **`.lock-78/85/86/87` 空目录堆积**(占号后没清):属文档库卫生,`docs-audit.py` 不报,建议下次收口时统一清。
6. **文档库「档案 86」仍未见提交**(在途,属别人 lane)——本档与 86 无重叠。
---
## 九、补做(2026-09-14 00:0x–00:3x):**接上官方厂家目录 + 页面改成官方结构**(插件 → **0.3.12**)
> 用户当天实测反馈(原话):「新增模型条目怎么看不懂呢,**是按照 dsh 自带的模型设置交互开发的吗**,而且**模型厂商选择怎么这么少、国内的一家都没有**」,随后追加:「页面就按照 dsh 模型设置的页面(使用项目 ui 规范),**增加 admin 设置的模型管理**就行」。
### 9.1 缺陷认定(**是我的设计做窄了,不是 dsh 没有**)
第一版只支持「内置 DeepSeek + 手填一个 OpenAI 兼容网关」两类 —— 用户要**自己写 endpoint / 协议 / 协议值 / 模型 id**,而且选择框里没有任何厂家。而实际情况是:
| 事实 | 证据 |
|---|---|
| 官方 `pi-ai` 自带 **38 个厂家** | `<dsh>/node_modules/@earendil-works/pi-ai/dist/providers/data/*.json`(本次实测枚举) |
| 其中**中国大陆 11 家** | 蚂蚁 `ant-ling` · 通义千问 `qwen-token-plan{,-cn,-individual}` · 小米 `xiaomi{,-token-plan-cn}` · 月之暗面 `moonshotai-cn` + `kimi-coding` · 智谱 `zai` + `zai-coding-cn` · MiniMax `minimax-cn` |
| **命中的 route 无需手写端点** | 官方 `dsh-llm-pi-ai` 的 `config.d.ts` 原文:*"A route key is not required to name an installed pi-ai provider. When it does, that provider's endpoint, protocol, display name, and model catalog are the profile's defaults and the profile overrides them field by field"* ⇒ **只需 `apiKeyEnv`** |
⇒ 所以正确做法是:**先选厂家**(从目录),**目录厂家只填 API 密钥**;只有目录里没有的才走「自定义」手填。
### 9.2 改了什么
| 层 | 内容 |
|---|---|
| 新文件 | `src/web/model-catalog.ts`:只读官方目录(`data/*.json`,10 分钟缓存,**不导入 pi-ai 运行时**避免把 40 个厂家模块拉进平台进程);厂家中文名走**显示用**静态表(表里没有 ⇒ Title Case 化 id,**选取范围永远以目录为准**,不会因表落后漏厂家);`isCnProvider()` 供界面分组 |
| 接口 | 新增 **`GET /api/me/model-providers`**(回 38 家 + `cn` 分组标记 + `modelCount` + 前 60 个模型名;**排除 `deepseek`** —— 平台已有「内置 DeepSeek」入口,再列同名只会让用户分不清哪个生效);`POST /api/me/keys` 新增 `provider` 参数(**只收 apiKey**,非法厂家 400) |
| 落地 | `refForEntry()` 判据从「baseUrl 为空」改为「**没有 route**」—— 否则目录厂家(也没 baseUrl)的 key 会被写进 `DEEPSEEK_API_KEY`;`SettingsEntry` 的 `baseURL/api/models` 改为**可选**,目录厂家只写 `apiKeyEnv`(写得越少越不容易漂) |
| 前端 | 按官方页结构重写:**「我已添加的厂家」提供方行**(厂家 / 说明 / 状态 / 操作)+ **「新增厂家」先选厂家**(`<optgroup>` **中国大陆 / 国际** / 自定义)+ 选中目录厂家时只需填密钥并提示「端点与 N 个模型由官方目录提供」+ 可展开看模型清单;**保留并强化 admin 的「平台共享模型」区块**(用户明确要求"增加 admin 设置的模型管理") |
### 9.3 验收(真环境实测)
| 检查 | 结果 |
|---|---|
| `GET /api/me/model-providers` | **38 家**;国内 **11 家**(蚂蚁 / 通义千问×3 / 小米×2 / 月之暗面×2 / 智谱×2 / MiniMax);`deepseek` 已排除 ✓ |
| 加一条目录厂家(`moonshotai-cn`,**只给 apiKey**) | `{route:"moonshotai-cn", baseUrl:null, api:null, models:null}` ✓ |
| 不存在的厂家 | **400** ✓ |
| 实例 `.credentials.yaml` | 出现 `MOONSHOTAI_CN_API_KEY` ✓(与 `DEEPSEEK_API_KEY` 并存 —— 口径①实测) |
| 实例 `settings.yaml` | `providers.moonshotai-cn:` **只有 `apiKeyEnv`**,无 baseURL / api / models ✓(正是官方语义) |
| 删除该条目 → 重启 | 整块消失、`agent-default-model` 未受影响 ✓ |
| 本地验收 | `verify-platform-admin-section`(含 8 条新断言)+ `verify-model-landing` **全绿**;zh/en 词典 **282/282** 键集一一对应 |
### 9.4 仍未做
1. **官方页的「获取可用模型」(`llm/discoverModels`)未做** —— 那是"按端点探测模型清单",只对自定义网关有意义,本轮先要求手填模型 id。
2. **官方页的「自定义设置」折叠区**(`baseURL` / 协议 / 模型目录逐项覆盖)只做了必要的三项,未做逐模型覆盖(`modelOverrides`)。
3. 目录厂家的**模型数量/价格**已从目录读出并展示,但**未做模型级勾选**(用哪个模型仍由 dsh 对话框的模型选择器决定,符合口径③)。