Files

2180 lines
70 KiB
JavaScript
Raw Permalink Normal View History

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 openAiCompatibleApiUrlForProfile(profile) {
const apiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[profile.providerId] || '';
return resolveChatCompletionsUrl(apiUrl);
}
function extractChatCompletionText(payload) {
const content = payload?.choices?.[0]?.message?.content;
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('')
.trim();
}
if (content != null) return String(content);
const fallback = payload?.message?.content ?? payload?.output_text ?? payload?.output;
return fallback == null ? '' : String(fallback);
}
function normalizeChatUsage(usage) {
if (!usage || typeof usage !== 'object') return null;
const inputTokens = Number(
usage.prompt_tokens ??
usage.input_tokens ??
usage.promptTokens ??
usage.inputTokens ??
0,
);
const outputTokens = Number(
usage.completion_tokens ??
usage.output_tokens ??
usage.completionTokens ??
usage.outputTokens ??
0,
);
return {
inputTokens: Number.isFinite(inputTokens) ? inputTokens : 0,
outputTokens: Number.isFinite(outputTokens) ? outputTokens : 0,
raw: usage,
};
}
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') {
2026-07-02 09:42:08 +08:00
const aiderModel = aiderModelNameForProvider(providerId, model);
const env = {
...common,
2026-07-02 09:42:08 +08:00
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,
};
}
2026-07-02 09:42:08 +08:00
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');
2026-07-02 09:42:08 +08:00
const model = aiderModelNameForProvider(env.TKMIND_EXECUTOR_PROVIDER, runtime.model);
return {
ok: true,
executor: 'aider',
cwd,
command,
args: [
'--model',
2026-07-02 09:42:08 +08:00
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,
2026-06-27 21:31:02 +08:00
{ apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
) {
const launchStates = new Map();
2026-06-27 21:31:02 +08:00
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 createChatCompletion({
messages,
providerKeyId = null,
model = null,
temperature = 0.7,
} = {}, fetchImpl = apiFetchImpl) {
const requestedProviderKeyId = String(providerKeyId ?? '').trim();
const row = requestedProviderKeyId
? await getRowById(requestedProviderKeyId)
: await getSelectedRow();
if (!row) return { ok: false, message: '未配置可用的聊天模型' };
if (row.status !== 'active') return { ok: false, message: '聊天模型 Provider 已禁用' };
const profile = profileFromRow(row, decryptRow);
const apiUrl = openAiCompatibleApiUrlForProfile(profile);
if (!apiUrl) {
return {
ok: false,
message: `${row.name ?? profile.providerId} 暂不支持 Portal 直连聊天`,
};
}
const selectedModel = String(model || row.default_model || profile.defaultModel || '').trim();
if (!selectedModel) return { ok: false, message: '未配置聊天模型名称' };
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
if (publicRow.models.length && !publicRow.models.includes(selectedModel)) {
return { ok: false, message: '聊天模型不在当前 Provider 支持列表中' };
}
const upstream = await fetchImpl(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${profile.apiKey}`,
},
body: JSON.stringify({
model: selectedModel,
messages: Array.isArray(messages) ? messages : [],
stream: false,
temperature,
...(profile.relayProvider ? { provider: profile.relayProvider } : {}),
}),
dispatcher: apiUrl.startsWith('https://') ? insecureDispatcher : undefined,
});
const text = await upstream.text().catch(() => '');
if (!upstream.ok) {
return {
ok: false,
status: upstream.status,
message: text.slice(0, 500) || `聊天模型请求失败 (${upstream.status})`,
};
}
let payload;
try {
payload = JSON.parse(text);
} catch {
return { ok: false, message: '聊天模型响应不是 JSON' };
}
const reply = extractChatCompletionText(payload).trim();
if (!reply) return { ok: false, message: '聊天模型未返回文本内容' };
return {
ok: true,
providerKeyId: row.id,
providerId: profile.providerId,
providerKind: profile.providerKind,
model: selectedModel,
reply,
usage: normalizeChatUsage(payload?.usage),
raw: payload,
};
},
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() {
2026-06-27 21:31:02 +08:00
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,
2026-06-27 21:31:02 +08:00
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 };
},
};
}