feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)

- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 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)。
This commit is contained in:
admin committed 2026-09-14 00:00:15 +08:00
1 parent eb50ca2d5b
commit 918f1d3a51
20 files changed
+2073 -276

No files matched your search

+17 -1
View File
@@ -8,6 +8,8 @@
import type {
BusinessPlugin,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
@@ -71,9 +73,23 @@ export interface DbAdapter {
setDomainVerified(id: string, verified: boolean): Promise<boolean>
// credential vault
listCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 86:该用户**全部已启用**的条目(spawn 时按它们写实例配置)。 */
listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 87:同上 + **encrypted ref** —— **仅供 `server.ts` 的落地层**,绝不经 API 返回。 */
listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]>
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey>
setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey>
selectCredentialKey(userId: string, id: string): Promise<boolean>
/** 档案 86:开/关**单个**条目(不动其它条目 —— 用户口径:可同时启用多个)。 */
toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean>
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
getSharedModelEnabled(userId: string): Promise<boolean>
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
deleteCredentialKey(userId: string, id: string): Promise<boolean>
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
+120 -28
View File
@@ -20,6 +20,8 @@ import {
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CredentialKeyMeta,
type CredentialLandingRow,
type CreateSessionInput,
type CreateUserInput,
type Domain,
@@ -64,6 +66,38 @@ export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<
}
}
/**
* 凭据行的列清单与映射(档案 87)—— 与 `repo.ts` 里同名的一组**逐字对应**:
* 两个后端必须选出同一批列,否则就是"SQLite 上好好的、k8s 上少字段"这种
* 只在生产才出现的偏差。(**不**从 `repo.ts` 导入:那会把 better-sqlite3 原生依赖
* 拖进 pg 模式。)
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id: string
key_name: string
enabled: number
updated_at: number
route: string | null
base_url: string | null
api: string | null
models: string | null
}
function toCredentialKey(r: CredentialRow): CredentialKey {
return {
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
}
}
export class PgAdapter implements DbAdapter {
constructor(private readonly pool: Pool, private readonly baseUid: number) {}
@@ -336,65 +370,123 @@ export class PgAdapter implements DbAdapter {
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
const { rows } = await this.pool.query(
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC',
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC`,
[userId],
)
return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({
id: r.id,
return (rows as CredentialRow[]).map(toCredentialKey)
}
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
const { rows } = await this.pool.query(
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC`,
[userId],
)
return (rows as CredentialRow[]).map(toCredentialKey)
}
/** 落地层专用:多返回 `secret_ref`(密文)—— 与 `listEnabledCredentialKeys` 的唯一差别。 */
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
const { rows } = await this.pool.query(
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC',
[userId],
)
return (
rows as Array<{
key_name: string
route: string | null
base_url: string | null
api: string | null
models: string | null
secret_ref: string
}>
).map((r) => ({
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
encryptedRef: r.secret_ref,
}))
}
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的语义重定义)。
* 互斥被删之后 `enabled = 1` 可能命中多行 ⇒ 必须钉死"内置 + 最新一条",
* 否则调用方会随机拿到某一个厂家的 key(详见 `repo.ts` 同名函数的注释)。
*/
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
const { rows } = await this.pool.query(
'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1',
"SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
[userId],
)
const row = rows[0] as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
/** Upsert by `name` 并启用它 —— **不动**其它条目(档案 87,互斥已删)。 */
async setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey> {
const route = meta?.route ?? null
const baseUrl = meta?.baseUrl ?? null
const api = meta?.api ?? null
const models = meta?.models ?? null
try {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const existing = await client.query(
'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2',
[userId, name],
)
const existing = await client.query('SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', [
userId,
name,
])
let id: string
if (existing.rows.length > 0) {
id = (existing.rows[0] as { id: string }).id
await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [
encryptedRef,
Date.now(),
id,
])
await client.query(
'UPDATE credential_vault SET secret_ref = $1, route = $2, base_url = $3, api = $4, models = $5, enabled = 1, updated_at = $6 WHERE id = $7',
[encryptedRef, route, baseUrl, api, models, Date.now(), id],
)
} else {
id = randomUUID()
await client.query(
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)',
[id, userId, name, encryptedRef, Date.now()],
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9)',
[id, userId, name, encryptedRef, route, baseUrl, api, models, Date.now()],
)
}
return { id, name, enabled: true, updatedAt: Date.now() }
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
})
} catch (e) {
mapPgError(e)
}
}
/** 启用一个条目(档案 87:**非互斥**,不再先全关)。 */
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [
id,
userId,
])
return (result.rowCount ?? 0) > 0
})
return await this.toggleCredentialKey(userId, id, true)
}
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
const result = await this.pool.query(
'UPDATE credential_vault SET enabled = $1, updated_at = $2 WHERE id = $3 AND user_id = $4',
[enabled ? 1 : 0, Date.now(), id, userId],
)
return (result.rowCount ?? 0) > 0
}
/** 该用户是否启用「平台共享模型」(档案 87)—— 缺失行按 `true`(默认值)。 */
async getSharedModelEnabled(userId: string): Promise<boolean> {
const { rows } = await this.pool.query('SELECT shared_model_enabled FROM users WHERE id = $1', [userId])
const row = rows[0] as { shared_model_enabled: number } | undefined
return row === undefined ? true : Number(row.shared_model_enabled) !== 0
}
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
const result = await this.pool.query('UPDATE users SET shared_model_enabled = $1 WHERE id = $2', [
enabled ? 1 : 0,
userId,
])
return (result.rowCount ?? 0) > 0
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
+160 -27
View File
@@ -21,6 +21,8 @@ import {
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CredentialKeyMeta,
type CredentialLandingRow,
type CreateSessionInput,
type CreateUserInput,
type Domain,
@@ -211,57 +213,188 @@ export function upsertDomain(db: Database, userId: string, domain: string, nginx
return findDomainByUser(db, userId)!
}
/**
* 两个凭据列表共用的列清单与行映射(档案 87)—— 抽出来是为了**两处不可能漂**:
* 之前 `listCredentialKeys` 与 `listEnabledCredentialKeys` 各写一份 SELECT,
* 加一次列就要改两处,漏一处就是"一个列表有 route、另一个没有"。
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id: string
key_name: string
enabled: number
updated_at: number
route: string | null
base_url: string | null
api: string | null
models: string | null
}
function toCredentialKey(r: CredentialRow): CredentialKey {
return {
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
}
}
/** List a user's named credential keys (metadata only). */
export function listCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC',
).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>
return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at }))
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的**语义重定义**,必须读这一条)。
*
* 老语义是「那一把启用的 key」—— 当时 `setCredentialKey` 会先 `SET enabled = 0` 全关,
* 所以 `enabled = 1` **最多一行**,不带 ORDER BY 也唯一。
* 用户口径改成「条目各自开关、都能同时启用」后,互斥被删(见 `setCredentialKey`)⇒
* `WHERE enabled = 1` 可能命中**多行**,而**没有 ORDER BY 就是"任取一条"** ——
* `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会因此**随机飘**。
*
* ⇒ 这里把语义钉死为:**已启用、且是内置 DeepSeek(`base_url` 为空)的最新一条**。
* 自定义厂家**不算**"自己的 DeepSeek key"(它们各自有自己的 ref 与 settings 段)。
* @returns 该 ref,或 `null`(用户没有任何启用的内置条目)。
*/
export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null {
const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as
| { secret_ref: string }
| undefined
const row = prepare(
db,
"SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
).get(userId) as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
/** Upsert a named key, disable the others, and enable this one. */
export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey {
/**
* Upsert a named key by `name` and enable it —— **不动**其它条目(档案 87)。
*
* ⚠️ 老行为是「先 `SET enabled = 0` 全关,再开这一个」(单选)。用户口径已改为
* 「条目各自开关、都能同时启用」,所以那一行**已删**:否则用户每加一把 key,
* 别的厂家就被静默关掉。要"只开这一个"请显式先关别的(`toggleCredentialKey`)。
* @param meta - 模型厂家元数据(route / endpoint / 协议 / 模型清单),见 {@link CredentialKeyMeta}。
*/
export function setCredentialKey(
db: Database,
userId: string,
name: string,
encryptedRef: string,
meta: CredentialKeyMeta = {},
): CredentialKey {
const route = meta.route ?? null
const baseUrl = meta.baseUrl ?? null
const api = meta.api ?? null
const models = meta.models ?? null
const id = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get(
userId,
name,
) as { id: string } | undefined
if (existing !== undefined) {
prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run(
encryptedRef,
Date.now(),
existing.id,
)
prepare(
db,
'UPDATE credential_vault SET secret_ref = ?, route = ?, base_url = ?, api = ?, models = ?, enabled = 1, updated_at = ? WHERE id = ?',
).run(encryptedRef, route, baseUrl, api, models, Date.now(), existing.id)
return existing.id
}
const id = randomUUID()
const newId = randomUUID()
prepare(
db,
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)',
).run(id, userId, name, encryptedRef, Date.now())
return id
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
).run(newId, userId, name, encryptedRef, route, baseUrl, api, models, Date.now())
return newId
})()
return { id, name, enabled: true, updatedAt: Date.now() }
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
}
/** Enable one of a user's named keys (disabling the others). */
/**
* 启用某一个条目(档案 87:**改为非互斥**)。
*
* 老语义是「单选」:先 `SET enabled = 0` 把该用户所有条目关掉,再开这一个。
* 用户口径改成「各自开关、可同时启用」之后,那一步会**悄悄关掉别的已启用条目**
* (用户看不出为什么换了个厂家另一个就不生效了),所以这里退化成
* `toggleCredentialKey(id, true)` 的同义实现 —— **保留函数名只为接口兼容**。
*/
export function selectCredentialKey(db: Database, userId: string, id: string): boolean {
const ok = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId)
return info.changes > 0
})()
return ok as boolean
return toggleCredentialKey(db, userId, id, true)
}
/**
* 打开/关闭**单个**条目(档案 87;用户口径:条目各自开关、可同时启用)。
* 与 `selectCredentialKey`(名字带"单选"但已改为非互斥)的差别:这里**只碰这一行**。
* @returns 是否命中了该用户下的这一行(false = 不存在或不属于他)。
*/
export function toggleCredentialKey(db: Database, userId: string, id: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE credential_vault SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?').run(
enabled ? 1 : 0,
Date.now(),
id,
userId,
)
return info.changes > 0
}
/** 该用户**所有已启用**的条目(含 route / base_url / api / models)—— spawn 时按它们写实例配置。 */
export function listEnabledCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/**
* 该用户**所有已启用**的条目 + 各自 encrypted ref —— **仅供落地层**(档案 87)。
* 与 `listEnabledCredentialKeys` 的差别只有一个:多返回 `secret_ref`。
* 单独一个函数是为了让"密文"这件事**不可能**顺着 `/api/me/keys` 漏出去。
*/
export function listCredentialLandingRows(db: Database, userId: string): CredentialLandingRow[] {
const rows = prepare(
db,
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC',
).all(userId) as Array<{
key_name: string
route: string | null
base_url: string | null
api: string | null
models: string | null
secret_ref: string
}>
return rows.map((r) => ({
name: r.key_name,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
encryptedRef: r.secret_ref,
}))
}
/**
* 该用户是否启用「平台共享模型」(档案 87)—— **用户侧偏好**,开关它**不动** admin 的配置。
*
* 缺失行一律按 `true` 处理:V6 迁移给所有老用户填了默认 1,而"查不到这个人"时
* 也不该因为一个开关把共享 key 断掉(宁可多给,不可少给)。
*/
export function getSharedModelEnabled(db: Database, userId: string): boolean {
const row = prepare(db, 'SELECT shared_model_enabled FROM users WHERE id = ?').get(userId) as
| { shared_model_enabled: number }
| undefined
return row === undefined ? true : row.shared_model_enabled !== 0
}
/** 打开/关闭「平台共享模型」(档案 87)。@returns 是否命中该用户。 */
export function setSharedModelEnabled(db: Database, userId: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE users SET shared_model_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, userId)
return info.changes > 0
}
/** Delete a named key (by id, scoped to the user). */
+35
View File
@@ -258,6 +258,40 @@ CREATE TABLE IF NOT EXISTS business_plugins (
);
`
// v6: 用户自配「模型厂家」支持(档案 87)。
// 原先 `credential_vault` 只存一把 key(`key_name` 兼作展示名);用户要按**厂家**配模型,
// 平台还需要知道:**route**(= settings.yaml 里 `llm-pi-ai.providers` 的 dict 键)、
// **endpoint**(`baseURL`)、**协议**(`api`)、**模型清单**(`models`)—— spawn 时按这四样写
// `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>` 与
// `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`。
// · `base_url` 为空 = **内置 DeepSeek**(只写 refs,不写 settings.yaml)。
// · REF 命名:内置 = `DEEPSEEK_API_KEY`;自定义 = `<ROUTE 大写化>_API_KEY`
// (须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)。
// · `api` 的合法值只有三个(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级):
// `openai-completions` / `openai-responses` / `anthropic-messages`。
// · ⚠️ 字段名是 **`api` 不是 `protocol`**;`apiKeyEnv`(不是 `apiKey`);且该 profile
// **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错)。
// 以上全部为 2026-09-13 读官方包 `[email protected]` 的 `lib/index.js`
// (`const NS = "llm-pi-ai"` / `profile` schema / `PROTOCOLS`)实测结论。
// 另:`users.shared_model_enabled` = 用户**要不要用平台共享模型**(admin 配的那把)——
// 属于用户侧偏好,开关它**不动** admin 的配置(用户口径:条目各自开关,都能同时启用;
// 具体用哪个模型是在 dsh 对话框的模型选择器里挑)。
const SQLITE_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
const PG_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
interface Migration {
version: number
name: string
@@ -271,6 +305,7 @@ const MIGRATIONS: readonly Migration[] = [
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
{ version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
+34 -2
View File
@@ -39,9 +39,12 @@ import {
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync,
listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync,
listCredentialLandingRows as listCredentialLandingRowsSync,
listDomains as listDomainsSync,
listEnabledCredentialKeys as listEnabledCredentialKeysSync,
listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync,
@@ -50,8 +53,10 @@ import {
setDomainVerified as setDomainVerifiedSync,
setFolderPlugins as setFolderPluginsSync,
setInstanceStatus as setInstanceStatusSync,
setSharedModelEnabled as setSharedModelEnabledSync,
setUserRole as setUserRoleSync,
setUserUid as setUserUidSync,
toggleCredentialKey as toggleCredentialKeySync,
upsertBusinessPlugin as upsertBusinessPluginSync,
upsertDomain as upsertDomainSync,
upsertInstance as upsertInstanceSync,
@@ -59,6 +64,8 @@ import {
import type {
BusinessPlugin,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
@@ -229,13 +236,26 @@ export class SqliteAdapter implements DbAdapter {
return listCredentialKeysSync(this.db, userId)
}
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
return listEnabledCredentialKeysSync(this.db, userId)
}
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
return listCredentialLandingRowsSync(this.db, userId)
}
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
return getEnabledCredentialKeyRefSync(this.db, userId)
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
async setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey> {
try {
return setCredentialKeySync(this.db, userId, name, encryptedRef)
return setCredentialKeySync(this.db, userId, name, encryptedRef, meta)
} catch (e) {
mapSqliteError(e)
}
@@ -245,6 +265,18 @@ export class SqliteAdapter implements DbAdapter {
return selectCredentialKeySync(this.db, userId, id)
}
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
return toggleCredentialKeySync(this.db, userId, id, enabled)
}
async getSharedModelEnabled(userId: string): Promise<boolean> {
return getSharedModelEnabledSync(this.db, userId)
}
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
return setSharedModelEnabledSync(this.db, userId, enabled)
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
return deleteCredentialKeySync(this.db, userId, id)
}
+52
View File
@@ -98,6 +98,58 @@ export interface CredentialKey {
name: string
enabled: boolean
updatedAt: number
/**
* settings.yaml 里 `llm-pi-ai.providers` 的 **dict 键**(档案 87)。内置 DeepSeek 条目
* 可用 `deepseek`;自定义厂家由用户给(平台按名字生成 slug 作为默认值)。
* ⚠️ 不是「厂家名」,而是**寻址键** —— 改名不影响它,所以平台把它显式存下来。
*/
route?: string | null
/**
* 自定义厂家的 endpoint(档案 87)。`null` = **内置 DeepSeek**(此时不写 settings.yaml,
* 只把 key 落到 `refs.DEEPSEEK_API_KEY`);非空 = 用户声明的 OpenAI 兼容网关,
* 平台会写 `settings.yaml` 的 `llm-pi-ai.providers.<route>`(`baseURL` + `api` + `models`)。
*/
baseUrl?: string | null
/**
* 该 route 的**线协议**(档案 87)—— settings.yaml 里字段名是 **`api`**。
* 合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`
* (官方 `dsh-llm-pi-ai` 的 `PROTOCOLS` 键;空 = 取默认 `openai-completions`)。
*/
api?: string | null
/** 该厂家下的模型 id 清单(JSON 数组字符串;自定义厂家必填,内置厂家可为空)。 */
models?: string | null
}
/**
* 写入一条凭据时可带的**模型厂家元数据**(档案 87)。
* 全部可空:只给 `name` + `encryptedRef` 时就是老语义的「内置 DeepSeek 那一把 key」。
*/
export interface CredentialKeyMeta {
/** settings.yaml 里 `llm-pi-ai.providers` 的 dict 键;空 = 内置 DeepSeek。 */
route?: string | null
/** 自定义厂家的 endpoint;空 = 内置(只写 `.credentials.yaml`,不写 settings.yaml)。 */
baseUrl?: string | null
/** 线协议(settings.yaml 的 `api`);空 = 官方默认 `openai-completions`。 */
api?: string | null
/** 模型 id 的 JSON 数组字符串。 */
models?: string | null
}
/**
* **落地层专用**:一条已启用条目 + 它的 encrypted ref(档案 87)。
*
* 为什么单独一个类型:{@link CredentialKey} 会被 `/api/me/keys` **原样返回给浏览器**,
* 所以它刻意不含密文;而 `server.ts` 要把 key 写进实例的 `.credentials.yaml`,
* 必须要密文(用部署密钥解出来)。两件事的受众不同 ⇒ 两个类型,别合并。
*/
export interface CredentialLandingRow {
name: string
route: string | null
baseUrl: string | null
api: string | null
models: string | null
/** 部署密钥加密后的 ref(`decrypt()` 之后才是明文 key)。 */
encryptedRef: string
}
/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */