- 【留痕】model-catalog 读不到目录:console.warn(每进程一次)+ 新增 catalogDiagnostics(),并把结果透出到 GET /api/me/model-providers 的 catalog 字段;plugin-compat 的 platformPkgCount()=0 同样告警一次 ⇒「功能没做」与「做了但读不到目录」从此**可区分**(这正是那个 P1 长期潜伏的原因) - 【前端】插件 0.3.13:厂家目录不可读 / 为空时**如实提示**(含目录路径),不再静默给空列表 - 【消漂移】poc/workspace-scoped-picker 升 0.1.5 并重打包铺发 —— 两实例已装 0.1.5 且含按序探测的新代码(此前只改了源码、线上还是旧候选) - 【文档】README 补 DSHS_PACKAGE_DIR / DSHS_COMPAT_ROOT / DSHS_PI_AI_DATA_DIR(逃生口此前**未在任何地方暴露**) - 【回归】verify-dsh-install 扩到 6 组(含「读不到 ≠ 目录为空」的区分);verify-platform-admin-section 加 2 条断言(词典 284/284) - 真环境回归:picker 在 /usr/local 仍 import OK(没把本来能用的改坏)|providers 39(端点回 38)|platformPkgCount 223
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
/**
|
||
* 官方**模型厂家目录**读取层(档案 87 补做)。
|
||
*
|
||
* 背景:第一版「模型设置」只支持「内置 DeepSeek + 手填一个 OpenAI 兼容网关」两类,
|
||
* 用户反馈**「厂家选择怎么这么少、国内一家都没有」** —— 而实际情况是官方 `pi-ai` 包里
|
||
* 自带 **39 个厂家**(含 `deepseek` / `kimi-coding` / `moonshotai-cn` / `minimax-cn` /
|
||
* `qwen-token-plan-cn` / `zai` / `xiaomi` / `ant-ling` …),只是平台没接。
|
||
*
|
||
* 数据来源(**只读**,与实例用的同一个包 ⇒ 不会漂):
|
||
* `<dsh 安装目录>/node_modules/@earendil-works/pi-ai/dist/providers/data/<id>.json`
|
||
* 形状:`{ "<api>": { "<模型id>": { id, name, api, baseUrl, provider, contextWindow, maxTokens, cost… } } }`
|
||
*
|
||
* 关键结论(读官方 `dsh-llm-pi-ai` 的 `config.d.ts` 实证):
|
||
* **route 只要命中目录里的厂家 id,就被视为「目录厂家」** —— 该厂家的 endpoint、协议、
|
||
* 显示名、模型清单**全部由目录提供**,profile 只需给 `apiKeyEnv`(外加可选覆盖)。
|
||
* 所以对 39 个目录厂家,用户**只需要填一把 API Key**,不必手写 endpoint / 协议 / 模型。
|
||
*
|
||
* 显示名策略:**目录数据里只有模型信息、没有厂家名**(厂家名在 `dist/providers/<id>.js` 里,
|
||
* 用正则去抠 JS 太脆)。所以这里用一张**显示用**的静态表(仅影响文案):
|
||
* · 表里有 → 用「中文名|官方名」;表里没有(dsh 以后新增厂家)→ 回落成 id 的 Title Case。
|
||
* ⇒ **选取范围永远以数据目录为准**,静态表只是标签;不会因为表落后而漏掉新厂家。
|
||
*
|
||
* @module dshs/web/model-catalog
|
||
*/
|
||
|
||
import { readdir, readFile } from 'node:fs/promises'
|
||
import { join } from 'node:path'
|
||
import { piAiDataDir } from './dsh-install.js'
|
||
|
||
/**
|
||
* 厂家目录数据目录。
|
||
*
|
||
* ⚠️ 2026-09-14 修:原先写死 `/usr/local/lib/node_modules/...`,在 `npm root -g` 落到
|
||
* `/usr/lib/node_modules` 的机器上会**静默返回空目录**(测试服实测
|
||
* `GET /api/me/model-providers` → `{"providers":[]}`,而真实位置有 39 个厂家文件)。
|
||
* 改由 `dsh-install.ts` 按序探测(env → dsh 可执行文件解软链 → 常见全局根 → `npm root -g`)。
|
||
*/
|
||
export function catalogDir(): string {
|
||
return piAiDataDir()
|
||
}
|
||
|
||
/** 目录里一个厂家。 */
|
||
export interface CatalogProvider {
|
||
/** = settings.yaml 里 `llm-pi-ai.providers` 的 dict 键。 */
|
||
id: string
|
||
/** 展示名(中文名|官方名;认不出就 Title Case 化 id)。 */
|
||
label: string
|
||
api: string
|
||
baseURL: string | null
|
||
models: Array<{ id: string; name: string | null; contextWindow: number | null; inputCost: number | null }>
|
||
}
|
||
|
||
/**
|
||
* 显示用标签表(`id → 中文名|官方名`)。**只影响文案**,不影响可选范围。
|
||
* 国内厂家集中在前半段,方便用户在列表里先看到。
|
||
*/
|
||
const LABEL: Record<string, string> = {
|
||
deepseek: '深度求索 DeepSeek',
|
||
'kimi-coding': '月之暗面 Kimi(编程)',
|
||
moonshotai: '月之暗面 Moonshot',
|
||
'moonshotai-cn': '月之暗面 Moonshot(中国)',
|
||
minimax: 'MiniMax',
|
||
'minimax-cn': 'MiniMax(中国)',
|
||
'qwen-token-plan': '通义千问 Qwen(Token Plan)',
|
||
'qwen-token-plan-cn': '通义千问 Qwen(中国)',
|
||
'qwen-token-plan-individual': '通义千问 Qwen(个人版)',
|
||
zai: '智谱 Z.AI',
|
||
'zai-coding-cn': '智谱 Z.AI Coding(中国)',
|
||
xiaomi: '小米 Xiaomi',
|
||
'xiaomi-token-plan-cn': '小米 Xiaomi(中国)',
|
||
'xiaomi-token-plan-ams': '小米 Xiaomi(AMS)',
|
||
'xiaomi-token-plan-sgp': '小米 Xiaomi(SGP)',
|
||
'ant-ling': '蚂蚁 Ant Ling',
|
||
anthropic: 'Anthropic Claude',
|
||
openai: 'OpenAI',
|
||
'openai-codex': 'OpenAI Codex',
|
||
'azure-openai-responses': 'Azure OpenAI',
|
||
google: 'Google Gemini',
|
||
'google-vertex': 'Google Vertex',
|
||
xai: 'xAI Grok',
|
||
mistral: 'Mistral',
|
||
groq: 'Groq',
|
||
cerebras: 'Cerebras',
|
||
baseten: 'Baseten',
|
||
fireworks: 'Fireworks',
|
||
together: 'Together AI',
|
||
nvidia: 'NVIDIA NIM',
|
||
openrouter: 'OpenRouter',
|
||
huggingface: 'Hugging Face',
|
||
'vercel-ai-gateway': 'Vercel AI Gateway',
|
||
'cloudflare-ai-gateway': 'Cloudflare AI Gateway',
|
||
'cloudflare-workers-ai': 'Cloudflare Workers AI',
|
||
'github-copilot': 'GitHub Copilot',
|
||
opencode: 'OpenCode Zen',
|
||
'opencode-go': 'OpenCode Go',
|
||
'amazon-bedrock': 'Amazon Bedrock',
|
||
radius: 'Radius',
|
||
}
|
||
|
||
/** 认不出时的回落:`my-provider` → `My Provider`。 */
|
||
function prettify(id: string): string {
|
||
return id
|
||
.split(/[-_]/)
|
||
.map((s) => (s === '' ? s : s[0].toUpperCase() + s.slice(1)))
|
||
.join(' ')
|
||
}
|
||
|
||
/**
|
||
* 中国大陆可直连的厂家 id(仅用于界面**分组**,让用户一眼看到国内选项 —— 用户原话:
|
||
* 「模型厂商选择怎么这么少 国内的一家都没有」)。判据是厂家主体在中国大陆。
|
||
*/
|
||
const CN_IDS = new Set([
|
||
'deepseek',
|
||
'kimi-coding',
|
||
'moonshotai-cn',
|
||
'minimax-cn',
|
||
'qwen-token-plan',
|
||
'qwen-token-plan-cn',
|
||
'qwen-token-plan-individual',
|
||
'zai',
|
||
'zai-coding-cn',
|
||
'xiaomi',
|
||
'xiaomi-token-plan-cn',
|
||
'ant-ling',
|
||
])
|
||
|
||
/** @returns 该厂家是否属于「中国大陆可直连」分组。 */
|
||
export function isCnProvider(id: string): boolean {
|
||
return CN_IDS.has(id)
|
||
}
|
||
|
||
/** @returns 该厂家的展示名。 */
|
||
export function providerLabel(id: string): string {
|
||
return LABEL[id] ?? prettify(id)
|
||
}
|
||
|
||
/** 解析一个厂家的数据文件。 */
|
||
function parseProvider(id: string, raw: string): CatalogProvider | null {
|
||
let data: unknown
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
return null
|
||
}
|
||
if (data === null || typeof data !== 'object') return null
|
||
const byApi = data as Record<string, Record<string, Record<string, unknown>>>
|
||
let api = ''
|
||
let baseURL: string | null = null
|
||
const models: CatalogProvider['models'] = []
|
||
const seen = new Set<string>()
|
||
for (const [apiKey, group] of Object.entries(byApi)) {
|
||
if (group === null || typeof group !== 'object') continue
|
||
if (api === '') api = apiKey
|
||
for (const [modelId, m] of Object.entries(group)) {
|
||
if (m === null || typeof m !== 'object') continue
|
||
if (seen.has(modelId)) continue
|
||
seen.add(modelId)
|
||
const row = m as Record<string, unknown>
|
||
if (baseURL === null && typeof row.baseUrl === 'string' && row.baseUrl !== '') baseURL = row.baseUrl
|
||
const cost = (row.cost ?? null) as Record<string, unknown> | null
|
||
models.push({
|
||
id: typeof row.id === 'string' ? row.id : modelId,
|
||
name: typeof row.name === 'string' ? row.name : null,
|
||
contextWindow: typeof row.contextWindow === 'number' ? row.contextWindow : null,
|
||
inputCost: cost !== null && typeof cost.input === 'number' ? cost.input : null,
|
||
})
|
||
}
|
||
}
|
||
if (models.length === 0) return null
|
||
models.sort((a, b) => a.id.localeCompare(b.id))
|
||
return { id, label: providerLabel(id), api, baseURL, models }
|
||
}
|
||
|
||
/** 进程内缓存(目录是安装期冻结的,TTL 给长一点省 IO)。 */
|
||
let cache: { at: number; list: CatalogProvider[] } | null = null
|
||
const TTL_MS = 10 * 60 * 1000
|
||
|
||
/** 读取结果的可读性诊断。 */
|
||
export interface CatalogDiagnostics {
|
||
/** 实际使用的目录(`dsh-install.ts` 探测出来的)。 */
|
||
dir: string
|
||
/** 能否读到(`false` = 探测/读取失败 ⇒ 界面只剩内置 DeepSeek + 自定义网关)。 */
|
||
readable: boolean
|
||
/** 读到了几个厂家。 */
|
||
count: number
|
||
}
|
||
|
||
/**
|
||
* 目录可读性诊断(2026-09-14 加,见 `04-调整方案/88`)。
|
||
*
|
||
* 为什么必须加:定位/读取失败原本**完全静默**(catch 后返回 `[]`),于是
|
||
* 「功能没做」与「功能做了但读不到目录」在界面上**一模一样** —— 一个 P1 缺陷就这样在
|
||
* 另一种安装布局(`npm root -g` = `/usr/lib/node_modules`)上潜伏了很久,最后靠人工
|
||
* 比对才看出来。⇒ ① 读不到时 `console.warn`(每进程一次,不刷日志);
|
||
* ② 把结果透出到 `/api/me/model-providers`,让「到底读没读到」可观测。
|
||
*/
|
||
let diag: CatalogDiagnostics | null = null
|
||
let warnedUnreadable = false
|
||
|
||
/** 最近一次读取的可读性诊断(尚未读过时返回 `null`)。 */
|
||
export function catalogDiagnostics(): CatalogDiagnostics | null {
|
||
return diag
|
||
}
|
||
|
||
/**
|
||
* 读出目录里的全部厂家(按中文名/官方名排序)。
|
||
* 目录不存在(例如本地开发机)⇒ 返回空数组,**不抛错**:调用方据此降级为"只有内置 + 自定义"。
|
||
* ⚠️ 降级**不是无声的** —— 见 `catalogDiagnostics()` 与上方的 `console.warn`。
|
||
*/
|
||
export async function listCatalogProviders(force = false): Promise<CatalogProvider[]> {
|
||
if (!force && cache !== null && Date.now() - cache.at < TTL_MS) return cache.list
|
||
const dir = catalogDir()
|
||
let files: string[]
|
||
try {
|
||
files = await readdir(dir)
|
||
} catch {
|
||
diag = { dir, readable: false, count: 0 }
|
||
if (!warnedUnreadable) {
|
||
warnedUnreadable = true
|
||
console.warn(
|
||
`[model-catalog] 厂家目录不可读:${dir} —— 「设置 → 模型设置 → 新增厂家」只会显示内置 DeepSeek 与自定义网关。` +
|
||
' 检查 DSHS_DSH_BIN / DSH_PACKAGE_DIR 是否正确(见 src/web/dsh-install.ts)。',
|
||
)
|
||
}
|
||
cache = { at: Date.now(), list: [] }
|
||
return []
|
||
}
|
||
const ids = files.filter((f) => f.endsWith('.json') && !f.startsWith('.')).map((f) => f.slice(0, -'.json'.length))
|
||
const out: CatalogProvider[] = []
|
||
for (const id of ids) {
|
||
try {
|
||
const p = parseProvider(id, await readFile(join(dir, id + '.json'), 'utf8'))
|
||
if (p !== null) out.push(p)
|
||
} catch {
|
||
/* 单个文件读坏不影响其余 */
|
||
}
|
||
}
|
||
out.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'))
|
||
diag = { dir, readable: true, count: out.length }
|
||
cache = { at: Date.now(), list: out }
|
||
return out
|
||
}
|
||
|
||
/** 目录里有没有这个厂家 id(`POST /api/me/keys` 的校验用)。 */
|
||
export async function isCatalogProvider(id: string): Promise<boolean> {
|
||
return (await listCatalogProviders()).some((p) => p.id === id)
|
||
}
|