- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言
⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
173 lines
12 KiB
JavaScript
173 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-model-landing.mjs —— 模型落地层回归(档案 87)
|
||
*
|
||
* 为什么需要:`src/web/model-landing.ts` 干的是**改写实例自己的配置文件**
|
||
* (`$DSH_HOME/.credentials.yaml` 与 `settings.yaml`)——这类逻辑错了**不会报错**,
|
||
* 只会让实例静默少一个厂家、或者把用户自己配的 key 覆盖掉。官方「设置 → 模型」页
|
||
* 在平台环境必然报错(见 `ensure-role-profile-patch.cjs` 注释),所以平台自己写的这两处
|
||
* 文件就是**唯一**的配置来源,没有第二双眼睛。
|
||
*
|
||
* 本脚本用固定样例把 12 条不变式钉死:**幂等**、**不碰用户自己的**、**删得掉自己写的**、
|
||
* **字段名必须与官方一致**(`api` 不是 `protocol`;`apiKeyEnv` 不是 `apiKey`)。
|
||
* 用法:node scripts/verify-model-landing.mjs 退出码 0=全绿 / 1=有失败
|
||
*/
|
||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
// ⚠️ 动态 import 必须走 file:// URL —— 本机是 Windows,裸 `D:\...` 会被 ESM loader 拒
|
||
// (ERR_UNSUPPORTED_ESM_URL_SCHEME);pathToFileURL 在 Linux 上同样正确。
|
||
const {
|
||
BUILTIN_REF,
|
||
PI_AI_NS,
|
||
PROTOCOLS,
|
||
normalizeProtocol,
|
||
parseModels,
|
||
readRefValue,
|
||
reconcileCredentials,
|
||
reconcileSettings,
|
||
refForEntry,
|
||
routeRef,
|
||
} = await import(pathToFileURL(join(ROOT, 'lib/web/model-landing.js')).href)
|
||
|
||
let failed = 0
|
||
const ok = (name, cond, extra = '') => {
|
||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||
if (!cond) failed++
|
||
}
|
||
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||
|
||
console.log('verify-model-landing(档案 87 落地层)\n')
|
||
|
||
// ── 1. ref 命名 ───────────────────────────────────────────────────────────────
|
||
console.log('[1] ref 命名(须匹配官方 REF_PATTERN,否则 dsh 静默不认)')
|
||
ok('my-gateway → MY_GATEWAY_API_KEY', routeRef('my-gateway') === 'MY_GATEWAY_API_KEY', routeRef('my-gateway'))
|
||
ok('数字开头补 X', routeRef('2fast') === 'X2FAST_API_KEY', routeRef('2fast'))
|
||
ok('空串也合法', REF_PATTERN.test(routeRef('')))
|
||
ok('中文名也合法(折成 X_API_KEY)', routeRef('硅基流动') === 'X_API_KEY', routeRef('硅基流动'))
|
||
ok('内置条目 → DEEPSEEK_API_KEY', refForEntry({ route: 'deepseek', baseUrl: null }) === BUILTIN_REF)
|
||
ok('自定义 → routeRef(route)', refForEntry({ route: 'my-gw', baseUrl: 'https://x/v1' }) === 'MY_GW_API_KEY')
|
||
ok('官方分区名是 llm-pi-ai', PI_AI_NS === 'llm-pi-ai')
|
||
ok('协议只有官方那三个', PROTOCOLS.length === 3 && PROTOCOLS[0] === 'openai-completions')
|
||
ok('未知协议回落默认', normalizeProtocol('nope') === 'openai-completions')
|
||
|
||
// ── 2. 凭据文件:从零创建 ─────────────────────────────────────────────────────
|
||
console.log('\n[2] .credentials.yaml 从零创建')
|
||
let cred = reconcileCredentials('', [{ ref: BUILTIN_REF, value: 'sk-shared' }], [])
|
||
ok('写出 version/refs/行', cred.text === "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\n", JSON.stringify(cred.text))
|
||
ok('managed 记录该 ref', cred.managed.length === 1 && cred.managed[0] === BUILTIN_REF)
|
||
|
||
// ── 3. 凭据文件:追加第二条 + 保留用户自己的内容 ──────────────────────────────
|
||
console.log('\n[3] 追加第二条(用户自己的 ref 必须原样保留)')
|
||
const withUserOwn = "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\nrecords:\n saved-by-dsh: 'zzz'\n"
|
||
cred = reconcileCredentials(
|
||
withUserOwn,
|
||
[
|
||
{ ref: BUILTIN_REF, value: 'sk-shared' },
|
||
{ ref: 'MY_GW_API_KEY', value: 'sk-gw' },
|
||
],
|
||
[BUILTIN_REF],
|
||
)
|
||
ok('新增了自定义 ref', cred.text.includes("MY_GW_API_KEY: 'sk-gw'"))
|
||
ok('records: 段完整保留', cred.text.includes('records:') && cred.text.includes("saved-by-dsh: 'zzz'"))
|
||
ok('已管线的 ref 值被刷新', cred.text.includes("DEEPSEEK_API_KEY: 'sk-shared'"))
|
||
ok('缩进正确(两条都在 refs 下)', /refs:\n( {2}\S+.*\n)+/.test(cred.text), JSON.stringify(cred.text))
|
||
|
||
// ── 4. 凭据文件:用户自己的 ref 绝不覆盖 / 删除 ───────────────────────────────
|
||
console.log('\n[4] 用户自己写的 ref:不覆盖、不删除')
|
||
const userOwn = "version: 1\nrefs:\n USER_OWN_KEY: 'mine'\n"
|
||
const untouched = reconcileCredentials(userOwn, [{ ref: 'USER_OWN_KEY', value: 'platform-would-write' }], [])
|
||
ok('值未被改写', untouched.text.includes("USER_OWN_KEY: 'mine'"), JSON.stringify(untouched.text))
|
||
ok('未把它记为平台托管', untouched.managed.length === 0)
|
||
const notManagedDrop = reconcileCredentials(userOwn, [], ['SOME_OTHER_KEY'])
|
||
ok('不在 managed 里的 ref 不会被删', notManagedDrop.text.includes('USER_OWN_KEY'), JSON.stringify(notManagedDrop.text))
|
||
|
||
// ── 5. 凭据文件:关掉条目 ⇒ 平台自己写的那行被删 ──────────────────────────────
|
||
console.log('\n[5] 关掉条目 ⇒ 撤掉平台自己写的 ref(验收③依赖这条)')
|
||
const twoRefs = "version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-shared'\n MY_GW_API_KEY: 'sk-gw'\n"
|
||
const disabled = reconcileCredentials(twoRefs, [], [BUILTIN_REF, 'MY_GW_API_KEY'])
|
||
ok('DEEPSEEK_API_KEY 已移除', !disabled.text.includes('DEEPSEEK_API_KEY'), JSON.stringify(disabled.text))
|
||
ok('MY_GW_API_KEY 已移除', !disabled.text.includes('MY_GW_API_KEY'))
|
||
ok('managed 清空', disabled.managed.length === 0)
|
||
|
||
// ── 6. 幂等(最容易被忽略的不变式)────────────────────────────────────────────
|
||
console.log('\n[6] 幂等:同一输入跑两次结果必须相同')
|
||
const once = reconcileCredentials(twoRefs, [{ ref: BUILTIN_REF, value: 'sk-shared' }], [BUILTIN_REF])
|
||
const twice = reconcileCredentials(once.text, [{ ref: BUILTIN_REF, value: 'sk-shared' }], once.managed)
|
||
ok('凭据:第二次无变化', twice.text === once.text, JSON.stringify(twice.text))
|
||
|
||
// ── 7. settings.yaml:从零创建厂商段 ─────────────────────────────────────────
|
||
console.log('\n[7] settings.yaml 从零创建(字段名必须与官方一致)')
|
||
const gw = { route: 'my-gw', apiKeyEnv: 'MY_GW_API_KEY', baseURL: 'https://api.example.com/v1', api: 'openai-completions', models: ['gpt-4o', 'gpt-4o-mini'] }
|
||
let set = reconcileSettings('', [gw], [])
|
||
ok('含 llm-pi-ai → providers 链', set.text.includes('llm-pi-ai:\n providers:'))
|
||
ok('字段是 apiKeyEnv(不是 apiKey)', set.text.includes('apiKeyEnv: MY_GW_API_KEY') && !set.text.includes('apiKey:'))
|
||
ok('字段是 api(不是 protocol)', set.text.includes('api: openai-completions') && !set.text.includes('protocol:'))
|
||
ok('baseURL 存在', set.text.includes('baseURL: https://api.example.com/v1'))
|
||
ok('models 是 id 列表', set.text.includes('- id: gpt-4o') && set.text.includes('- id: gpt-4o-mini'))
|
||
ok('有 begin/end 标记(删的依据)', set.text.includes('# dshs:model-route my-gw begin') && set.text.includes('# dshs:model-route my-gw end'))
|
||
ok('managed 记录 route', set.managed.includes('my-gw'))
|
||
|
||
// ── 8. settings.yaml:往既有文档追加,且不破坏别人的键 ────────────────────────
|
||
console.log('\n[8] settings.yaml 追加:既有顶层键与注释必须保留')
|
||
const existingSettings = "# 用户自己的备注\n" + "llm-pi-ai:\n providers:\n deepseek:\n apiKeyEnv: DEEPSEEK_API_KEY\nagent-default-model: deepseek-chat\n"
|
||
set = reconcileSettings(existingSettings, [gw], [])
|
||
ok('既有 deepseek 段保留', set.text.includes('deepseek:\n apiKeyEnv: DEEPSEEK_API_KEY'))
|
||
ok('既有顶层键保留', set.text.includes('agent-default-model: deepseek-chat'))
|
||
ok('既有注释保留', set.text.includes('# 用户自己的备注'))
|
||
ok('新厂家已插入 providers 之下', set.text.indexOf('my-gw:') > set.text.indexOf('providers:'))
|
||
ok('未重复写入 deepseek', (set.text.match(/^ {4}deepseek:$/gm) ?? []).length === 1)
|
||
// 回归项:第一版实现把 `providers:` 当成顶层键去找 ⇒ 找不到 ⇒ 又补一行 ⇒ 文档里两个同键。
|
||
ok('providers 只有一处(防重复键)', (set.text.match(/^[ \t]+providers:$/gm) ?? []).length === 1)
|
||
|
||
// ── 9. settings.yaml:关掉厂家 ⇒ 整块删除 ────────────────────────────────────
|
||
console.log('\n[9] settings.yaml 关掉厂家 ⇒ 只删自己那块')
|
||
const removed = reconcileSettings(set.text, [], ['my-gw'])
|
||
ok('my-gw 块已删干净', !removed.text.includes('my-gw'), JSON.stringify(removed.text.slice(-160)))
|
||
ok('deepseek 段还在', removed.text.includes('deepseek:'))
|
||
ok('顶层键还在', removed.text.includes('agent-default-model'))
|
||
const removedTwice = reconcileSettings(removed.text, [], removed.managed)
|
||
ok('删除也幂等', removedTwice.text === removed.text)
|
||
|
||
// ── 10. settings.yaml:文件里已有同名 route(非平台写的)⇒ 不重复写 ──────────
|
||
console.log('\n[10] 已存在同名 route ⇒ 不重复写(防 YAML 重复键)')
|
||
const collide = reconcileSettings("llm-pi-ai:\n providers:\n my-gw:\n apiKeyEnv: USER_SET\n", [gw], [])
|
||
ok('未插入第二个 my-gw', (collide.text.match(/^ {4}my-gw:$/gm) ?? []).length === 1, JSON.stringify(collide.text))
|
||
|
||
// ── 11. parseModels 宽容性 ───────────────────────────────────────────────────
|
||
console.log('\n[11] parseModels:坏值只能被丢弃,不能把整份文件写坏')
|
||
ok('坏 JSON → []', parseModels('{oops').length === 0)
|
||
ok('非数组 → []', parseModels('"x"').length === 0)
|
||
ok('过滤非字符串项', JSON.stringify(parseModels('["a", 1, null, "b"]')) === '["a","b"]')
|
||
ok('去重', parseModels('["a","a"]').length === 1)
|
||
ok('限长 50', parseModels(JSON.stringify(Array.from({ length: 80 }, (_, i) => 'm' + i))).length === 50)
|
||
ok('null → []', parseModels(null).length === 0)
|
||
|
||
// ── 12. 认不出的布局:宁可不动 ───────────────────────────────────────────────
|
||
console.log('\n[12] 认不出的凭据布局 ⇒ 原样返回(不冒写坏凭据的风险)')
|
||
const weird = 'this: is\n not: a credentials doc\n'
|
||
const keptWeird = reconcileCredentials(weird, [{ ref: 'A_KEY', value: 'v' }], [])
|
||
ok('原样返回', keptWeird.text === weird)
|
||
|
||
// ── 13. readRefValue(一次性交接:认领老实现写下的 ref)──────────────────────
|
||
console.log('\n[13] readRefValue:读出文件里某 ref 的当前值')
|
||
ok('带单引号', readRefValue("version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-1'\n", 'DEEPSEEK_API_KEY') === 'sk-1')
|
||
ok('不带引号', readRefValue('version: 1\nrefs:\n ANYSEARCH_API_KEY: as_sk_x\n', 'ANYSEARCH_API_KEY') === 'as_sk_x')
|
||
ok('没有该 ref → null', readRefValue("version: 1\nrefs:\n OTHER: 'x'\n", 'DEEPSEEK_API_KEY') === null)
|
||
ok('不在 refs 段下的同名行不算', readRefValue("version: 1\nrecords:\n DEEPSEEK_API_KEY: 'z'\n", 'DEEPSEEK_API_KEY') === null)
|
||
ok(
|
||
'真实样例(服务器现状两把 key + records 段)',
|
||
readRefValue(
|
||
"version: 1\nrefs:\n DEEPSEEK_API_KEY: 'sk-81c7'\n ANYSEARCH_API_KEY: as_sk_4\nrecords:\n client-connection/browser-session:\n kind: grant\n",
|
||
'DEEPSEEK_API_KEY',
|
||
) === 'sk-81c7',
|
||
)
|
||
|
||
console.log('')
|
||
if (failed > 0) {
|
||
console.log(`✗ verify-model-landing 失败 ${failed} 项`)
|
||
process.exit(1)
|
||
}
|
||
console.log('✓ verify-model-landing 全绿')
|