229805a070
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>
1107 lines
34 KiB
JavaScript
1107 lines
34 KiB
JavaScript
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 };
|
||
},
|
||
};
|
||
}
|