2026-06-15 15:04:43 -07:00
|
|
|
|
import crypto from 'node:crypto';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
|
import os from 'node:os';
|
|
|
|
|
|
import path from 'node:path';
|
|
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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'],
|
|
|
|
|
|
},
|
2026-06-26 15:19:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
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'],
|
|
|
|
|
|
},
|
2026-06-15 15:04:43 -07:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item]));
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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',
|
|
|
|
|
|
};
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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(/\/+$/, '')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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(/\/+$/, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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',
|
2026-06-17 16:39:39 -07:00
|
|
|
|
model: process.env.H5_LOCAL_LLM_MODEL ?? 'qwen2.5:3b',
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2026-06-26 15:19:03 +08:00
|
|
|
|
isVisionSelected: Boolean(row.is_vision_selected),
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 14:01:00 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
2026-06-29 07:01:26 +08:00
|
|
|
|
const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || '';
|
|
|
|
|
|
const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const common = {
|
|
|
|
|
|
TKMIND_EXECUTOR: executor,
|
|
|
|
|
|
TKMIND_EXECUTOR_PROVIDER: providerId,
|
|
|
|
|
|
TKMIND_EXECUTOR_MODEL: model,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl;
|
|
|
|
|
|
if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey;
|
|
|
|
|
|
|
|
|
|
|
|
if (executor === 'aider') {
|
2026-07-02 09:42:08 +08:00
|
|
|
|
const aiderModel = aiderModelNameForProvider(providerId, model);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const env = {
|
|
|
|
|
|
...common,
|
2026-07-02 09:42:08 +08:00
|
|
|
|
AIDER_MODEL: aiderModel,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
if (providerId === 'anthropic') {
|
|
|
|
|
|
env.AIDER_ANTHROPIC_API_KEY = apiKeyValue;
|
|
|
|
|
|
env.ANTHROPIC_API_KEY = apiKeyValue;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
env.AIDER_OPENAI_API_KEY = apiKeyValue;
|
|
|
|
|
|
env.OPENAI_API_KEY = apiKeyValue;
|
|
|
|
|
|
if (providerId === 'openrouter') env.OPENROUTER_API_KEY = apiKeyValue;
|
|
|
|
|
|
if (providerId === 'custom_deepseek') env.DEEPSEEK_API_KEY = apiKeyValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (baseUrl) {
|
|
|
|
|
|
env.AIDER_OPENAI_API_BASE = baseUrl;
|
|
|
|
|
|
env.OPENAI_API_BASE = baseUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
return env;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (executor === 'openhands') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...common,
|
|
|
|
|
|
LLM_MODEL: model,
|
|
|
|
|
|
LLM_API_KEY: apiKeyValue,
|
|
|
|
|
|
...(baseUrl ? { LLM_BASE_URL: baseUrl } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...common,
|
|
|
|
|
|
GOOSE_PROVIDER: profile.providerKind === 'custom'
|
|
|
|
|
|
? (profile.goosedProviderId ?? profile.providerId)
|
|
|
|
|
|
: profile.providerId,
|
|
|
|
|
|
GOOSE_MODEL: model,
|
|
|
|
|
|
TKMIND_PROVIDER: profile.providerKind === 'custom'
|
|
|
|
|
|
? (profile.goosedProviderId ?? profile.providerId)
|
|
|
|
|
|
: profile.providerId,
|
|
|
|
|
|
TKMIND_MODEL: model,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 09:42:08 +08:00
|
|
|
|
function aiderModelNameForProvider(providerId, model) {
|
|
|
|
|
|
const normalizedModel = String(model ?? '').trim();
|
|
|
|
|
|
if (!normalizedModel) return normalizedModel;
|
|
|
|
|
|
if (String(providerId ?? '').trim() === 'custom_deepseek') {
|
|
|
|
|
|
return normalizedModel.startsWith('deepseek/')
|
|
|
|
|
|
? normalizedModel
|
|
|
|
|
|
: `deepseek/${normalizedModel}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalizedModel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
function launchLogDir() {
|
|
|
|
|
|
return path.join(os.tmpdir(), 'memindadm-launches');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeLaunchMode(mode) {
|
|
|
|
|
|
const normalized = String(mode ?? 'headless').trim().toLowerCase();
|
|
|
|
|
|
return normalized === 'serve' ? 'serve' : 'headless';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 07:01:26 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
function commandExists(command) {
|
2026-06-29 07:01:26 +08:00
|
|
|
|
if (path.isAbsolute(command)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
fs.accessSync(command, fs.constants.X_OK);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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);
|
2026-06-29 07:01:26 +08:00
|
|
|
|
const command = resolveExecutorCommand('aider');
|
2026-07-02 09:42:08 +08:00
|
|
|
|
const model = aiderModelNameForProvider(env.TKMIND_EXECUTOR_PROVIDER, runtime.model);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
executor: 'aider',
|
|
|
|
|
|
cwd,
|
2026-06-29 07:01:26 +08:00
|
|
|
|
command,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
args: [
|
|
|
|
|
|
'--model',
|
2026-07-02 09:42:08 +08:00
|
|
|
|
model,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
...(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',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-29 07:01:26 +08:00
|
|
|
|
const command = resolveExecutorCommand('openhands');
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const args =
|
|
|
|
|
|
mode === 'serve'
|
|
|
|
|
|
? ['serve', '--mount-cwd']
|
|
|
|
|
|
: ['--headless', '--json', '--override-with-envs', '--task', instruction];
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
executor: 'openhands',
|
|
|
|
|
|
cwd,
|
2026-06-29 07:01:26 +08:00
|
|
|
|
command,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
args,
|
|
|
|
|
|
env,
|
|
|
|
|
|
notes: [
|
|
|
|
|
|
mode === 'serve'
|
|
|
|
|
|
? 'OpenHands GUI Server 依赖 Docker,适合人工交互调试。'
|
|
|
|
|
|
: 'Headless 模式适合后续和后台调度对接。',
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
executor: runtime.executor,
|
|
|
|
|
|
message: '不支持的执行器',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch) {
|
|
|
|
|
|
const url = new URL(pathname, apiTarget);
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
...(init.headers ?? {}),
|
|
|
|
|
|
'X-Secret-Key': apiSecret,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (init.body && !headers['Content-Type']) {
|
|
|
|
|
|
headers['Content-Type'] = 'application/json';
|
|
|
|
|
|
}
|
|
|
|
|
|
const dispatcher = apiTarget.startsWith('https://') ? insecureDispatcher : undefined;
|
|
|
|
|
|
return fetchImpl(url, { ...init, headers, dispatcher });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function writeGoosedConfig(apiTarget, apiSecret, key, value, isSecret, fetchImpl) {
|
|
|
|
|
|
const upstream = await goosedApiFetch(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'/config/upsert',
|
|
|
|
|
|
{
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({ key, value, is_secret: isSecret }),
|
|
|
|
|
|
},
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (upstream.ok) return { ok: true, skipped: false };
|
|
|
|
|
|
if (upstream.status === 404) return { ok: false, skipped: true };
|
|
|
|
|
|
const text = await upstream.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`同步 TKMind Agent 配置 ${key} 失败: ${text || upstream.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function ensureLocalFallbackProviderOnGoosed(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
fetchImpl = undiciFetch,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (!LOCAL_LLM_FALLBACK.enabled) {
|
|
|
|
|
|
throw new Error('本地 LLM fallback 已禁用');
|
|
|
|
|
|
}
|
|
|
|
|
|
const profile = {
|
|
|
|
|
|
providerKind: 'custom',
|
|
|
|
|
|
name: LOCAL_LLM_FALLBACK.displayName,
|
|
|
|
|
|
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
|
|
|
|
|
|
apiKey: LOCAL_LLM_FALLBACK.apiKey,
|
|
|
|
|
|
models: [LOCAL_LLM_FALLBACK.model],
|
|
|
|
|
|
defaultModel: LOCAL_LLM_FALLBACK.model,
|
|
|
|
|
|
goosedProviderId: cachedLocalFallbackProviderId,
|
|
|
|
|
|
engine: 'openai',
|
|
|
|
|
|
};
|
|
|
|
|
|
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
cachedLocalFallbackProviderId = goosedProviderId;
|
|
|
|
|
|
return goosedProviderId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function updateSessionProvider(
|
|
|
|
|
|
apiFetchImpl,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
model,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const upstream = await apiFetchImpl('/agent/update_provider', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
session_id: sessionId,
|
|
|
|
|
|
provider,
|
|
|
|
|
|
model,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!upstream.ok) {
|
|
|
|
|
|
const text = await upstream.text().catch(() => '');
|
|
|
|
|
|
throw new Error(text || `切换会话 Provider 失败: ${upstream.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
|
|
|
|
|
const headers = profile.relayProvider
|
|
|
|
|
|
? { 'X-Provider': profile.relayProvider }
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
engine: profile.engine || 'openai',
|
|
|
|
|
|
display_name: profile.name,
|
|
|
|
|
|
api_url: profile.apiUrl,
|
|
|
|
|
|
api_key: profile.apiKey,
|
|
|
|
|
|
models: profile.models,
|
|
|
|
|
|
supports_streaming: true,
|
|
|
|
|
|
requires_auth: true,
|
|
|
|
|
|
...(profile.basePath ? { base_path: profile.basePath } : {}),
|
|
|
|
|
|
...(headers ? { headers } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (profile.goosedProviderId) {
|
|
|
|
|
|
const upstream = await goosedApiFetch(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
`/config/custom-providers/${encodeURIComponent(profile.goosedProviderId)}`,
|
|
|
|
|
|
{ method: 'PUT', body: JSON.stringify(body) },
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!upstream.ok) {
|
|
|
|
|
|
const text = await upstream.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`更新 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return profile.goosedProviderId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const upstream = await goosedApiFetch(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'/config/custom-providers',
|
|
|
|
|
|
{ method: 'POST', body: JSON.stringify(body) },
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!upstream.ok) {
|
|
|
|
|
|
const text = await upstream.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`创建 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await upstream.json();
|
|
|
|
|
|
return data.provider_name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) {
|
|
|
|
|
|
if (!goosedProviderId) return;
|
|
|
|
|
|
const upstream = await goosedApiFetch(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
`/config/custom-providers/${encodeURIComponent(goosedProviderId)}`,
|
|
|
|
|
|
{ method: 'DELETE' },
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!upstream.ok && upstream.status !== 404) {
|
|
|
|
|
|
const text = await upstream.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`删除 TKMind Agent 自定义 provider 失败: ${text || upstream.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
|
|
|
|
|
const catalogItem = catalogById[profile.providerId];
|
|
|
|
|
|
if (!catalogItem?.apiKeyEnv) {
|
|
|
|
|
|
throw new Error(`未知内置 provider: ${profile.providerId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
catalogItem.apiKeyEnv,
|
|
|
|
|
|
profile.apiKey,
|
|
|
|
|
|
true,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'GOOSE_PROVIDER',
|
|
|
|
|
|
profile.providerId,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'GOOSE_MODEL',
|
|
|
|
|
|
profile.defaultModel,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'TKMIND_PROVIDER',
|
|
|
|
|
|
profile.providerId,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'TKMIND_MODEL',
|
|
|
|
|
|
profile.defaultModel,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch) {
|
|
|
|
|
|
if (profile.providerKind === 'custom') {
|
|
|
|
|
|
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
profile,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'GOOSE_PROVIDER',
|
|
|
|
|
|
goosedProviderId,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'GOOSE_MODEL',
|
|
|
|
|
|
profile.defaultModel,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'TKMIND_PROVIDER',
|
|
|
|
|
|
goosedProviderId,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
await writeGoosedConfig(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
'TKMIND_MODEL',
|
|
|
|
|
|
profile.defaultModel,
|
|
|
|
|
|
false,
|
|
|
|
|
|
fetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
return goosedProviderId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
|
|
|
|
|
|
return profile.providerId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isCustomPayload(payload) {
|
|
|
|
|
|
return payload?.providerId === CUSTOM_PROVIDER_ID || payload?.providerKind === 'custom';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validateCustomPayload(payload) {
|
|
|
|
|
|
const name = String(payload?.name ?? '').trim();
|
|
|
|
|
|
const apiKey = String(payload?.apiKey ?? '').trim();
|
|
|
|
|
|
const apiUrl = normalizeApiUrl(payload?.apiUrl);
|
|
|
|
|
|
const models = parseModelList(payload?.models);
|
|
|
|
|
|
const defaultModel = String(payload?.defaultModel ?? '').trim() || models[0] || '';
|
|
|
|
|
|
if (!name) return { ok: false, message: '请填写配置名称' };
|
|
|
|
|
|
if (!apiUrl) return { ok: false, message: '请填写 API 地址' };
|
|
|
|
|
|
if (!apiKey) return { ok: false, message: '请填写 API Key / Bearer Token' };
|
|
|
|
|
|
if (models.length === 0) return { ok: false, message: '请至少填写一个模型' };
|
|
|
|
|
|
if (!models.includes(defaultModel)) {
|
|
|
|
|
|
return { ok: false, message: '默认模型必须在模型列表中' };
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
value: {
|
|
|
|
|
|
name,
|
|
|
|
|
|
apiKey,
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
models,
|
|
|
|
|
|
defaultModel,
|
|
|
|
|
|
basePath: String(payload?.basePath ?? '').trim() || null,
|
|
|
|
|
|
engine: String(payload?.engine ?? 'openai').trim() || 'openai',
|
|
|
|
|
|
relayProvider: String(payload?.relayProvider ?? '').trim() || null,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createLlmProviderService(
|
|
|
|
|
|
pool,
|
2026-06-27 21:31:02 +08:00
|
|
|
|
{ apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {},
|
2026-06-15 15:04:43 -07:00
|
|
|
|
) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const launchStates = new Map();
|
2026-06-27 21:31:02 +08:00
|
|
|
|
const syncTargets = [
|
|
|
|
|
|
...new Set([...(Array.isArray(apiTargets) ? apiTargets : []), apiTarget].filter(Boolean)),
|
|
|
|
|
|
];
|
2026-06-26 15:19:03 +08:00
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
async function clearSelected() {
|
|
|
|
|
|
await pool.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [
|
|
|
|
|
|
Date.now(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
async function syncRow(row, fetchImpl = apiFetchImpl) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const profile = profileFromRow(row, decryptRow);
|
|
|
|
|
|
const goosedProviderId = await syncProfileToGoosed(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
profile,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
fetchImpl,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
);
|
|
|
|
|
|
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],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (fetchImpl === apiFetchImpl) {
|
|
|
|
|
|
syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() });
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
return goosedProviderId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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 : '停止失败',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
async function resolveSelectedProvider(fetchImpl = apiFetchImpl) {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 {
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const goosedProviderId = await syncRow(row, fetchImpl);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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. */
|
2026-06-19 23:06:43 +08:00
|
|
|
|
async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
fetchImpl,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
);
|
|
|
|
|
|
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,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
executorCatalog: LLM_EXECUTOR_CATALOG,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-07-04 22:32:57 +08:00
|
|
|
|
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();
|
2026-07-04 14:01:00 +08:00
|
|
|
|
if (!row) return { ok: false, message: '未配置可用的聊天模型' };
|
2026-07-04 22:32:57 +08:00
|
|
|
|
if (row.status !== 'active') return { ok: false, message: '聊天模型 Provider 已禁用' };
|
2026-07-04 14:01:00 +08:00
|
|
|
|
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: '未配置聊天模型名称' };
|
2026-07-04 22:32:57 +08:00
|
|
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
|
|
|
|
if (publicRow.models.length && !publicRow.models.includes(selectedModel)) {
|
|
|
|
|
|
return { ok: false, message: '聊天模型不在当前 Provider 支持列表中' };
|
|
|
|
|
|
}
|
2026-07-04 14:01:00 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
} 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);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} else {
|
|
|
|
|
|
if (!meta) return { ok: false, message: '不支持的 provider' };
|
|
|
|
|
|
if (!insertName) return { ok: false, message: '请填写配置名称' };
|
|
|
|
|
|
if (!apiKey) return { ok: false, message: '请填写 API Key' };
|
|
|
|
|
|
defaultModel = defaultModel || meta.defaultModel;
|
|
|
|
|
|
if (!meta.models.includes(defaultModel)) {
|
|
|
|
|
|
return { ok: false, message: '不支持的模型' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const [existing] = await pool.query(
|
|
|
|
|
|
'SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1',
|
|
|
|
|
|
[insertName],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (existing.length > 0) return { ok: false, message: '配置名称已存在' };
|
|
|
|
|
|
|
|
|
|
|
|
const [countRows] = await pool.query('SELECT COUNT(*) AS total FROM h5_llm_provider_keys');
|
|
|
|
|
|
const shouldSelect = Number(countRows[0]?.total ?? 0) === 0;
|
|
|
|
|
|
const encrypted = encryptSecret(apiKey, encryptionKey);
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const id = crypto.randomUUID();
|
|
|
|
|
|
if (shouldSelect) await clearSelected();
|
|
|
|
|
|
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`INSERT INTO h5_llm_provider_keys
|
|
|
|
|
|
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider,
|
|
|
|
|
|
name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
|
|
|
|
|
|
[
|
|
|
|
|
|
id,
|
|
|
|
|
|
providerId,
|
|
|
|
|
|
providerKind,
|
|
|
|
|
|
apiUrl,
|
|
|
|
|
|
basePath,
|
|
|
|
|
|
modelsJson,
|
|
|
|
|
|
engine,
|
|
|
|
|
|
relayProvider,
|
|
|
|
|
|
insertName,
|
|
|
|
|
|
encrypted.ciphertext,
|
|
|
|
|
|
encrypted.iv,
|
|
|
|
|
|
encrypted.tag,
|
|
|
|
|
|
defaultModel,
|
|
|
|
|
|
shouldSelect ? 1 : 0,
|
|
|
|
|
|
now,
|
|
|
|
|
|
now,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let row = await getRowById(id);
|
|
|
|
|
|
if (shouldSelect) {
|
|
|
|
|
|
await syncRow(row);
|
|
|
|
|
|
row = await getRowById(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { ok: true, key: rowToPublic(row, meta, apiKey) };
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async updateKey(id, payload) {
|
|
|
|
|
|
const row = await getRowById(id);
|
|
|
|
|
|
if (!row) return { ok: false, message: '配置不存在' };
|
|
|
|
|
|
const providerKind = row.provider_kind ?? 'builtin';
|
|
|
|
|
|
|
|
|
|
|
|
const nextName =
|
|
|
|
|
|
payload?.name !== undefined ? String(payload.name).trim() : row.name;
|
|
|
|
|
|
const nextApiKey =
|
|
|
|
|
|
payload?.apiKey !== undefined ? String(payload.apiKey).trim() : null;
|
|
|
|
|
|
const nextStatus =
|
|
|
|
|
|
payload?.status === 'disabled' || payload?.status === 'active'
|
|
|
|
|
|
? payload.status
|
|
|
|
|
|
: row.status;
|
|
|
|
|
|
|
|
|
|
|
|
if (!nextName) return { ok: false, message: '请填写配置名称' };
|
|
|
|
|
|
|
|
|
|
|
|
const [existing] = await pool.query(
|
|
|
|
|
|
'SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1',
|
|
|
|
|
|
[nextName, id],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (existing.length > 0) return { ok: false, message: '配置名称已存在' };
|
|
|
|
|
|
|
|
|
|
|
|
if (row.is_selected && nextStatus === 'disabled') {
|
|
|
|
|
|
return { ok: false, message: '当前启用的配置不能禁用,请先切换到其他配置' };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let nextModel = row.default_model;
|
|
|
|
|
|
let nextApiUrl = row.api_url;
|
|
|
|
|
|
let nextBasePath = row.base_path;
|
|
|
|
|
|
let nextEngine = row.engine ?? 'openai';
|
|
|
|
|
|
let nextRelayProvider = row.relay_provider;
|
|
|
|
|
|
let nextModelsJson = row.models_json;
|
|
|
|
|
|
|
|
|
|
|
|
if (providerKind === 'custom') {
|
|
|
|
|
|
const models = payload?.models !== undefined
|
|
|
|
|
|
? parseModelList(payload.models)
|
|
|
|
|
|
: parseModelsJson(row.models_json);
|
|
|
|
|
|
nextModel =
|
|
|
|
|
|
payload?.defaultModel !== undefined
|
|
|
|
|
|
? String(payload.defaultModel).trim()
|
|
|
|
|
|
: row.default_model;
|
|
|
|
|
|
nextApiUrl =
|
|
|
|
|
|
payload?.apiUrl !== undefined
|
|
|
|
|
|
? normalizeApiUrl(payload.apiUrl)
|
|
|
|
|
|
: row.api_url;
|
|
|
|
|
|
nextBasePath =
|
|
|
|
|
|
payload?.basePath !== undefined
|
|
|
|
|
|
? String(payload.basePath).trim() || null
|
|
|
|
|
|
: row.base_path;
|
|
|
|
|
|
nextEngine =
|
|
|
|
|
|
payload?.engine !== undefined
|
|
|
|
|
|
? String(payload.engine).trim() || 'openai'
|
|
|
|
|
|
: row.engine;
|
|
|
|
|
|
nextRelayProvider =
|
|
|
|
|
|
payload?.relayProvider !== undefined
|
|
|
|
|
|
? String(payload.relayProvider).trim() || null
|
|
|
|
|
|
: row.relay_provider;
|
|
|
|
|
|
if (models.length === 0) return { ok: false, message: '请至少保留一个模型' };
|
|
|
|
|
|
if (!models.includes(nextModel)) {
|
|
|
|
|
|
return { ok: false, message: '默认模型必须在模型列表中' };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!nextApiUrl) return { ok: false, message: '请填写 API 地址' };
|
|
|
|
|
|
nextModelsJson = JSON.stringify(models);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const meta = catalogItem(row.provider_id);
|
|
|
|
|
|
if (!meta) return { ok: false, message: '不支持的 provider' };
|
|
|
|
|
|
nextModel =
|
|
|
|
|
|
payload?.defaultModel !== undefined
|
|
|
|
|
|
? String(payload.defaultModel).trim()
|
|
|
|
|
|
: row.default_model;
|
|
|
|
|
|
if (!meta.models.includes(nextModel)) {
|
|
|
|
|
|
return { ok: false, message: '不支持的模型' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const encrypted = nextApiKey
|
|
|
|
|
|
? encryptSecret(nextApiKey, encryptionKey)
|
|
|
|
|
|
: {
|
|
|
|
|
|
ciphertext: row.api_key_ciphertext,
|
|
|
|
|
|
iv: row.api_key_iv,
|
|
|
|
|
|
tag: row.api_key_tag,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
`UPDATE h5_llm_provider_keys
|
|
|
|
|
|
SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?,
|
|
|
|
|
|
api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?,
|
|
|
|
|
|
default_model = ?, status = ?, updated_at = ?
|
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
|
[
|
|
|
|
|
|
nextName,
|
|
|
|
|
|
nextApiUrl,
|
|
|
|
|
|
nextBasePath,
|
|
|
|
|
|
nextModelsJson,
|
|
|
|
|
|
nextEngine,
|
|
|
|
|
|
nextRelayProvider,
|
|
|
|
|
|
encrypted.ciphertext,
|
|
|
|
|
|
encrypted.iv,
|
|
|
|
|
|
encrypted.tag,
|
|
|
|
|
|
nextModel,
|
|
|
|
|
|
nextStatus,
|
|
|
|
|
|
now,
|
|
|
|
|
|
id,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (row.is_selected) {
|
|
|
|
|
|
await syncRow(await getRowById(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updated = await getRowById(id);
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
key: rowToPublic(updated, catalogItem(updated.provider_id), nextApiKey || decryptRow(row)),
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async selectKey(id) {
|
|
|
|
|
|
const row = await getRowById(id);
|
|
|
|
|
|
if (!row) return { ok: false, message: '配置不存在' };
|
|
|
|
|
|
if (row.status !== 'active') {
|
|
|
|
|
|
return { ok: false, message: '已禁用的配置不能启用' };
|
|
|
|
|
|
}
|
|
|
|
|
|
await clearSelected();
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
|
'UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?',
|
|
|
|
|
|
[now, id],
|
|
|
|
|
|
);
|
|
|
|
|
|
await syncRow(row);
|
|
|
|
|
|
const updated = await getRowById(id);
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
key: rowToPublic(updated, catalogItem(updated.provider_id), decryptRow(updated)),
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async deleteKey(id) {
|
|
|
|
|
|
const row = await getRowById(id);
|
|
|
|
|
|
if (!row) return { ok: false, message: '配置不存在' };
|
|
|
|
|
|
if (row.is_selected) {
|
|
|
|
|
|
return { ok: false, message: '当前启用的配置不能删除,请先切换到其他配置' };
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((row.provider_kind ?? 'builtin') === 'custom' && row.goosed_provider_id) {
|
|
|
|
|
|
await removeCustomProviderOnGoosed(
|
|
|
|
|
|
apiTarget,
|
|
|
|
|
|
apiSecret,
|
|
|
|
|
|
row.goosed_provider_id,
|
|
|
|
|
|
apiFetchImpl,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
await pool.query('DELETE FROM h5_llm_provider_keys WHERE id = ?', [id]);
|
|
|
|
|
|
return { ok: true };
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
async syncSelectedToGoosed() {
|
2026-06-27 21:31:02 +08:00
|
|
|
|
const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean);
|
|
|
|
|
|
let lastResolved = null;
|
|
|
|
|
|
for (const target of targets) {
|
|
|
|
|
|
const targetFetch = (url, init) => {
|
|
|
|
|
|
const pathname = `${url.pathname}${url.search}`;
|
|
|
|
|
|
return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl);
|
|
|
|
|
|
};
|
|
|
|
|
|
let resolved = await resolveExecutorProvider('goose', 'default', targetFetch);
|
|
|
|
|
|
if (!resolved.ok) {
|
|
|
|
|
|
resolved = await resolveSelectedProvider(targetFetch);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!resolved.ok) {
|
|
|
|
|
|
return { ok: false, synced: false, target, message: resolved.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
lastResolved = resolved;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
synced: true,
|
2026-06-27 21:31:02 +08:00
|
|
|
|
targets,
|
|
|
|
|
|
source: lastResolved.source,
|
|
|
|
|
|
providerId: lastResolved.providerId,
|
|
|
|
|
|
model: lastResolved.model,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
let resolved = await resolveExecutorProvider('goose', 'default', fetchImpl);
|
|
|
|
|
|
if (!resolved.ok) {
|
|
|
|
|
|
resolved = await resolveSelectedProvider(fetchImpl);
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
if (!resolved.ok) {
|
|
|
|
|
|
return resolved;
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const sessionGoosedApi = (pathname, init) =>
|
|
|
|
|
|
goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
|
|
|
|
|
|
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
return resolved;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */
|
2026-06-19 23:06:43 +08:00
|
|
|
|
async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) {
|
|
|
|
|
|
const resolved = await resolveLocalFallbackProvider(fetchImpl);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
if (!resolved.ok) {
|
|
|
|
|
|
return resolved;
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
const sessionGoosedApi = (pathname, init) =>
|
|
|
|
|
|
goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
|
|
|
|
|
|
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
return resolved;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
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,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 };
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|