2066 lines
66 KiB
JavaScript
2066 lines
66 KiB
JavaScript
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 };
|
||
},
|
||
};
|
||
}
|