feat(models): 「模型设置」接官方厂家目录 + 按官方页结构重做界面(档案 87 补做)
用户实测反馈:新增条目看不懂、厂家选择太少且国内一家都没有。根因是**第一版只做了手填表单**,没接官方 pi-ai 自带目录(实测 38 家,国内 11 家:蚂蚁 / 通义千问 / 小米 / 月之暗面 / 智谱 / MiniMax)。 - 新文件 src/web/model-catalog.ts:只读官方 data/*.json(10 分钟缓存,不导入 pi-ai 运行时);中文名走显示用静态表,选取范围以目录为准 - 新增 GET /api/me/model-providers(38 家 + cn 分组 + modelCount + 模型名;排除 deepseek —— 平台已有内置入口) - POST /api/me/keys 新增 provider 参数:**目录厂家只需 API 密钥**(端点/协议/模型全由官方目录兜底 —— 官方 config.d.ts 明示 route 命中即继承默认) - refForEntry 判据由「baseUrl 为空」改为「**没有 route**」:否则目录厂家的 key 会被写进 DEEPSEEK_API_KEY - SettingsEntry 的 baseURL/api/models 改为可选:目录厂家只写 apiKeyEnv(写得越少越不容易漂) - 前端按官方页结构重写:提供方行(厂家/说明/状态/操作)+ 新增**先选厂家**(中国大陆 / 国际分组 / 自定义)+ 保留并强化 admin 的「平台共享模型」区块 - 真环境验收:目录端点 38 家 / 国内 11 家;加 moonshotai-cn 只给 key → settings.yaml 仅 apiKeyEnv;删后整块消失
This commit is contained in:
1 parent
918f1d3a51
commit
022b1f770c
7 files changed
+489
-121
No files matched your search
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 官方**模型厂家目录**读取层(档案 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'
|
||||
|
||||
/** dsh 包安装目录(可用 env 覆盖,便于测试与其它部署形态)。 */
|
||||
const DSH_PACKAGE_DIR = process.env.DSH_PACKAGE_DIR ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh'
|
||||
|
||||
/** 目录数据目录。 */
|
||||
export function catalogDir(): string {
|
||||
return (
|
||||
process.env.PI_AI_DATA_DIR ??
|
||||
join(DSH_PACKAGE_DIR, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data')
|
||||
)
|
||||
}
|
||||
|
||||
/** 目录里一个厂家。 */
|
||||
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 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 {
|
||||
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'))
|
||||
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)
|
||||
}
|
||||
+30
-16
@@ -78,9 +78,14 @@ export function routeRef(route: string): string {
|
||||
return `${up}_API_KEY`
|
||||
}
|
||||
|
||||
/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */
|
||||
/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;目录厂家 / 自定义 ⇒ `routeRef(route)`。 */
|
||||
export function refForEntry(entry: Pick<LandingEntry, 'route' | 'baseUrl'>): string {
|
||||
return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom')
|
||||
// 内置 DeepSeek ⇔ **没有 route**(它走 `dsh-llm-deepseek` + `DEEPSEEK_API_KEY`,不写 settings.yaml)。
|
||||
// ⚠️ 不能再用「baseUrl 为空」当判据:**目录厂家**(读官方 pi-ai 目录的那些 route)也是
|
||||
// baseUrl 为空 —— 它们的 endpoint/协议/模型全由目录提供 —— 但它们有自己的 route 与
|
||||
// 自己的 `<ROUTE>_API_KEY`。两者混一起会把目录厂家的 key 写进 DEEPSEEK_API_KEY。
|
||||
const route = entry.route ?? ''
|
||||
return route === '' ? BUILTIN_REF : routeRef(route)
|
||||
}
|
||||
|
||||
/** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */
|
||||
@@ -186,13 +191,19 @@ export function reconcileCredentials(
|
||||
return { text: out.join('\n'), managed: [...new Set(kept)] }
|
||||
}
|
||||
|
||||
/** 一条要写进 `settings.yaml` 的厂家声明。 */
|
||||
/**
|
||||
* 一条要写进 `settings.yaml` 的厂家声明。
|
||||
* ⚠️ `baseURL` / `api` / `models` **都可选**:**目录厂家**(route 命中官方 pi-ai 目录)只需要
|
||||
* `apiKeyEnv` —— 它的 endpoint、协议、模型目录全部由官方目录提供(官方 `config.d.ts`:
|
||||
* "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")。写得越少越不容易漂。
|
||||
*/
|
||||
export interface SettingsEntry {
|
||||
route: string
|
||||
apiKeyEnv: string
|
||||
baseURL: string
|
||||
api: Protocol
|
||||
models: string[]
|
||||
baseURL?: string
|
||||
api?: Protocol
|
||||
models?: string[]
|
||||
}
|
||||
|
||||
const markBegin = (route: string): string => ` # dshs:model-route ${route} begin`
|
||||
@@ -285,16 +296,19 @@ export function reconcileSettings(
|
||||
}
|
||||
}
|
||||
|
||||
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 blocks = add.flatMap((d) => {
|
||||
const lines = [markBegin(d.route), ` ${d.route}:`, ` apiKeyEnv: ${d.apiKeyEnv}`]
|
||||
// 只写**给了的**字段:目录厂家只给 apiKeyEnv(其余由官方目录兜底),
|
||||
// 自定义厂家才需要 baseURL / api / models。
|
||||
if (d.baseURL !== undefined && d.baseURL !== '') lines.push(` baseURL: ${d.baseURL}`)
|
||||
if (d.api !== undefined) lines.push(` api: ${d.api}`)
|
||||
if (d.models !== undefined && d.models.length > 0) {
|
||||
lines.push(' models:')
|
||||
for (const m of d.models) lines.push(` - id: ${m}`)
|
||||
}
|
||||
lines.push(markEnd(d.route))
|
||||
return lines
|
||||
})
|
||||
|
||||
const out = [...kept]
|
||||
if (provAt >= 0) {
|
||||
|
||||
+54
-5
@@ -10,6 +10,7 @@ import { requireAuth } from '../middleware/authn.js'
|
||||
import { homeRoot, userRoot } from '../../fs/workspace.js'
|
||||
import { deriveKey, encrypt } from '../../crypto.js'
|
||||
import { toPublicUser } from '../../db/types.js'
|
||||
import { isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
|
||||
import { PROTOCOLS } from '../model-landing.js'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
@@ -129,6 +130,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 32 },
|
||||
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
// 档案 87 补做:**目录厂家 id**(选了它只需 apiKey;endpoint/协议/模型由官方目录兜底)。
|
||||
provider: { type: 'string', minLength: 1, maxLength: 40 },
|
||||
// 档案 87:自定义厂家三件套 —— **都不给**就是老语义的「内置 DeepSeek 那一把 key」。
|
||||
route: { type: 'string', minLength: 1, maxLength: 40 },
|
||||
baseUrl: { type: 'string', maxLength: 300 },
|
||||
@@ -216,10 +219,38 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
protocols: [...PROTOCOLS],
|
||||
}))
|
||||
|
||||
/**
|
||||
* 官方 **pi-ai 厂家目录**(档案 87 补做)——「新增模型条目」的选择框数据源。
|
||||
*
|
||||
* 为什么由后端给:目录是**安装期冻结**在官方包里的(`@earendil-works/pi-ai/dist/providers/data/`),
|
||||
* 前端拿不到也不该硬编码;后端读一次缓存 10 分钟。选中的厂家**只要填 API Key** ——
|
||||
* endpoint / 协议 / 模型目录全由目录提供(见 `model-catalog.ts` 头注释)。
|
||||
* ⚠️ 目录里的 `deepseek` 被**排除**:平台已有「内置 DeepSeek」入口(走 `dsh-llm-deepseek` +
|
||||
* 平台共享 key),再列一个同名选项只会让用户分不清哪个生效。
|
||||
*/
|
||||
app.get('/api/me/model-providers', { preHandler: requireAuth }, async () => {
|
||||
const all = await listCatalogProviders()
|
||||
return {
|
||||
providers: all
|
||||
.filter((p) => p.id !== 'deepseek')
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
api: p.api,
|
||||
baseURL: p.baseURL,
|
||||
cn: isCnProvider(p.id),
|
||||
modelCount: p.models.length,
|
||||
// 只回前 60 个模型名给界面展示(足量的"看到它自带什么"),不整份下发。
|
||||
models: p.models.slice(0, 60).map((m) => m.name ?? m.id),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => {
|
||||
const body = request.body as {
|
||||
name: string
|
||||
apiKey: string
|
||||
provider?: string
|
||||
route?: string
|
||||
baseUrl?: string
|
||||
api?: string
|
||||
@@ -235,6 +266,28 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(body.apiKey)) {
|
||||
return reply.code(400).send({ error: 'invalid_api_key' })
|
||||
}
|
||||
const existingKeys = await app.db.listCredentialKeys(request.user!.id)
|
||||
/** route 是 settings.yaml 里的 dict 键 ⇒ 同一用户下撞键 = 后写的覆盖前者、静默失效。 */
|
||||
const routeTaken = (r: string): boolean => existingKeys.some((k) => k.route === r && k.name !== cleanName)
|
||||
|
||||
// ── 情形 A:**目录厂家**(用户只填了 API Key)────────────────────────────────
|
||||
const providerId = (body.provider ?? '').trim()
|
||||
if (providerId !== '') {
|
||||
if (!(await isCatalogProvider(providerId))) return reply.code(400).send({ error: 'unknown_provider' })
|
||||
if (routeTaken(providerId)) return reply.code(409).send({ error: 'route_taken' })
|
||||
const key = await app.db.setCredentialKey(
|
||||
request.user!.id,
|
||||
cleanName,
|
||||
encrypt(body.apiKey, deriveKey(app.config.encryptionSecret)),
|
||||
// 只记 route:endpoint / 协议 / 模型清单**都不写**,交给官方目录兜底。
|
||||
{ route: providerId, baseUrl: null, api: null, models: null },
|
||||
)
|
||||
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName, provider: providerId }))
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { key }
|
||||
}
|
||||
|
||||
// ── 情形 B:内置 DeepSeek(不带 baseUrl)/自定义网关(带 baseUrl)────────────
|
||||
const baseUrl = (body.baseUrl ?? '').trim()
|
||||
const isCustom = baseUrl !== ''
|
||||
let route: string | null = null
|
||||
@@ -251,11 +304,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const ids = (body.models ?? []).map((m) => m.trim()).filter((m) => m !== '')
|
||||
if (ids.length === 0) return reply.code(400).send({ error: 'models_required' })
|
||||
models = JSON.stringify(ids.slice(0, 50))
|
||||
// route 是 settings.yaml 里的 dict 键 ⇒ 同一个用户下撞键 = 后写入的会覆盖前者,静默失效。
|
||||
const existing = await app.db.listCredentialKeys(request.user!.id)
|
||||
if (existing.some((k) => k.route === route && k.name !== cleanName)) {
|
||||
return reply.code(409).send({ error: 'route_taken' })
|
||||
}
|
||||
if (routeTaken(route)) return reply.code(409).send({ error: 'route_taken' })
|
||||
}
|
||||
const key = await app.db.setCredentialKey(
|
||||
request.user!.id,
|
||||
|
||||
+18
-10
@@ -183,16 +183,24 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
}
|
||||
seen.add(ref)
|
||||
creds.push({ ref, value })
|
||||
// 只有"自定义厂家"才写 settings.yaml:内置 DeepSeek 由官方 `dsh-llm-deepseek` 自己管
|
||||
// (它的 route 是 `deepseek-official`,不是 `llm-pi-ai` 下的键)。
|
||||
if (row.baseUrl !== null && row.baseUrl !== '' && row.route !== null && row.route !== '') {
|
||||
providers.push({
|
||||
route: row.route,
|
||||
apiKeyEnv: ref,
|
||||
baseURL: row.baseUrl,
|
||||
api: normalizeProtocol(row.api),
|
||||
models: parseModels(row.models),
|
||||
})
|
||||
// 有 route 才写 settings.yaml。两种情形:
|
||||
// · **目录厂家**(route 命中官方 pi-ai catalog,baseUrl 为空)⇒ 只写 `apiKeyEnv`,
|
||||
// endpoint / 协议 / 模型目录全由官方目录提供(档案 87 补做)。
|
||||
// · **自定义厂家**(baseUrl 非空)⇒ endpoint + 协议 + 模型清单必须齐全。
|
||||
// 内置 DeepSeek 没有 route ⇒ 只写 `refs.DEEPSEEK_API_KEY`,不进 settings.yaml。
|
||||
const route = row.route ?? ''
|
||||
if (route !== '') {
|
||||
if (row.baseUrl === null || row.baseUrl === '') {
|
||||
providers.push({ route, apiKeyEnv: ref })
|
||||
} else {
|
||||
providers.push({
|
||||
route,
|
||||
apiKeyEnv: ref,
|
||||
baseURL: row.baseUrl,
|
||||
api: normalizeProtocol(row.api),
|
||||
models: parseModels(row.models),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const credFile = join(owner.home_dir, '.credentials.yaml')
|
||||
|
||||
Reference in new issue
Block a user