Files
tkmind_go/ui/h5/llm-providers.mjs
john 4e21ca937a
Deploy Documentation / deploy (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / build-cli (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Canary / bundle-desktop (push) Has been cancelled
Canary / bundle-desktop-intel (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-desktop-windows (push) Has been cancelled
Canary / bundle-desktop-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
CI / H5 Plaza Tests and Build (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled
Live Provider Tests / goose server HTTP integration tests (push) Has been cancelled
Publish Ask AI Bot Docker Image / docker (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Add TKMind platform extensions, H5/MindSpace stack, and deployment tooling.
Fork goose with custom MCP widgets, platform extensions (aider, git, web, search),
MindSpace H5 backend/frontend, Plaza/Ops UIs, and deploy scripts for tkmind.cn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 21:30:20 +08:00

914 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'node:crypto';
import { Agent, fetch as undiciFetch } from 'undici';
export const CUSTOM_PROVIDER_ID = '__custom__';
export const LLM_PROVIDER_CATALOG = [
{
id: CUSTOM_PROVIDER_ID,
label: '自定义 OpenAI 兼容',
kind: 'custom',
apiKeyEnv: null,
defaultModel: '',
models: [],
},
{
id: 'custom_deepseek',
label: 'DeepSeek',
kind: 'builtin',
apiKeyEnv: 'DEEPSEEK_API_KEY',
defaultModel: 'deepseek-chat',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
{
id: 'openai',
label: 'OpenAI',
kind: 'builtin',
apiKeyEnv: 'OPENAI_API_KEY',
defaultModel: 'gpt-4o',
models: ['gpt-4o', 'gpt-4o-mini'],
},
{
id: 'openrouter',
label: 'OpenRouter',
kind: 'builtin',
apiKeyEnv: 'OPENROUTER_API_KEY',
defaultModel: 'anthropic/claude-sonnet-4',
models: ['anthropic/claude-sonnet-4', 'openai/gpt-4o'],
},
{
id: 'anthropic',
label: 'Anthropic',
kind: 'builtin',
apiKeyEnv: 'ANTHROPIC_API_KEY',
defaultModel: 'claude-sonnet-4-20250514',
models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
},
];
const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item]));
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
});
function resolveEncryptionKey(explicitKey) {
const raw =
explicitKey ??
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
process.env.TKMIND_SERVER__SECRET_KEY ??
'local-dev-secret';
return crypto.createHash('sha256').update(raw).digest();
}
export function encryptSecret(plaintext, encryptionKey) {
const key = resolveEncryptionKey(encryptionKey);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
};
}
export function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
const key = resolveEncryptionKey(encryptionKey);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
const plain = Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'base64')),
decipher.final(),
]);
return plain.toString('utf8');
}
export function maskApiKey(apiKey) {
if (!apiKey) return '';
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
const head = apiKey.slice(0, 4);
const tail = apiKey.slice(-4);
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
}
export function parseModelList(raw) {
if (Array.isArray(raw)) {
return [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
}
return [
...new Set(
String(raw ?? '')
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean),
),
];
}
export function normalizeApiUrl(raw) {
const trimmed = String(raw ?? '').trim();
if (!trimmed) return '';
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, '');
return `http://${trimmed.replace(/\/+$/, '')}`;
}
export function resolveChatCompletionsUrl(apiUrl) {
const normalized = normalizeApiUrl(apiUrl);
if (!normalized) return '';
if (normalized.endsWith('/chat/completions')) return normalized;
if (normalized.endsWith('/v1')) return `${normalized}/chat/completions`;
return `${normalized}/chat/completions`;
}
export const RELAY_BOOTSTRAP = {
name: process.env.H5_RELAY_BOOTSTRAP_NAME ?? 'Relay Buyer Ollama',
apiUrl:
process.env.H5_RELAY_BOOTSTRAP_URL ??
'http://127.0.0.1:18300/relay/buyer/v1/chat/completions',
apiKey:
process.env.H5_RELAY_BOOTSTRAP_API_KEY ??
'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo',
models: parseModelList(process.env.H5_RELAY_BOOTSTRAP_MODELS ?? 'qwen2.5:3b'),
defaultModel: process.env.H5_RELAY_BOOTSTRAP_MODEL ?? 'qwen2.5:3b',
relayProvider: process.env.H5_RELAY_BOOTSTRAP_PROVIDER ?? 'ollama',
};
export async function testRelayConnection(
{ apiUrl, apiKey, model, relayProvider },
fetchImpl = undiciFetch,
) {
const url = resolveChatCompletionsUrl(apiUrl);
if (!url) return { ok: false, message: 'API 地址无效' };
if (!apiKey) return { ok: false, message: '缺少 API Key' };
if (!model) return { ok: false, message: '缺少模型' };
const started = Date.now();
const body = {
model,
messages: [{ role: 'user', content: 'Hello' }],
stream: false,
...(relayProvider ? { provider: relayProvider } : {}),
};
const upstream = await fetchImpl(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
dispatcher: url.startsWith('https://') ? insecureDispatcher : undefined,
});
const latencyMs = Date.now() - started;
const text = await upstream.text().catch(() => '');
if (!upstream.ok) {
let detail = text.slice(0, 500) || '(空响应)';
if (detail === '{}') {
detail = '空 JSON 响应';
}
const statusHint =
upstream.status === 401
? 'Bearer Token 无效或已过期'
: upstream.status === 404
? '地址不存在,请检查 API URL'
: upstream.status === 400
? '请求参数被拒绝,请检查 model / provider'
: `HTTP ${upstream.status}`;
return {
ok: false,
latencyMs,
message: `Relay ${upstream.status} ${statusHint}${detail}`,
};
}
let data;
try {
data = JSON.parse(text);
} catch {
return { ok: false, latencyMs, message: '响应不是 JSON' };
}
const reply =
data?.choices?.[0]?.message?.content ??
data?.message?.content ??
data?.output ??
null;
return {
ok: true,
latencyMs,
model,
reply: reply ? String(reply).slice(0, 300) : '(联通成功,无文本内容)',
};
}
function parseModelsJson(raw) {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parseModelList(parsed) : [];
} catch {
return [];
}
}
function rowToPublic(row, catalogItem, apiKeyForMask) {
const providerKind = row.provider_kind ?? 'builtin';
const models =
providerKind === 'custom'
? parseModelsJson(row.models_json)
: (catalogItem?.models ?? []);
return {
id: row.id,
providerId: row.provider_id,
providerKind,
providerLabel:
providerKind === 'custom'
? row.name
: (catalogItem?.label ?? row.provider_id),
name: row.name,
defaultModel: row.default_model,
models,
apiUrl: row.api_url ?? null,
basePath: row.base_path ?? null,
engine: row.engine ?? 'openai',
relayProvider: row.relay_provider ?? null,
goosedProviderId: row.goosed_provider_id ?? null,
status: row.status,
isSelected: Boolean(row.is_selected),
apiKeyMasked: maskApiKey(apiKeyForMask),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function profileFromRow(row, decryptRow) {
const providerKind = row.provider_kind ?? 'builtin';
const models =
providerKind === 'custom'
? parseModelsJson(row.models_json)
: [row.default_model];
return {
providerKind,
providerId: row.provider_id,
goosedProviderId: row.goosed_provider_id ?? null,
name: row.name,
defaultModel: row.default_model,
apiKey: decryptRow(row),
apiUrl: row.api_url ?? null,
basePath: row.base_path ?? null,
engine: row.engine ?? 'openai',
relayProvider: row.relay_provider ?? null,
models,
};
}
async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch) {
const url = new URL(pathname, apiTarget);
const headers = {
...(init.headers ?? {}),
'X-Secret-Key': apiSecret,
};
if (init.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
const dispatcher = apiTarget.startsWith('https://') ? insecureDispatcher : undefined;
return fetchImpl(url, { ...init, headers, dispatcher });
}
async function writeGoosedConfig(apiTarget, apiSecret, key, value, isSecret, fetchImpl) {
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
'/config/write',
{
method: 'POST',
body: JSON.stringify({ key, value, is_secret: isSecret }),
},
fetchImpl,
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(`同步 TKMind Agent 配置 ${key} 失败: ${text || upstream.status}`);
}
}
async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) {
const headers = profile.relayProvider
? { 'X-Provider': profile.relayProvider }
: undefined;
const body = {
engine: profile.engine || 'openai',
display_name: profile.name,
api_url: profile.apiUrl,
api_key: profile.apiKey,
models: profile.models,
supports_streaming: true,
requires_auth: true,
...(profile.basePath ? { base_path: profile.basePath } : {}),
...(headers ? { headers } : {}),
};
if (profile.goosedProviderId) {
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
`/config/custom-providers/${encodeURIComponent(profile.goosedProviderId)}`,
{ method: 'PUT', body: JSON.stringify(body) },
fetchImpl,
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(`更新 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
}
return profile.goosedProviderId;
}
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
'/config/custom-providers',
{ method: 'POST', body: JSON.stringify(body) },
fetchImpl,
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(`创建 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
}
const data = await upstream.json();
return data.provider_name;
}
async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) {
if (!goosedProviderId) return;
const upstream = await goosedApiFetch(
apiTarget,
apiSecret,
`/config/custom-providers/${encodeURIComponent(goosedProviderId)}`,
{ method: 'DELETE' },
fetchImpl,
);
if (!upstream.ok && upstream.status !== 404) {
const text = await upstream.text().catch(() => '');
throw new Error(`删除 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
}
}
async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
const catalogItem = catalogById[profile.providerId];
if (!catalogItem?.apiKeyEnv) {
throw new Error(`未知内置 provider: ${profile.providerId}`);
}
await writeGoosedConfig(
apiTarget,
apiSecret,
catalogItem.apiKeyEnv,
profile.apiKey,
true,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'GOOSE_PROVIDER',
profile.providerId,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'GOOSE_MODEL',
profile.defaultModel,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'TKMIND_PROVIDER',
profile.providerId,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'TKMIND_MODEL',
profile.defaultModel,
false,
fetchImpl,
);
}
export async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch) {
if (profile.providerKind === 'custom') {
const goosedProviderId = await upsertCustomProviderOnGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'GOOSE_PROVIDER',
goosedProviderId,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'GOOSE_MODEL',
profile.defaultModel,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'TKMIND_PROVIDER',
goosedProviderId,
false,
fetchImpl,
);
await writeGoosedConfig(
apiTarget,
apiSecret,
'TKMIND_MODEL',
profile.defaultModel,
false,
fetchImpl,
);
return goosedProviderId;
}
await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
return profile.providerId;
}
function isCustomPayload(payload) {
return payload?.providerId === CUSTOM_PROVIDER_ID || payload?.providerKind === 'custom';
}
function validateCustomPayload(payload) {
const name = String(payload?.name ?? '').trim();
const apiKey = String(payload?.apiKey ?? '').trim();
const apiUrl = normalizeApiUrl(payload?.apiUrl);
const models = parseModelList(payload?.models);
const defaultModel = String(payload?.defaultModel ?? '').trim() || models[0] || '';
if (!name) return { ok: false, message: '请填写配置名称' };
if (!apiUrl) return { ok: false, message: '请填写 API 地址' };
if (!apiKey) return { ok: false, message: '请填写 API Key / Bearer Token' };
if (models.length === 0) return { ok: false, message: '请至少填写一个模型' };
if (!models.includes(defaultModel)) {
return { ok: false, message: '默认模型必须在模型列表中' };
}
return {
ok: true,
value: {
name,
apiKey,
apiUrl,
models,
defaultModel,
basePath: String(payload?.basePath ?? '').trim() || null,
engine: String(payload?.engine ?? 'openai').trim() || 'openai',
relayProvider: String(payload?.relayProvider ?? '').trim() || null,
},
};
}
export function createLlmProviderService(
pool,
{ apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
) {
function catalogItem(providerId) {
return catalogById[providerId] ?? null;
}
function decryptRow(row) {
return decryptSecret(
{
ciphertext: row.api_key_ciphertext,
iv: row.api_key_iv,
tag: row.api_key_tag,
},
encryptionKey,
);
}
async function getRowById(id) {
const [rows] = await pool.query('SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1', [
id,
]);
return rows[0] ?? null;
}
async function getSelectedRow() {
const [rows] = await pool.query(
'SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1',
['active'],
);
return rows[0] ?? null;
}
async function clearSelected() {
await pool.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [
Date.now(),
]);
}
async function syncRow(row) {
const profile = profileFromRow(row, decryptRow);
const goosedProviderId = await syncProfileToGoosed(
apiTarget,
apiSecret,
profile,
apiFetchImpl,
);
if (profile.providerKind === 'custom' && goosedProviderId !== row.goosed_provider_id) {
await pool.query(
'UPDATE h5_llm_provider_keys SET goosed_provider_id = ?, provider_id = ?, updated_at = ? WHERE id = ?',
[goosedProviderId, goosedProviderId, Date.now(), row.id],
);
}
return goosedProviderId;
}
return {
catalog: LLM_PROVIDER_CATALOG,
async listKeys() {
const [rows] = await pool.query(
'SELECT * FROM h5_llm_provider_keys ORDER BY is_selected DESC, updated_at DESC',
);
return rows.map((row) =>
rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)),
);
},
async createKey(payload) {
const custom = isCustomPayload(payload);
let providerKind = 'builtin';
let providerId = String(payload?.providerId ?? '').trim();
let insertName = String(payload?.name ?? '').trim();
let apiKey = String(payload?.apiKey ?? '').trim();
let defaultModel = String(payload?.defaultModel ?? '').trim();
let apiUrl = null;
let basePath = null;
let engine = 'openai';
let relayProvider = null;
let modelsJson = null;
let meta = catalogItem(providerId);
if (custom) {
const validated = validateCustomPayload(payload);
if (!validated.ok) return validated;
providerKind = 'custom';
providerId = CUSTOM_PROVIDER_ID;
meta = catalogById[CUSTOM_PROVIDER_ID];
insertName = validated.value.name;
apiKey = validated.value.apiKey;
apiUrl = validated.value.apiUrl;
basePath = validated.value.basePath;
engine = validated.value.engine;
relayProvider = validated.value.relayProvider;
defaultModel = validated.value.defaultModel;
modelsJson = JSON.stringify(validated.value.models);
} else {
if (!meta) return { ok: false, message: '不支持的 provider' };
if (!insertName) return { ok: false, message: '请填写配置名称' };
if (!apiKey) return { ok: false, message: '请填写 API Key' };
defaultModel = defaultModel || meta.defaultModel;
if (!meta.models.includes(defaultModel)) {
return { ok: false, message: '不支持的模型' };
}
}
const [existing] = await pool.query(
'SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1',
[insertName],
);
if (existing.length > 0) return { ok: false, message: '配置名称已存在' };
const [countRows] = await pool.query('SELECT COUNT(*) AS total FROM h5_llm_provider_keys');
const shouldSelect = Number(countRows[0]?.total ?? 0) === 0;
const encrypted = encryptSecret(apiKey, encryptionKey);
const now = Date.now();
const id = crypto.randomUUID();
if (shouldSelect) await clearSelected();
await pool.query(
`INSERT INTO h5_llm_provider_keys
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider,
name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
[
id,
providerId,
providerKind,
apiUrl,
basePath,
modelsJson,
engine,
relayProvider,
insertName,
encrypted.ciphertext,
encrypted.iv,
encrypted.tag,
defaultModel,
shouldSelect ? 1 : 0,
now,
now,
],
);
let row = await getRowById(id);
if (shouldSelect) {
await syncRow(row);
row = await getRowById(id);
}
return { ok: true, key: rowToPublic(row, meta, apiKey) };
},
async updateKey(id, payload) {
const row = await getRowById(id);
if (!row) return { ok: false, message: '配置不存在' };
const providerKind = row.provider_kind ?? 'builtin';
const nextName =
payload?.name !== undefined ? String(payload.name).trim() : row.name;
const nextApiKey =
payload?.apiKey !== undefined ? String(payload.apiKey).trim() : null;
const nextStatus =
payload?.status === 'disabled' || payload?.status === 'active'
? payload.status
: row.status;
if (!nextName) return { ok: false, message: '请填写配置名称' };
const [existing] = await pool.query(
'SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1',
[nextName, id],
);
if (existing.length > 0) return { ok: false, message: '配置名称已存在' };
if (row.is_selected && nextStatus === 'disabled') {
return { ok: false, message: '当前启用的配置不能禁用,请先切换到其他配置' };
}
let nextModel = row.default_model;
let nextApiUrl = row.api_url;
let nextBasePath = row.base_path;
let nextEngine = row.engine ?? 'openai';
let nextRelayProvider = row.relay_provider;
let nextModelsJson = row.models_json;
if (providerKind === 'custom') {
const models = payload?.models !== undefined
? parseModelList(payload.models)
: parseModelsJson(row.models_json);
nextModel =
payload?.defaultModel !== undefined
? String(payload.defaultModel).trim()
: row.default_model;
nextApiUrl =
payload?.apiUrl !== undefined
? normalizeApiUrl(payload.apiUrl)
: row.api_url;
nextBasePath =
payload?.basePath !== undefined
? String(payload.basePath).trim() || null
: row.base_path;
nextEngine =
payload?.engine !== undefined
? String(payload.engine).trim() || 'openai'
: row.engine;
nextRelayProvider =
payload?.relayProvider !== undefined
? String(payload.relayProvider).trim() || null
: row.relay_provider;
if (models.length === 0) return { ok: false, message: '请至少保留一个模型' };
if (!models.includes(nextModel)) {
return { ok: false, message: '默认模型必须在模型列表中' };
}
if (!nextApiUrl) return { ok: false, message: '请填写 API 地址' };
nextModelsJson = JSON.stringify(models);
} else {
const meta = catalogItem(row.provider_id);
if (!meta) return { ok: false, message: '不支持的 provider' };
nextModel =
payload?.defaultModel !== undefined
? String(payload.defaultModel).trim()
: row.default_model;
if (!meta.models.includes(nextModel)) {
return { ok: false, message: '不支持的模型' };
}
}
const encrypted = nextApiKey
? encryptSecret(nextApiKey, encryptionKey)
: {
ciphertext: row.api_key_ciphertext,
iv: row.api_key_iv,
tag: row.api_key_tag,
};
const now = Date.now();
await pool.query(
`UPDATE h5_llm_provider_keys
SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?,
api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?,
default_model = ?, status = ?, updated_at = ?
WHERE id = ?`,
[
nextName,
nextApiUrl,
nextBasePath,
nextModelsJson,
nextEngine,
nextRelayProvider,
encrypted.ciphertext,
encrypted.iv,
encrypted.tag,
nextModel,
nextStatus,
now,
id,
],
);
if (row.is_selected) {
await syncRow(await getRowById(id));
}
const updated = await getRowById(id);
return {
ok: true,
key: rowToPublic(updated, catalogItem(updated.provider_id), nextApiKey || decryptRow(row)),
};
},
async selectKey(id) {
const row = await getRowById(id);
if (!row) return { ok: false, message: '配置不存在' };
if (row.status !== 'active') {
return { ok: false, message: '已禁用的配置不能启用' };
}
await clearSelected();
const now = Date.now();
await pool.query(
'UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?',
[now, id],
);
await syncRow(row);
const updated = await getRowById(id);
return {
ok: true,
key: rowToPublic(updated, catalogItem(updated.provider_id), decryptRow(updated)),
};
},
async deleteKey(id) {
const row = await getRowById(id);
if (!row) return { ok: false, message: '配置不存在' };
if (row.is_selected) {
return { ok: false, message: '当前启用的配置不能删除,请先切换到其他配置' };
}
if ((row.provider_kind ?? 'builtin') === 'custom' && row.goosed_provider_id) {
await removeCustomProviderOnGoosed(
apiTarget,
apiSecret,
row.goosed_provider_id,
apiFetchImpl,
);
}
await pool.query('DELETE FROM h5_llm_provider_keys WHERE id = ?', [id]);
return { ok: true };
},
async syncSelectedToGoosed() {
const row = await getSelectedRow();
if (!row) return { ok: true, synced: false };
await syncRow(row);
return { ok: true, synced: true };
},
async getGlobalSettings() {
const row = await getSelectedRow();
if (!row) {
return {
keyId: null,
keyName: null,
providerLabel: null,
globalModel: null,
availableModels: [],
};
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
return {
keyId: publicRow.id,
keyName: publicRow.name,
providerLabel: publicRow.providerLabel,
globalModel: publicRow.defaultModel,
availableModels: publicRow.models,
};
},
async setGlobalModel(model) {
const nextModel = String(model ?? '').trim();
if (!nextModel) return { ok: false, message: '请选择全局模型' };
const row = await getSelectedRow();
if (!row) return { ok: false, message: '请先启用一个 LLM 配置' };
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(nextModel)) {
return { ok: false, message: '模型不在当前 Provider 支持列表中' };
}
await pool.query(
'UPDATE h5_llm_provider_keys SET default_model = ?, updated_at = ? WHERE id = ?',
[nextModel, Date.now(), row.id],
);
await syncRow(await getRowById(row.id));
return { ok: true, global: await this.getGlobalSettings() };
},
async testDraft(payload) {
const custom = isCustomPayload(payload);
if (!custom) {
return { ok: false, message: '联通测试目前支持自定义 OpenAI 兼容配置' };
}
const validated = validateCustomPayload(payload);
if (!validated.ok) return validated;
const model = String(payload?.testModel ?? payload?.defaultModel ?? validated.value.defaultModel).trim();
if (!validated.value.models.includes(model)) {
return { ok: false, message: '测试模型不在模型列表中' };
}
return testRelayConnection(
{
apiUrl: validated.value.apiUrl,
apiKey: validated.value.apiKey,
model,
relayProvider: validated.value.relayProvider,
},
apiFetchImpl,
);
},
async testKey(id, testModel) {
const row = await getRowById(id);
if (!row) return { ok: false, message: '配置不存在' };
if ((row.provider_kind ?? 'builtin') !== 'custom') {
return { ok: false, message: '内置 Provider 联通测试暂未开放' };
}
const model = String(testModel ?? row.default_model).trim();
const models = parseModelsJson(row.models_json);
if (!models.includes(model)) {
return { ok: false, message: '测试模型不在配置列表中' };
}
return testRelayConnection(
{
apiUrl: row.api_url,
apiKey: decryptRow(row),
model,
relayProvider: row.relay_provider,
},
apiFetchImpl,
);
},
async ensureBootstrapRelay() {
const [existing] = await pool.query(
'SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1',
[RELAY_BOOTSTRAP.name],
);
if (existing.length > 0) {
const row = await getRowById(existing[0].id);
if (row && !row.is_selected) {
await this.selectKey(row.id);
}
return { ok: true, created: false, keyId: existing[0].id };
}
const result = await this.createKey({
providerId: CUSTOM_PROVIDER_ID,
name: RELAY_BOOTSTRAP.name,
apiKey: RELAY_BOOTSTRAP.apiKey,
apiUrl: RELAY_BOOTSTRAP.apiUrl,
models: RELAY_BOOTSTRAP.models,
defaultModel: RELAY_BOOTSTRAP.defaultModel,
relayProvider: RELAY_BOOTSTRAP.relayProvider,
});
if (!result.ok) return result;
if (result.key && !result.key.isSelected) {
await this.selectKey(result.key.id);
}
return { ok: true, created: true, key: result.key };
},
};
}