Files
memind/llm-providers.mjs
T
2026-07-02 09:42:08 +08:00

2066 lines
66 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 fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
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'],
},
{
id: 'custom_qwen',
label: 'Qwen VL (通义千问)',
kind: 'builtin',
apiKeyEnv: null,
// stored as custom provider; apiUrl is injected at createKey time
apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
defaultModel: 'qwen-vl-max',
models: ['qwen-vl-max', 'qwen-vl-max-latest', 'qwen-vl-plus', 'qwen-plus', 'qwen-turbo'],
},
];
export const LLM_EXECUTOR_CATALOG = [
{
id: 'goose',
label: 'Goose',
description: '策略判断、分析、总结和轻量任务编排',
purposes: ['default'],
},
{
id: 'aider',
label: 'Aider',
description: '小范围代码修改和补丁式修复',
purposes: ['default'],
},
{
id: 'openhands',
label: 'OpenHands',
description: '复杂仓库任务、多文件改造和命令执行',
purposes: ['default'],
},
];
const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item]));
const executorById = Object.fromEntries(LLM_EXECUTOR_CATALOG.map((item) => [item.id, item]));
const BUILTIN_PROVIDER_TEST_URLS = {
custom_deepseek: process.env.DEEPSEEK_API_BASE_URL ?? 'https://api.deepseek.com/v1',
openai: process.env.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1',
openrouter: process.env.OPENROUTER_API_BASE_URL ?? 'https://openrouter.ai/api/v1',
};
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 resolveApiBaseUrl(apiUrl) {
const normalized = normalizeApiUrl(apiUrl);
if (!normalized) return '';
if (normalized.endsWith('/chat/completions')) {
return normalized.slice(0, -'/chat/completions'.length).replace(/\/+$/, '');
}
return normalized.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),
isVisionSelected: Boolean(row.is_vision_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,
};
}
function normalizeExecutor(raw) {
const executor = String(raw ?? '').trim().toLowerCase();
return executorById[executor] ? executor : null;
}
function normalizePurpose(raw) {
return String(raw ?? 'default').trim() || 'default';
}
function rowToExecutorBinding(row, keyPublic = null) {
const executor = normalizeExecutor(row.executor);
const meta = executorById[executor] ?? null;
return {
id: row.id,
executor,
executorLabel: meta?.label ?? row.executor,
executorDescription: meta?.description ?? '',
purpose: row.purpose ?? 'default',
providerKeyId: row.provider_key_id ?? null,
providerName: keyPublic?.name ?? null,
providerLabel: keyPublic?.providerLabel ?? null,
providerId: keyPublic?.providerId ?? null,
model: row.model ?? '',
enabled: Boolean(row.enabled),
availableModels: keyPublic?.models ?? [],
createdAt: row.created_at ? Number(row.created_at) : null,
updatedAt: row.updated_at ? Number(row.updated_at) : null,
};
}
function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) {
const apiKeyValue = includeSecret ? profile.apiKey : '[hidden]';
const providerId = profile.providerId;
const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || '';
const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
const common = {
TKMIND_EXECUTOR: executor,
TKMIND_EXECUTOR_PROVIDER: providerId,
TKMIND_EXECUTOR_MODEL: model,
};
if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl;
if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey;
if (executor === 'aider') {
const aiderModel = aiderModelNameForProvider(providerId, model);
const env = {
...common,
AIDER_MODEL: aiderModel,
};
if (providerId === 'anthropic') {
env.AIDER_ANTHROPIC_API_KEY = apiKeyValue;
env.ANTHROPIC_API_KEY = apiKeyValue;
} else {
env.AIDER_OPENAI_API_KEY = apiKeyValue;
env.OPENAI_API_KEY = apiKeyValue;
if (providerId === 'openrouter') env.OPENROUTER_API_KEY = apiKeyValue;
if (providerId === 'custom_deepseek') env.DEEPSEEK_API_KEY = apiKeyValue;
}
if (baseUrl) {
env.AIDER_OPENAI_API_BASE = baseUrl;
env.OPENAI_API_BASE = baseUrl;
}
return env;
}
if (executor === 'openhands') {
return {
...common,
LLM_MODEL: model,
LLM_API_KEY: apiKeyValue,
...(baseUrl ? { LLM_BASE_URL: baseUrl } : {}),
};
}
return {
...common,
GOOSE_PROVIDER: profile.providerKind === 'custom'
? (profile.goosedProviderId ?? profile.providerId)
: profile.providerId,
GOOSE_MODEL: model,
TKMIND_PROVIDER: profile.providerKind === 'custom'
? (profile.goosedProviderId ?? profile.providerId)
: profile.providerId,
TKMIND_MODEL: model,
};
}
function aiderModelNameForProvider(providerId, model) {
const normalizedModel = String(model ?? '').trim();
if (!normalizedModel) return normalizedModel;
if (String(providerId ?? '').trim() === 'custom_deepseek') {
return normalizedModel.startsWith('deepseek/')
? normalizedModel
: `deepseek/${normalizedModel}`;
}
return normalizedModel;
}
function launchLogDir() {
return path.join(os.tmpdir(), 'memindadm-launches');
}
function normalizeLaunchMode(mode) {
const normalized = String(mode ?? 'headless').trim().toLowerCase();
return normalized === 'serve' ? 'serve' : 'headless';
}
function resolveExecutorCommand(executor) {
const candidates =
executor === 'aider'
? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, 'aider']
: executor === 'openhands'
? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, 'openhands']
: [executor];
return candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor;
}
function commandExists(command) {
if (path.isAbsolute(command)) {
try {
fs.accessSync(command, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
const probe = spawnSync('sh', ['-lc', `command -v ${JSON.stringify(command).slice(1, -1)}`], {
stdio: 'ignore',
});
return probe.status === 0;
}
function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) {
if (!plan?.ok) {
return plan;
}
if (!plan.command) {
return {
ok: false,
executor: plan.executor ?? null,
message: '启动命令缺失',
};
}
if (!commandExists(plan.command)) {
return {
ok: false,
executor: plan.executor ?? null,
message: `${plan.command} 未安装或不在 PATH 中`,
};
}
fs.mkdirSync(logDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(
logDir,
`${plan.executor ?? 'executor'}-${stamp}-${crypto.randomUUID().slice(0, 8)}.log`,
);
const fd = fs.openSync(logFile, 'a');
try {
const child = spawn(plan.command, plan.args ?? [], {
cwd: plan.cwd ?? process.cwd(),
env: {
...process.env,
...(plan.env ?? {}),
},
detached: true,
stdio: ['ignore', fd, fd],
});
child.unref();
return {
ok: true,
executor: plan.executor ?? null,
pid: child.pid ?? null,
command: plan.command,
args: plan.args ?? [],
cwd: plan.cwd ?? process.cwd(),
logFile,
notes: plan.notes ?? [],
};
} catch (err) {
return {
ok: false,
executor: plan.executor ?? null,
message: err instanceof Error ? err.message : '启动失败',
logFile,
};
} finally {
try {
fs.closeSync(fd);
} catch {}
}
}
function isProcessAlive(pid) {
if (!pid || Number.isNaN(Number(pid))) return false;
try {
process.kill(Number(pid), 0);
return true;
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'EPERM') return true;
return false;
}
}
function launchStateKey(executor, purpose = 'default') {
return `${normalizeExecutor(executor)}:${normalizePurpose(purpose)}`;
}
export function buildExecutorLaunchPlan(runtime, options = {}) {
if (!runtime?.ok) {
return {
ok: false,
executor: runtime?.executor ?? null,
message: runtime?.message ?? '运行配置未就绪',
};
}
const mode = normalizeLaunchMode(options.mode ?? 'headless');
const cwd = String(options.cwd ?? process.cwd());
const instruction = String(options.instruction ?? '').trim();
const env = runtime.env ?? {};
if (runtime.executor === 'goose') {
return {
ok: false,
executor: 'goose',
message: 'Goose 作为现有服务入口,不通过本方法启动',
};
}
if (runtime.executor === 'aider') {
const hasInstruction = Boolean(instruction);
const command = resolveExecutorCommand('aider');
const model = aiderModelNameForProvider(env.TKMIND_EXECUTOR_PROVIDER, runtime.model);
return {
ok: true,
executor: 'aider',
cwd,
command,
args: [
'--model',
model,
...(hasInstruction ? ['--message', instruction] : []),
'--yes-always',
],
env,
notes: [
'Aider 通过统一模型中心提供的 API Key / Base URL 运行。',
'实际启动时建议在仓库目录中执行。',
],
};
}
if (runtime.executor === 'openhands') {
if (mode === 'headless' && !instruction) {
return {
ok: false,
executor: 'openhands',
message: 'OpenHands headless 模式需要 instruction',
};
}
const command = resolveExecutorCommand('openhands');
const args =
mode === 'serve'
? ['serve', '--mount-cwd']
: ['--headless', '--json', '--override-with-envs', '--task', instruction];
return {
ok: true,
executor: 'openhands',
cwd,
command,
args,
env,
notes: [
mode === 'serve'
? 'OpenHands GUI Server 依赖 Docker,适合人工交互调试。'
: 'Headless 模式适合后续和后台调度对接。',
],
};
}
return {
ok: false,
executor: runtime.executor,
message: '不支持的执行器',
};
}
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, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
) {
const launchStates = new Map();
const syncTargets = [
...new Set([...(Array.isArray(apiTargets) ? apiTargets : []), apiTarget].filter(Boolean)),
];
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 getExecutorBindingRow(executor, purpose = 'default') {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return null;
const [rows] = await pool.query(
'SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1',
[normalizedExecutor, normalizePurpose(purpose)],
);
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 getVisionRow() {
const [rows] = await pool.query(
'SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1',
['active'],
);
return rows[0] ?? null;
}
async function clearVisionSelected() {
await pool.query('UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?', [
Date.now(),
]);
}
// Lightweight in-memory flag so proxyFallback can skip DB query on text-only messages.
// Invalidated on every setVisionKey / clearVisionKey call.
let visionKeyConfiguredCache = null;
// Cache last sync per row to avoid redundant Goosed /config/upsert calls on every request.
// Key: `${row.id}:${row.updated_at}`, value: { goosedProviderId, syncedAt }.
// Each sync makes 4-5 HTTP calls to Goosed; under load these accumulate open FDs on the
// Goosed side and can exhaust the OS file descriptor limit (EMFILE / error 24).
const syncRowCache = new Map();
const SYNC_ROW_CACHE_TTL_MS = 30_000;
async function syncRow(row, fetchImpl = apiFetchImpl) {
const cacheKey = `${row.id}:${row.updated_at}`;
const cached = syncRowCache.get(cacheKey);
if (cached && Date.now() - cached.syncedAt < SYNC_ROW_CACHE_TTL_MS && fetchImpl === apiFetchImpl) {
return cached.goosedProviderId;
}
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],
);
}
if (fetchImpl === apiFetchImpl) {
syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() });
}
return goosedProviderId;
}
async function syncRowWithModel(row, model, fetchImpl = apiFetchImpl) {
const originalModel = row.default_model;
row.default_model = model || row.default_model;
try {
return await syncRow(row, fetchImpl);
} finally {
row.default_model = originalModel;
}
}
async function resolveExecutorProvider(executor, purpose = 'default', fetchImpl = apiFetchImpl) {
const binding = await getExecutorBindingRow(executor, purpose);
if (!binding?.enabled) {
return { ok: false, message: `${executorById[executor]?.label ?? executor} 未启用模型绑定` };
}
if (!binding.provider_key_id) {
return { ok: false, message: `${executorById[executor]?.label ?? executor} 未绑定 Provider` };
}
const row = await getRowById(binding.provider_key_id);
if (!row || row.status !== 'active') {
return { ok: false, message: '绑定的 Provider 不存在或已禁用' };
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(binding.model)) {
return { ok: false, message: '绑定模型不在当前 Provider 支持列表中' };
}
try {
const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl);
const profile = profileFromRow(row, decryptRow);
const providerId =
profile.providerKind === 'custom'
? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId)
: profile.providerId;
return {
ok: true,
providerId,
model: binding.model,
source: 'executor_binding',
executor,
purpose: binding.purpose,
};
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : '同步执行器模型配置失败',
};
}
}
async function resolveExecutorRuntimeConfig(
executor,
{ purpose = 'default', includeSecret = false } = {},
) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return { ok: false, message: '不支持的执行器' };
const binding = await getExecutorBindingRow(normalizedExecutor, purpose);
if (!binding?.enabled) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} 未启用模型绑定`,
};
}
if (!binding.provider_key_id) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} 未绑定 Provider`,
};
}
const row = await getRowById(binding.provider_key_id);
if (!row || row.status !== 'active') {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: '绑定的 Provider 不存在或已禁用',
};
}
const keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!keyPublic.models.includes(binding.model)) {
return {
ok: false,
executor: normalizedExecutor,
purpose,
message: '绑定模型不在当前 Provider 支持列表中',
};
}
const profile = profileFromRow(row, decryptRow);
const runtimeProfile = includeSecret
? profile
: { ...profile, apiKey: '' };
return {
ok: true,
executor: normalizedExecutor,
executorLabel: executorById[normalizedExecutor]?.label ?? normalizedExecutor,
purpose: binding.purpose,
providerKeyId: row.id,
providerName: row.name,
providerKind: profile.providerKind,
providerId: profile.providerId,
providerLabel: keyPublic.providerLabel,
goosedProviderId: profile.goosedProviderId,
model: binding.model,
apiUrl: profile.apiUrl,
apiBaseUrl: resolveApiBaseUrl(profile.apiUrl),
apiKeyMasked: keyPublic.apiKeyMasked,
env: executorEnvForProfile(normalizedExecutor, runtimeProfile, binding.model, {
includeSecret,
}),
};
}
function getLaunchState(executor, purpose = 'default') {
const key = launchStateKey(executor, purpose);
const current = launchStates.get(key);
if (!current) return null;
if (current.pid && !isProcessAlive(current.pid)) {
current.running = false;
current.stoppedAt = current.stoppedAt ?? Date.now();
current.exitReason = current.exitReason ?? 'not_running';
launchStates.set(key, current);
}
return current;
}
function setLaunchState(executor, purpose, state) {
const key = launchStateKey(executor, purpose);
launchStates.set(key, {
...state,
executor: normalizeExecutor(executor),
purpose: normalizePurpose(purpose),
});
return launchStates.get(key);
}
function stateToPublic(state) {
if (!state) return null;
return {
executor: state.executor,
purpose: state.purpose,
pid: state.pid ?? null,
running: Boolean(state.running),
command: state.command ?? null,
args: state.args ?? [],
cwd: state.cwd ?? null,
logFile: state.logFile ?? null,
startedAt: state.startedAt ?? null,
stoppedAt: state.stoppedAt ?? null,
exitReason: state.exitReason ?? null,
mode: state.mode ?? null,
instruction: state.instruction ?? null,
};
}
async function stopLaunchState(state, { force = false } = {}) {
if (!state?.pid) {
return { ok: false, message: '未找到运行中的执行器进程' };
}
if (!isProcessAlive(state.pid)) {
state.running = false;
state.stoppedAt = state.stoppedAt ?? Date.now();
state.exitReason = state.exitReason ?? 'not_running';
return { ok: true, stopped: true, state: stateToPublic(state) };
}
try {
process.kill(state.pid, 'SIGTERM');
if (!force) {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
if (force || isProcessAlive(state.pid)) {
try {
process.kill(state.pid, 'SIGKILL');
} catch {}
}
state.running = false;
state.stoppedAt = Date.now();
state.exitReason = force ? 'force_kill' : 'sigterm';
return { ok: true, stopped: true, state: stateToPublic(state) };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : '停止失败',
};
}
}
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,
executorCatalog: LLM_EXECUTOR_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 listExecutorBindings() {
const keys = await this.listKeys();
const keyMap = new Map(keys.map((key) => [key.id, key]));
const [rows] = await pool.query(
'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose',
);
const rowMap = new Map(
rows.map((row) => [`${row.executor}:${row.purpose}`, row]),
);
return LLM_EXECUTOR_CATALOG.map((executor) => {
const row = rowMap.get(`${executor.id}:default`);
if (!row) {
return {
id: null,
executor: executor.id,
executorLabel: executor.label,
executorDescription: executor.description,
purpose: 'default',
providerKeyId: null,
providerName: null,
providerLabel: null,
providerId: null,
model: '',
enabled: false,
availableModels: [],
createdAt: null,
updatedAt: null,
};
}
return rowToExecutorBinding(row, keyMap.get(row.provider_key_id) ?? null);
});
},
async setExecutorBinding(executor, payload = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) return { ok: false, message: '不支持的执行器' };
const purpose = normalizePurpose(payload.purpose);
const enabled = payload.enabled !== false;
const keyId = String(payload.keyId ?? payload.providerKeyId ?? '').trim() || null;
const model = String(payload.model ?? '').trim();
if (enabled && !keyId) return { ok: false, message: '请选择 Provider' };
if (enabled && !model) return { ok: false, message: '请选择模型' };
let keyPublic = null;
if (keyId) {
const row = await getRowById(keyId);
if (!row) return { ok: false, message: 'Provider 不存在' };
if (row.status !== 'active') return { ok: false, message: 'Provider 已禁用' };
keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (model && !keyPublic.models.includes(model)) {
return { ok: false, message: '模型不在该 Provider 支持列表中' };
}
}
const existing = await getExecutorBindingRow(normalizedExecutor, purpose);
const now = Date.now();
if (existing) {
await pool.query(
`UPDATE h5_llm_executor_bindings
SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ?
WHERE id = ?`,
[keyId, model, enabled ? 1 : 0, now, existing.id],
);
} else {
await pool.query(
`INSERT INTO h5_llm_executor_bindings
(id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[crypto.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now],
);
}
const row = await getExecutorBindingRow(normalizedExecutor, purpose);
return { ok: true, binding: rowToExecutorBinding(row, keyPublic) };
},
async getExecutorRuntimeConfig(executor, options = {}) {
return resolveExecutorRuntimeConfig(executor, options);
},
async listExecutorRuntimeConfigs(options = {}) {
const configs = await Promise.all(
LLM_EXECUTOR_CATALOG.map((executor) =>
resolveExecutorRuntimeConfig(executor.id, options),
),
);
return configs;
},
async getExecutorLaunchPlan(executor, options = {}) {
const runtime = await resolveExecutorRuntimeConfig(executor, {
purpose: options.purpose ?? 'default',
includeSecret: options.includeSecret ?? false,
});
return buildExecutorLaunchPlan(runtime, options);
},
async listExecutorLaunchPlans(options = {}) {
const runtimes = await this.listExecutorRuntimeConfigs({
purpose: options.purpose ?? 'default',
includeSecret: options.includeSecret ?? false,
});
return runtimes.map((runtime) => buildExecutorLaunchPlan(runtime, options));
},
async listExecutorLaunchStates(options = {}) {
const purpose = normalizePurpose(options.purpose ?? 'default');
return LLM_EXECUTOR_CATALOG.map((item) => stateToPublic(getLaunchState(item.id, purpose)));
},
async getExecutorLaunchState(executor, options = {}) {
const purpose = normalizePurpose(options.purpose ?? 'default');
return stateToPublic(getLaunchState(executor, purpose));
},
async launchExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: '不支持的执行器' };
}
const purpose = normalizePurpose(options.purpose ?? 'default');
const mode = normalizeLaunchMode(options.mode ?? (normalizedExecutor === 'openhands' ? 'serve' : 'headless'));
const instruction = String(options.instruction ?? '').trim();
const currentState = getLaunchState(normalizedExecutor, purpose);
if (currentState?.running) {
return {
ok: true,
executor: normalizedExecutor,
purpose,
running: true,
reused: true,
launch: stateToPublic(currentState),
};
}
if (normalizedExecutor === 'goose') {
return {
ok: false,
executor: 'goose',
message: 'Goose 作为现有服务入口,不通过后台直接启动',
};
}
if (normalizedExecutor === 'aider' && !instruction) {
return {
ok: false,
executor: 'aider',
message: 'Aider 启动需要 instruction',
};
}
if (normalizedExecutor === 'openhands' && mode === 'headless' && !instruction) {
return {
ok: false,
executor: 'openhands',
message: 'OpenHands headless 启动需要 instruction',
};
}
const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, {
purpose,
includeSecret: true,
});
const plan = buildExecutorLaunchPlan(runtime, {
purpose,
mode,
cwd: options.cwd,
instruction,
includeSecret: true,
});
if (!plan.ok) {
return plan;
}
const launch = spawnDetachedExecutor(plan, { logDir: options.logDir });
if (!launch.ok) return launch;
const state = setLaunchState(normalizedExecutor, purpose, {
pid: launch.pid ?? null,
running: true,
command: launch.command,
args: launch.args ?? [],
cwd: launch.cwd ?? plan.cwd,
logFile: launch.logFile,
startedAt: Date.now(),
mode,
instruction: instruction || null,
});
return {
...launch,
purpose,
running: true,
launch: stateToPublic(state),
};
},
async stopExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: '不支持的执行器' };
}
const purpose = normalizePurpose(options.purpose ?? 'default');
const state = getLaunchState(normalizedExecutor, purpose);
if (!state) {
return { ok: false, executor: normalizedExecutor, purpose, message: '未找到执行记录' };
}
const result = await stopLaunchState(state, { force: Boolean(options.force) });
if (!result.ok) return { ok: false, executor: normalizedExecutor, purpose, message: result.message };
setLaunchState(normalizedExecutor, purpose, state);
return {
ok: true,
executor: normalizedExecutor,
purpose,
launch: result.state,
};
},
async restartExecutor(executor, options = {}) {
const normalizedExecutor = normalizeExecutor(executor);
if (!normalizedExecutor) {
return { ok: false, message: '不支持的执行器' };
}
const purpose = normalizePurpose(options.purpose ?? 'default');
const state = getLaunchState(normalizedExecutor, purpose);
if (!state) {
return { ok: false, executor: normalizedExecutor, purpose, message: '未找到可重启的执行记录' };
}
await stopLaunchState(state, { force: true });
const launchOptions = {
purpose,
mode: options.mode ?? state.mode ?? (normalizedExecutor === 'openhands' ? 'serve' : 'headless'),
cwd: options.cwd ?? state.cwd ?? process.cwd(),
instruction: options.instruction ?? state.instruction ?? '',
logDir: options.logDir,
};
return this.launchExecutor(normalizedExecutor, launchOptions);
},
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 (providerId === 'custom_qwen') {
// Qwen VL: displayed as builtin in the UI but stored as a custom OpenAI-compatible
// provider so Goose receives images (custom_deepseek is in PROVIDERS_WITHOUT_VISION).
if (!meta) return { ok: false, message: '不支持的 provider' };
if (!insertName) return { ok: false, message: '请填写配置名称' };
if (!apiKey) return { ok: false, message: '请填写 API Key' };
providerKind = 'custom';
apiUrl = meta.apiUrl ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1';
const qwenModels = meta.models;
defaultModel = defaultModel || meta.defaultModel || qwenModels[0];
if (!qwenModels.includes(defaultModel)) defaultModel = qwenModels[0];
modelsJson = JSON.stringify(qwenModels);
} 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 targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean);
let lastResolved = null;
for (const target of targets) {
const targetFetch = (url, init) => {
const pathname = `${url.pathname}${url.search}`;
return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl);
};
let resolved = await resolveExecutorProvider('goose', 'default', targetFetch);
if (!resolved.ok) {
resolved = await resolveSelectedProvider(targetFetch);
}
if (!resolved.ok) {
return { ok: false, synced: false, target, message: resolved.message };
}
lastResolved = resolved;
}
return {
ok: true,
synced: true,
targets,
source: lastResolved.source,
providerId: lastResolved.providerId,
model: lastResolved.model,
};
},
async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
let resolved = await resolveExecutorProvider('goose', 'default', fetchImpl);
if (!resolved.ok) {
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 hasVisionKey() {
if (visionKeyConfiguredCache !== null) return visionKeyConfiguredCache;
const row = await getVisionRow();
visionKeyConfiguredCache = row !== null;
return visionKeyConfiguredCache;
},
async getVisionSettings() {
const row = await getVisionRow();
if (!row) {
return { keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] };
}
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
return {
keyId: publicRow.id,
keyName: publicRow.name,
providerLabel: publicRow.providerLabel,
visionModel: publicRow.defaultModel,
availableModels: publicRow.models,
};
},
async setVisionKey(keyId, model) {
const nextModel = String(model ?? '').trim();
if (!nextModel) return { ok: false, message: '请选择视觉模型' };
const row = await getRowById(keyId);
if (!row) return { ok: false, message: '配置不存在' };
if (row.status !== 'active') return { ok: false, message: '该配置已禁用' };
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (!publicRow.models.includes(nextModel)) {
return { ok: false, message: '模型不在该配置支持列表中' };
}
await clearVisionSelected();
const now = Date.now();
await pool.query(
'UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?',
[nextModel, now, keyId],
);
visionKeyConfiguredCache = true;
return { ok: true, vision: await this.getVisionSettings() };
},
async clearVisionKey() {
await clearVisionSelected();
visionKeyConfiguredCache = false;
return { ok: true };
},
async applyVisionProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
const row = await getVisionRow();
if (!row) return { ok: false, message: '未配置图片任务模型' };
const sessionGoosedApi = (pathname, init) =>
goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
let goosedProviderId;
try {
goosedProviderId = await syncRow(row, fetchImpl);
if (!goosedProviderId) {
goosedProviderId = profileFromRow(row, decryptRow).goosedProviderId ?? row.provider_id;
}
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : '同步视觉模型失败' };
}
await updateSessionProvider(sessionGoosedApi, sessionId, goosedProviderId, row.default_model);
return { ok: true, providerId: goosedProviderId, model: row.default_model, source: 'vision' };
},
// Calls the vision provider directly (not through Goose) to analyze images.
// Returns the model's text description, or null on failure.
async analyzeImagesWithVision(imageItems, userText) {
const row = await getVisionRow();
if (!row) return null;
const apiUrl = String(row.api_url ?? '').trim();
if (!apiUrl) return null;
const apiKey = decryptRow(row);
if (!apiKey) return null;
const model = String(row.default_model ?? 'qwen-vl-max').trim();
const content = [
...imageItems.map((item) => ({
type: 'image_url',
image_url: { url: `data:${item.mimeType};base64,${item.data}` },
})),
{ type: 'text', text: String(userText || '请描述这张图片的内容').trim() },
];
try {
const resp = await undiciFetch(`${apiUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content }],
max_tokens: 1200,
}),
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) return null;
const json = await resp.json();
return json?.choices?.[0]?.message?.content ?? null;
} catch {
return null;
}
},
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: '配置不存在' };
const model = String(testModel ?? row.default_model).trim();
const providerKind = row.provider_kind ?? 'builtin';
const meta = catalogItem(row.provider_id);
const models = providerKind === 'custom'
? parseModelsJson(row.models_json)
: (meta?.models ?? []);
if (!models.includes(model)) {
return { ok: false, message: '测试模型不在配置列表中' };
}
if (providerKind !== 'custom') {
const apiUrl = BUILTIN_PROVIDER_TEST_URLS[row.provider_id];
if (!apiUrl) {
return { ok: false, message: '该内置 Provider 暂不支持联通测试' };
}
return testRelayConnection(
{
apiUrl,
apiKey: decryptRow(row),
model,
relayProvider: null,
},
apiFetchImpl,
);
}
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) {
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 };
},
};
}