Files
memind/llm-providers.mjs
T
john 229805a070 Improve WeChat MP replies and ship MindSpace/H5 production updates.
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:06:43 +08:00

1107 lines
34 KiB
JavaScript
Raw 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 const LOCAL_LLM_FALLBACK = {
enabled: process.env.H5_LOCAL_LLM_FALLBACK !== '0',
providerId: process.env.H5_LOCAL_LLM_PROVIDER_ID ?? 'custom_local_ollama_7b',
displayName: process.env.H5_LOCAL_LLM_NAME ?? 'Local Ollama 7B',
apiUrl:
process.env.H5_LOCAL_LLM_URL ?? 'http://127.0.0.1:11434/v1/chat/completions',
apiKey: process.env.H5_LOCAL_LLM_API_KEY ?? 'ollama',
model: process.env.H5_LOCAL_LLM_MODEL ?? 'qwen2.5:3b',
};
let cachedLocalFallbackProviderId = null;
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) : '(联通成功,无文本内容)',
};
}
export async function testLocalLlmConnection(fetchImpl = undiciFetch) {
if (!LOCAL_LLM_FALLBACK.enabled) {
return { ok: false, message: '本地 LLM fallback 已禁用' };
}
return testRelayConnection(
{
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
apiKey: LOCAL_LLM_FALLBACK.apiKey,
model: LOCAL_LLM_FALLBACK.model,
relayProvider: null,
},
fetchImpl,
);
}
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/upsert',
{
method: 'POST',
body: JSON.stringify({ key, value, is_secret: isSecret }),
},
fetchImpl,
);
if (upstream.ok) return { ok: true, skipped: false };
if (upstream.status === 404) return { ok: false, skipped: true };
const text = await upstream.text().catch(() => '');
throw new Error(`同步 TKMind Agent 配置 ${key} 失败: ${text || upstream.status}`);
}
export async function ensureLocalFallbackProviderOnGoosed(
apiTarget,
apiSecret,
fetchImpl = undiciFetch,
) {
if (!LOCAL_LLM_FALLBACK.enabled) {
throw new Error('本地 LLM fallback 已禁用');
}
const profile = {
providerKind: 'custom',
name: LOCAL_LLM_FALLBACK.displayName,
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
apiKey: LOCAL_LLM_FALLBACK.apiKey,
models: [LOCAL_LLM_FALLBACK.model],
defaultModel: LOCAL_LLM_FALLBACK.model,
goosedProviderId: cachedLocalFallbackProviderId,
engine: 'openai',
};
const goosedProviderId = await upsertCustomProviderOnGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl,
);
cachedLocalFallbackProviderId = goosedProviderId;
return goosedProviderId;
}
export async function updateSessionProvider(
apiFetchImpl,
sessionId,
provider,
model,
) {
const upstream = await apiFetchImpl('/agent/update_provider', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
provider,
model,
}),
});
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(text || `切换会话 Provider 失败: ${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, fetchImpl = apiFetchImpl) {
const profile = profileFromRow(row, decryptRow);
const goosedProviderId = await syncProfileToGoosed(
apiTarget,
apiSecret,
profile,
fetchImpl,
);
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;
}
async function testSelectedRow(row) {
const profile = profileFromRow(row, decryptRow);
if (profile.providerKind === 'custom') {
try {
return await testRelayConnection(
{
apiUrl: profile.apiUrl,
apiKey: profile.apiKey,
model: profile.defaultModel,
relayProvider: profile.relayProvider,
},
apiFetchImpl,
);
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
return { ok: true, model: profile.defaultModel };
}
async function resolveSelectedProvider(fetchImpl = apiFetchImpl) {
const row = await getSelectedRow();
if (!row) {
return { ok: false, message: '请先启用 LLM 配置' };
}
const profile = profileFromRow(row, decryptRow);
const selectedTest = await testSelectedRow(row);
if (!selectedTest.ok) {
return {
ok: false,
message: selectedTest.message ?? '当前选中的 LLM 不可用',
};
}
try {
const goosedProviderId = await syncRow(row, fetchImpl);
const providerId =
profile.providerKind === 'custom'
? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId)
: profile.providerId;
return {
ok: true,
providerId,
model: profile.defaultModel,
source: 'selected',
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : '同步 LLM 配置失败',
};
}
}
/** Local fallback is only for DeepSeek creditsExhausted — never auto-selected at boot/session start. */
async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) {
let localTest;
try {
localTest = await testLocalLlmConnection(apiFetchImpl);
} catch (err) {
localTest = {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
if (!localTest.ok) {
return {
ok: false,
message: localTest.message ?? '本地 LLM 不可用',
};
}
const providerId = await ensureLocalFallbackProviderOnGoosed(
apiTarget,
apiSecret,
fetchImpl,
);
return {
ok: true,
providerId,
model: LOCAL_LLM_FALLBACK.model,
source: 'local_fallback',
};
}
const goosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, apiFetchImpl);
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 resolved = await resolveSelectedProvider();
if (!resolved.ok) {
return { ok: false, synced: false, message: resolved.message };
}
return {
ok: true,
synced: true,
source: resolved.source,
providerId: resolved.providerId,
model: resolved.model,
};
},
async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
const resolved = await resolveSelectedProvider(fetchImpl);
if (!resolved.ok) {
return resolved;
}
const sessionGoosedApi = (pathname, init) =>
goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
return resolved;
},
/** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */
async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) {
const resolved = await resolveLocalFallbackProvider(fetchImpl);
if (!resolved.ok) {
return resolved;
}
const sessionGoosedApi = (pathname, init) =>
goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
return resolved;
},
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 };
},
};
}