# 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.`)与 `$DSH_HOME/settings.yaml`(`llm-pi-ai.providers.`);④ 落地层**只碰自己写过的**(平台托管清单 `/opt/dsh/state/model-landing/.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:` ⇒ dsh 的 /api trust fence 与 loopback 特权校验**全放行** ⇒ Settings 面板在门户代理后可用」。 两者**不矛盾,因为不是同一层**: | 层 | 谁在判 | 平台的表现 | |---|---|---| | **host 侧** `/api` trust fence / loopback 特权校验 | 看**请求的 Host** | `proxy.ts:108` 把 `out.host` 改写成 `127.0.0.1:` ⇒ **放行**(09-09 观察到的就是这一层,故 `modelCatalog` 能 200) | | **浏览器侧** settings 持久化 | 看**浏览器页面的 location**(`isLoopbackHostname(page)`) | 页面是 `https://.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/dsh-llm-pi-ai@0.1.2-rc.1` 的 `lib/index.js` 与 `lib/types/*.d.ts`。**字段名与 85 §九 的措辞不同,按本节为准**: | 项 | 结论 | |---|---| | 设置分区名 | **`llm-pi-ai`**(`const NS = "llm-pi-ai"`) | | 结构 | `llm-pi-ai:` → `providers:`(缩进 2)→ `:`(缩进 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`:`/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)` | `_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.`:用**成对标记** `# dshs:model-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/.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}--.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 个厂家** | `/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`(写得越少越不容易漂) | | 前端 | 按官方页结构重写:**「我已添加的厂家」提供方行**(厂家 / 说明 / 状态 / 操作)+ **「新增厂家」先选厂家**(`` **中国大陆 / 国际** / 自定义)+ 选中目录厂家时只需填密钥并提示「端点与 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 对话框的模型选择器决定,符合口径③)。