/** * 模型条目的**落地层**(档案 87):把平台凭据库里「已启用」的条目写进实例的两个配置文件。 * * 为什么必须有这一层:官方「设置 → 模型」页在平台环境**必然报错** —— 该页要求 Host settings * 镜像,而 `dsh-client-ui-settings` 的持久化判定是 * `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`, * 平台是「浏览器经域名访问远程服务器」⇒ 三条皆不成立 ⇒ persistence 降级为 `memory` * ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败」。详见 * `ensure-role-profile-patch.cjs` 的 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK` 注释。 * ⇒ 用户自配模型只能由**平台自己写文件**。 * * 落点两处(字段名均为 2026-09-13 读官方包 `dsh-llm-pi-ai@0.1.2-rc.1` 实测,勿凭记忆改): * * 1. `$DSH_HOME/.credentials.yaml` → `refs.: ''` * (REF 须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`) * 2. `$DSH_HOME/settings.yaml` → 顶层 `llm-pi-ai:` → `providers.` → * `apiKeyEnv` / `baseURL` / `api` / `models` * ⚠️ 是 **`api`**(不是 `protocol`)、是 **`apiKeyEnv`**(不是 `apiKey`); * 该 profile **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错); * `api` 的合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`。 * * 本模块**只做纯文本变换**(无 IO、无 db、无 crypto)⇒ 可以被 * `scripts/verify-model-landing.mjs` 用固定样例逐条断言。这类"改写别人家配置文件"的逻辑 * 最怕没有回归网:它错了不会报错,只会让实例静默少一个厂家。 * * 安全约束(与既有 `ensureRefInCredentials` 同族,每条都是踩出来的): * · 只认 `version: 1` 的凭据文档;认不出的布局**宁可不动**; * · 平台**只管自己写过的**(`managed` 清单):用户自己放的 ref / 厂家段**绝不覆盖、绝不删**; * · 要"删掉"时只删平台自己的:凭据=我们自己写的那一行,settings=我们自己那对标记之间; * · 内联样式(`baseURL: ...`)而不是 JSON 块,避免把用户的其它字段卷进重排。 * * @module dshs/web/model-landing */ /** 官方凭据 ref 名的语法(`dsh-credentials` 的 `REF_PATTERN`)。 */ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ /** 内置 DeepSeek 条目的 ref(官方与平台既有实现共同约定的名字)。 */ export const BUILTIN_REF = 'DEEPSEEK_API_KEY' /** `settings.yaml` 里那个用户设置分区的名字(官方 `const NS = "llm-pi-ai"`)。 */ export const PI_AI_NS = 'llm-pi-ai' /** 官方支持的线协议(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级)。 */ export const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const export type Protocol = (typeof PROTOCOLS)[number] /** 一条「已启用」条目落地所需的全部信息(`value` 已是解密后的明文 key)。 */ export interface LandingEntry { /** 展示名(只用于注释与日志)。 */ name: string /** settings.yaml 的 `providers` dict 键;内置条目可为空。 */ route: string | null /** 自定义厂家 endpoint;空 = 内置 DeepSeek。 */ baseUrl: string | null /** 线协议;空 = `openai-completions`。 */ api: string | null /** 模型 id 清单。 */ models: string[] /** 解密后的 key 明文。 */ value: string } /** `reconcile*` 的返回:新文本 + 这一轮之后仍归平台管的键。 */ export interface ReconcileResult { text: string managed: string[] } /** * 自定义厂家的 ref 名:`_API_KEY`。 * 结果**一定**匹配 `REF_PATTERN`(首字符强制成字母)—— 否则 dsh 解析凭据时会直接不认, * 而且是静默的"这条 ref 不存在",排查成本极高。 */ export function routeRef(route: string): string { let up = (route ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '_').replace(/^_+|_+$/g, '') if (up === '' || !/^[A-Z]/.test(up)) up = 'X' + up return `${up}_API_KEY` } /** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */ export function refForEntry(entry: Pick): string { return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom') } /** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */ export function normalizeProtocol(api: string | null | undefined): Protocol { return (PROTOCOLS as readonly string[]).includes(api ?? '') ? (api as Protocol) : PROTOCOLS[0] } /** YAML 单引号标量转义(`'` → `''`)—— 避免 key 里的引号把文档弄坏。 */ function yamlSingle(value: string): string { return `'${value.replace(/'/g, "''")}'` } function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } /** * 把「已启用条目」对账进 `.credentials.yaml`。 * * @param text - 现有文件内容(空串 = 文件不存在)。 * @param desired - 期望存在的 `{ ref, value }`。 * @param managed - **平台自己写过**的 ref 清单(上一次的返回值)。只有这里的 ref 允许被改写/删除。 * @returns 新文本 + 新的 managed 清单。认不出的布局会原样返回(宁可不动)。 */ export function reconcileCredentials( text: string, desired: readonly { ref: string; value: string }[], managed: readonly string[] = [], ): ReconcileResult { const owned = new Set(managed) const wanted = new Map(desired.map((d) => [d.ref, d.value])) // 文件不存在 / 空 ⇒ 从零建一个最小合法文档(与既有 ensureRefInCredentials 同款)。 if (text.trim() === '') { if (desired.length === 0) return { text, managed: [] } const body = desired.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`).join('\n') return { text: `version: 1\nrefs:\n${body}\n`, managed: desired.map((d) => d.ref) } } const lines = text.split('\n') const insideRefs: boolean[] = [] let refsAt = -1 let versionAt = -1 let inside = false for (let i = 0; i < lines.length; i++) { const raw = lines[i] const indented = /^[ \t]/.test(raw) inside = indented ? inside : raw.trim() === 'refs:' if (!indented && raw.trim() === 'refs:' && refsAt < 0) refsAt = i if (!indented && versionAt < 0 && /^version:[ \t]*1[ \t]*$/.test(raw.trim())) versionAt = i insideRefs.push(inside && indented) } const refAt = new Map() for (let i = 0; i < lines.length; i++) { if (!insideRefs[i]) continue const m = /^[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*:/.exec(lines[i]) if (m !== null) refAt.set(m[1], i) } const drop = new Set() const kept: string[] = [] const setLine: Array<{ at: number; ref: string; value: string }> = [] const add: Array<{ ref: string; value: string }> = [] for (const ref of owned) { if (wanted.has(ref)) { kept.push(ref) continue } const at = refAt.get(ref) if (at !== undefined) drop.add(at) // 用户关掉了 ⇒ 把平台自己写的那行撤掉 } for (const [ref, value] of wanted) { const at = refAt.get(ref) if (at === undefined) { add.push({ ref, value }) kept.push(ref) continue } if (!owned.has(ref)) continue // 文件里有、但不是平台写的 ⇒ 用户自己的,绝不碰 setLine.push({ at, ref, value }) kept.push(ref) } const inserted = new Map() if (add.length > 0) { const anchor = refsAt >= 0 ? refsAt : versionAt if (anchor < 0) return { text, managed: [...owned] } // 认不出布局 ⇒ 宁可不动 const body = add.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`) inserted.set(anchor, refsAt >= 0 ? body : ['refs:', ...body]) } const out: string[] = [] for (let i = 0; i < lines.length; i++) { if (!drop.has(i)) { const s = setLine.find((d) => d.at === i) out.push(s === undefined ? lines[i] : ` ${s.ref}: ${yamlSingle(s.value)}`) } const extra = inserted.get(i) if (extra !== undefined) out.push(...extra) } return { text: out.join('\n'), managed: [...new Set(kept)] } } /** 一条要写进 `settings.yaml` 的厂家声明。 */ export interface SettingsEntry { route: string apiKeyEnv: string baseURL: string api: Protocol models: string[] } const markBegin = (route: string): string => ` # dshs:model-route ${route} begin` const markEnd = (route: string): string => ` # dshs:model-route ${route} end` /** * 把厂家声明对账进 `settings.yaml`。 * * 用**成对标记**夹住平台自己写的那个 route 段:① 不解析别人的 YAML(不重排、不丢注释); * ② "关掉某个厂家"就是删掉自己那对标记之间的内容 ⇒ 精确可控。 * 代价:若 dsh 或用户某次把文件整体重写、标记丢了,就再也删不掉那个 route * (但"已存在"检查仍会拦住重复写入,所以最坏是留一条模型清单里的僵尸厂家,不会写坏配置)。 * * @param text - 现有文件内容(空串 = 文件不存在)。 * @param desired - 期望存在的厂家声明。 * @param managed - 平台自己写过的 route 清单。 */ export function reconcileSettings( text: string, desired: readonly SettingsEntry[], managed: readonly string[] = [], ): ReconcileResult { const owned = new Set(managed) const wanted = new Map(desired.map((d) => [d.route, d])) // ① 先删:不再需要的、且是我们自己写的块。 const lines = text === '' ? [] : text.split('\n') const kept: string[] = [] const present = new Set() for (let i = 0; i < lines.length; i++) { const b = /^[ \t]*# dshs:model-route ([^\s]+) begin[ \t]*$/.exec(lines[i]) if (b === null) { // 非标记行里如果已经有 ` :`(可能是用户/官方自己写的)⇒ 记为"已存在",不重复写。 const k = /^[ \t]{4}([^\s:#][^\s:]*)[ \t]*:[ \t]*$/.exec(lines[i]) if (k !== null) present.add(k[1]) kept.push(lines[i]) continue } const route = b[1] const endRe = new RegExp(`^[ \\t]*# dshs:model-route ${escapeRe(route)} end[ \\t]*$`) let end = -1 for (let j = i + 1; j < lines.length; j++) { if (endRe.test(lines[j])) { end = j break } } if (wanted.has(route) || !owned.has(route)) { // 还要留着(或不是我们的,不该动)⇒ 原样搬过去。 present.add(route) if (end < 0) kept.push(lines[i]) else { kept.push(...lines.slice(i, end + 1)) i = end } continue } // 用户已关掉这个厂家 ⇒ 整块丢掉(这就是"删")。 i = end < 0 ? i : end } const add = desired.filter((d) => !present.has(d.route)) const newOwned = [...new Set([...owned].filter((r) => wanted.has(r) || present.has(r)))] if (add.length === 0) return { text: kept.join('\n'), managed: newOwned } // ② 找 `llm-pi-ai:` → `providers:` 链,缺什么补什么,然后在 `providers:` 之后插入。 // ⚠️ 这里**不能**用"只看顶层行"的循环:`providers:` 本身就是缩进 2 的(它是 // `llm-pi-ai:` 的子键)。第一版就是这么写的 ⇒ 找不到既有 providers ⇒ 又补一行 // ` providers:` ⇒ 文档里出现**两个**同键(回归 [8]/[9] 当场抓到)。 let nsAt = -1 for (let i = 0; i < kept.length; i++) { if (/^[ \t]/.test(kept[i])) continue if (kept[i].trim() === `${PI_AI_NS}:`) { nsAt = i break } } let provAt = -1 if (nsAt >= 0) { for (let i = nsAt + 1; i < kept.length; i++) { const raw = kept[i] if (!/^[ \t]/.test(raw)) { if (raw.trim() !== '') break // 撞到下一个顶层键 ⇒ 该分区到此为止 continue // 分区里的空行不算结束 } if (raw.trim() === 'providers:') { provAt = i break } } } const blocks = add.flatMap((d) => [ markBegin(d.route), ` ${d.route}:`, ` apiKeyEnv: ${d.apiKeyEnv}`, ` baseURL: ${d.baseURL}`, ` api: ${d.api}`, ' models:', ...d.models.map((m) => ` - id: ${m}`), markEnd(d.route), ]) const out = [...kept] if (provAt >= 0) { out.splice(provAt + 1, 0, ...blocks) } else if (nsAt >= 0) { out.splice(nsAt + 1, 0, ' providers:', ...blocks) } else { // 整个 `llm-pi-ai:` 分区都不在 ⇒ 追加到文件末尾(顶层键,顺序无关)。 if (out.length > 0 && out[out.length - 1] === '') out.splice(out.length - 1, 0, `${PI_AI_NS}:`, ' providers:', ...blocks, '') else out.push(`${PI_AI_NS}:`, ' providers:', ...blocks) } return { text: out.join('\n'), managed: [...new Set([...newOwned, ...add.map((d) => d.route)])] } } /** * 读出 `.credentials.yaml` 里某个 ref 的**当前值**(没有该 ref 时 `null`)。 * * 用途只有一个:**一次性交接**(档案 87)。老实现把平台共享 key 直接写进 * `refs.DEEPSEEK_API_KEY`,但那时没有托管清单 ⇒ 新逻辑会把它当成"用户自己写的"而永不清理 * ⇒ 用户关掉共享开关后那个 key 仍然留在文件里("关掉即生效"就不成立)。 * 所以首次运行时要**认领**该 ref,但只在那行确实等于平台共享 key 的明文时才认领 * —— 否则就是用户自己配的,绝不碰。 * @param text - `.credentials.yaml` 内容。 * @param ref - 要读的 ref 名。 * @returns 去掉引号后的值,或 `null`。 */ export function readRefValue(text: string, ref: string): string | null { if (text === '') return null const lines = text.split('\n') let inside = false for (const raw of lines) { const indented = /^[ \t]/.test(raw) inside = indented ? inside : raw.trim() === 'refs:' if (!inside || !indented) continue const m = new RegExp(`^[ \\t]+${ref}[ \\t]*:[ \\t]*(.*)$`).exec(raw) if (m === null) continue const v = m[1].trim() if (v.length >= 2 && ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"')))) { return v.slice(1, -1).replace(/''/g, "'") } return v } return null } /** * 把 `models` 列(JSON 数组字符串)解析成 id 清单。 * 宽容:非数组、非字符串项、超量一律丢弃 —— 这条路上宁可少写一个模型, * 也不能让一个坏值把整个 `settings.yaml` 变成 dsh 拒绝加载的文档。 */ export function parseModels(json: string | null | undefined, limit = 50): string[] { if (json === null || json === undefined) return [] try { const raw: unknown = JSON.parse(json) if (!Array.isArray(raw)) return [] const out: string[] = [] for (const item of raw) { if (typeof item !== 'string') continue const v = item.trim() if (v === '' || v.length > 128) continue if (!out.includes(v)) out.push(v) if (out.length >= limit) break } return out } catch { return [] } }