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') { 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 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() { 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 }; }, }; }