feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { logAudit } from '../common/with-audit-log';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { SaveAiConfigDto, TestAiConfigDto, FetchModelsDto } from './dto/ai-config.dto';
|
||||
|
||||
@@ -39,17 +39,8 @@ export class AiConfigController {
|
||||
@RequirePermission('ai:config:write')
|
||||
async saveConfig(@Body() body: SaveAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const config = await this.service.saveConfig(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'save',
|
||||
targetId: config.id,
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.opLog, req, {
|
||||
module: 'ai-config', action: 'save', targetId: config.id, targetType: 'AiConfig', detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
|
||||
});
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
@@ -58,17 +49,8 @@ export class AiConfigController {
|
||||
@RequirePermission('ai:config:test')
|
||||
async testConnection(@Body() body: TestAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const result = await this.service.testConnection(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'test',
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: result.success ? 'success' : 'failure',
|
||||
await logAudit(this.opLog, req, {
|
||||
module: 'ai-config', action: 'test', targetType: 'AiConfig', detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`, status: result.success ? 'success' : 'failure',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -84,16 +66,8 @@ export class AiConfigController {
|
||||
@RequirePermission('ai:config:write')
|
||||
async clearKey(@Req() req: AuthenticatedRequest) {
|
||||
const data = await this.service.clearKey();
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'clear-key',
|
||||
targetType: 'AiConfig',
|
||||
detail: `keySource=${data.keySource}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
await logAudit(this.opLog, req, {
|
||||
module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,
|
||||
});
|
||||
return { success: true, message: '密钥已清除', data };
|
||||
}
|
||||
|
||||
351
apps/server/src/ai-config/ai-config.helpers.ts
Normal file
351
apps/server/src/ai-config/ai-config.helpers.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { isIP } from 'node:net';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import { AiProvider } from './ai-config.entity';
|
||||
import { DEFAULT_BASE_URLS } from './dto/ai-config.dto';
|
||||
|
||||
export function testFailureResult(message: string, now: string) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
let _encryptionWarned = false;
|
||||
|
||||
export function getEncryptionKey(): Buffer {
|
||||
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!_encryptionWarned) {
|
||||
_encryptionWarned = true;
|
||||
Logger.warn(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
|
||||
'AiConfigService',
|
||||
);
|
||||
}
|
||||
// 32 hex pairs → 32 bytes
|
||||
return Buffer.from('ff'.repeat(32), 'hex');
|
||||
}
|
||||
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
|
||||
}
|
||||
|
||||
// Hex: exactly 64 hex chars
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, 'hex');
|
||||
}
|
||||
|
||||
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
|
||||
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length !== 32) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 解码后须为 32 字节',
|
||||
);
|
||||
}
|
||||
// Re-encode to canonical base64 (no line breaks) and compare
|
||||
const canonical = buf.toString('base64');
|
||||
if (raw !== canonical) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 编码须为标准格式(无多余 padding)',
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
|
||||
);
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
export function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
|
||||
const key = getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
authTag: tag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
const PRIVATE_IPV4_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
||||
];
|
||||
|
||||
export function isPrivateHost(hostname: string): boolean {
|
||||
// Strip IPv6 brackets from URL.hostname
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
hostname = hostname.slice(1, -1);
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
|
||||
if (isIP(hostname) === 6) {
|
||||
// IPv6 private/loopback
|
||||
if (hostname === '::1' || hostname === '::') return true;
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
if (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
)
|
||||
return true; // fe80::/10
|
||||
// IPv4-mapped IPv6: ::ffff:0:0/96
|
||||
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known provider hosts — only these are allowed for fixed providers
|
||||
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
|
||||
[AiProvider.OPENAI]: ['api.openai.com'],
|
||||
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
|
||||
};
|
||||
|
||||
// Required pathname for fixed providers
|
||||
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
||||
[AiProvider.OPENAI]: '/v1',
|
||||
[AiProvider.DEEPSEEK]: '/',
|
||||
};
|
||||
|
||||
// Known public provider hosts — always skip DNS private-IP check.
|
||||
// Their CDN/proxy nodes may resolve to private-range IPs in certain regions.
|
||||
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
|
||||
|
||||
export function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
|
||||
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
|
||||
if (!raw) {
|
||||
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
|
||||
}
|
||||
|
||||
// Reject search/query and hash/fragment
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
|
||||
throw new BadRequestException('生产环境禁止使用 http://');
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
||||
|
||||
// Provider-specific host check
|
||||
const allowedHosts = PROVIDER_HOSTS[provider];
|
||||
if (allowedHosts) {
|
||||
if (!allowedHosts.includes(parsed.hostname)) {
|
||||
throw new BadRequestException(`${provider} 必须使用固定域名`);
|
||||
}
|
||||
// Enforce exact path for fixed providers
|
||||
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
|
||||
if (
|
||||
requiredPath !== undefined &&
|
||||
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
|
||||
) {
|
||||
throw new BadRequestException(`请求参数无效`);
|
||||
}
|
||||
} else {
|
||||
// OPENAI_COMPATIBLE — SSRF check
|
||||
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
|
||||
throw new BadRequestException('不允许使用内网地址');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function resolveHostnames(
|
||||
hostname: string,
|
||||
): Promise<{ address: string; family: number }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析返回空结果'));
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
addresses.map((a) => ({
|
||||
address: a.address,
|
||||
family: a.family,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
||||
// Trusted public provider hosts — skip DNS check (CDN nodes may resolve to private IPs)
|
||||
if (DNS_TRUSTED_HOSTS.has(hostname)) return;
|
||||
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
if (allowPrivate) return;
|
||||
|
||||
let addresses: { address: string; family: number }[];
|
||||
try {
|
||||
addresses = await resolveHostnames(hostname);
|
||||
} catch {
|
||||
throw new BadRequestException('无法解析域名');
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateHost(address)) {
|
||||
throw new BadRequestException('域名解析到内网地址');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
/**
|
||||
* Perform a pinned HTTP GET request.
|
||||
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
|
||||
* Redirects are forbidden. HTTPS certificate validation is enforced.
|
||||
*/
|
||||
export function pinnedGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
|
||||
const hostname = parsed.hostname;
|
||||
const path = parsed.pathname + parsed.search;
|
||||
|
||||
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
|
||||
if (dnsErr || !addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = addresses.find((a) => !isPrivateHost(a.address));
|
||||
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
|
||||
reject(new Error('解析到内网地址'));
|
||||
return;
|
||||
}
|
||||
const targetIp = resolved ? resolved.address : addresses[0].address;
|
||||
const family = resolved ? resolved.family : addresses[0].family;
|
||||
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const requestStart = Date.now();
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: targetIp,
|
||||
port,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { ...headers, Host: hostname },
|
||||
servername: isHttps ? hostname : undefined,
|
||||
rejectUnauthorized: isHttps,
|
||||
family: family === 6 ? 6 : 4,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
const status = res.statusCode ?? 500;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
res.destroy();
|
||||
return reject(new Error('禁止重定向'));
|
||||
}
|
||||
|
||||
const contentType = res.headers['content-type'] ?? null;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error('响应过大'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve({ status, contentType, body, latencyMs });
|
||||
});
|
||||
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('连接超时'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
260
apps/server/src/ai-config/ai-config.probe.ts
Normal file
260
apps/server/src/ai-config/ai-config.probe.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AiConfig } from './ai-config.entity';
|
||||
import {
|
||||
pinnedGet,
|
||||
testFailureResult,
|
||||
validateAndNormalizeBaseUrl,
|
||||
validateDnsNotPrivate,
|
||||
} from './ai-config.helpers';
|
||||
import type {
|
||||
AiConfigTestResultDto,
|
||||
FetchModelsDto,
|
||||
FetchModelsResultDto,
|
||||
TestAiConfigDto,
|
||||
} from './dto/ai-config.dto';
|
||||
|
||||
export interface AiConfigProbeContext {
|
||||
getOrCreateConfig(): Promise<AiConfig>;
|
||||
resolveApiKey(config: AiConfig | null): {
|
||||
plaintext: string | null;
|
||||
source: 'database' | 'environment' | 'none';
|
||||
};
|
||||
save(config: AiConfig): Promise<AiConfig>;
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
context: AiConfigProbeContext,
|
||||
dto?: TestAiConfigDto,
|
||||
): Promise<AiConfigTestResultDto> {
|
||||
const config = await context.getOrCreateConfig();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Determine effective provider / baseUrl
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return testFailureResult(message, now);
|
||||
}
|
||||
|
||||
// Determine effective defaultModel
|
||||
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
|
||||
|
||||
// DNS check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return testFailureResult(message, now);
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = context.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
let result: AiConfigTestResultDto;
|
||||
try {
|
||||
const { status, contentType, body, latencyMs } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
// Classify by HTTP status first, then content-type
|
||||
if (status === 401 || status === 403) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '认证失败,请检查 API Key',
|
||||
};
|
||||
} else if (status >= 500) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '服务不可用',
|
||||
};
|
||||
} else if (status >= 400) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: `服务返回错误状态 ${status}`,
|
||||
};
|
||||
} else if (!contentType || !contentType.includes('application/json')) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
} else {
|
||||
let data: { data?: Array<{ id: string }> };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
|
||||
data = parsed;
|
||||
} catch {
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = latencyMs;
|
||||
config.verified = false;
|
||||
await context.save(config);
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
}
|
||||
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
const modelCount = models.length;
|
||||
const modelAvailable =
|
||||
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
|
||||
|
||||
const message = modelAvailable
|
||||
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
|
||||
: effectiveDefaultModel
|
||||
? '连接成功,但未找到目标模型'
|
||||
: models.length > 0
|
||||
? `连接成功,可用模型 ${models.length} 个`
|
||||
: '连接成功,但未返回可用模型';
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
latencyMs,
|
||||
modelCount,
|
||||
modelAvailable,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message === '连接超时'
|
||||
? '连接超时'
|
||||
: err.message === '响应过大'
|
||||
? '响应过大'
|
||||
: err.message === '禁止重定向'
|
||||
? '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL';
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = result.latencyMs;
|
||||
config.verified = result.success;
|
||||
await context.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchModels(
|
||||
context: AiConfigProbeContext,
|
||||
dto?: FetchModelsDto,
|
||||
): Promise<FetchModelsResultDto> {
|
||||
const config = await context.getOrCreateConfig();
|
||||
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return { success: false, models: [], message };
|
||||
}
|
||||
|
||||
// DNS SSRF check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return { success: false, models: [], message };
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = context.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return { success: false, models: [], message: '未配置 API Key' };
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
try {
|
||||
const { status, contentType, body } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return { success: false, models: [], message: '认证失败,请检查 API Key' };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { success: false, models: [], message: '服务不可用' };
|
||||
}
|
||||
if (status >= 400) {
|
||||
return { success: false, models: [], message: `服务返回错误状态 ${status}` };
|
||||
}
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return { success: false, models: [], message: '响应格式无效' };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { success: false, models: [], message: '响应格式无效' };
|
||||
}
|
||||
|
||||
const data = parsed as { data?: Array<{ id: string }> };
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
return { success: true, models };
|
||||
} catch {
|
||||
return { success: false, models: [], message: '获取模型列表失败,请检查配置' };
|
||||
}
|
||||
}
|
||||
@@ -1,379 +1,28 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { isIP } from 'node:net';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
|
||||
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||
import {
|
||||
SaveAiConfigDto,
|
||||
TestAiConfigDto,
|
||||
FetchModelsDto,
|
||||
FetchModelsResultDto,
|
||||
AiConfigResponseDto,
|
||||
AiConfigTestResultDto,
|
||||
AiRuntimeConfig,
|
||||
DEFAULT_BASE_URLS,
|
||||
FetchModelsDto,
|
||||
FetchModelsResultDto,
|
||||
SaveAiConfigDto,
|
||||
TestAiConfigDto,
|
||||
AiConfigTestResultDto,
|
||||
} from './dto/ai-config.dto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _encryptionWarned = false;
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!_encryptionWarned) {
|
||||
_encryptionWarned = true;
|
||||
Logger.warn(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
|
||||
'AiConfigService',
|
||||
);
|
||||
}
|
||||
// 32 hex pairs → 32 bytes
|
||||
return Buffer.from('ff'.repeat(32), 'hex');
|
||||
}
|
||||
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
|
||||
}
|
||||
|
||||
// Hex: exactly 64 hex chars
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, 'hex');
|
||||
}
|
||||
|
||||
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
|
||||
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length !== 32) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 解码后须为 32 字节',
|
||||
);
|
||||
}
|
||||
// Re-encode to canonical base64 (no line breaks) and compare
|
||||
const canonical = buf.toString('base64');
|
||||
if (raw !== canonical) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 编码须为标准格式(无多余 padding)',
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
|
||||
);
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
|
||||
const key = getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
authTag: tag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL / SSRF helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRIVATE_IPV4_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
||||
];
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Strip IPv6 brackets from URL.hostname
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
hostname = hostname.slice(1, -1);
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
|
||||
if (isIP(hostname) === 6) {
|
||||
// IPv6 private/loopback
|
||||
if (hostname === '::1' || hostname === '::') return true;
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
if (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
)
|
||||
return true; // fe80::/10
|
||||
// IPv4-mapped IPv6: ::ffff:0:0/96
|
||||
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known provider hosts — only these are allowed for fixed providers
|
||||
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
|
||||
[AiProvider.OPENAI]: ['api.openai.com'],
|
||||
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
|
||||
};
|
||||
|
||||
// Required pathname for fixed providers
|
||||
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
||||
[AiProvider.OPENAI]: '/v1',
|
||||
[AiProvider.DEEPSEEK]: '/',
|
||||
};
|
||||
|
||||
// Known public provider hosts — always skip DNS private-IP check.
|
||||
// Their CDN/proxy nodes may resolve to private-range IPs in certain regions.
|
||||
const DNS_TRUSTED_HOSTS = new Set([
|
||||
'api.openai.com',
|
||||
'api.deepseek.com',
|
||||
]);
|
||||
|
||||
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
|
||||
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
|
||||
if (!raw) {
|
||||
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
|
||||
}
|
||||
|
||||
// Reject search/query and hash/fragment
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
|
||||
throw new BadRequestException('生产环境禁止使用 http://');
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
||||
|
||||
// Provider-specific host check
|
||||
const allowedHosts = PROVIDER_HOSTS[provider];
|
||||
if (allowedHosts) {
|
||||
if (!allowedHosts.includes(parsed.hostname)) {
|
||||
throw new BadRequestException(`${provider} 必须使用固定域名`);
|
||||
}
|
||||
// Enforce exact path for fixed providers
|
||||
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
|
||||
if (
|
||||
requiredPath !== undefined &&
|
||||
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
|
||||
) {
|
||||
throw new BadRequestException(`请求参数无效`);
|
||||
}
|
||||
} else {
|
||||
// OPENAI_COMPATIBLE — SSRF check
|
||||
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
|
||||
throw new BadRequestException('不允许使用内网地址');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveHostnames(hostname: string): Promise<{ address: string; family: number }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析返回空结果'));
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
addresses.map((a) => ({
|
||||
address: a.address,
|
||||
family: a.family,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
||||
// Trusted public provider hosts — skip DNS check (CDN nodes may resolve to private IPs)
|
||||
if (DNS_TRUSTED_HOSTS.has(hostname)) return;
|
||||
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
if (allowPrivate) return;
|
||||
|
||||
let addresses: { address: string; family: number }[];
|
||||
try {
|
||||
addresses = await resolveHostnames(hostname);
|
||||
} catch {
|
||||
throw new BadRequestException('无法解析域名');
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateHost(address)) {
|
||||
throw new BadRequestException('域名解析到内网地址');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection test — uses node:http/https with DNS pinning to prevent rebinding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
/**
|
||||
* Perform a pinned HTTP GET request.
|
||||
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
|
||||
* Redirects are forbidden. HTTPS certificate validation is enforced.
|
||||
*/
|
||||
function pinnedGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
|
||||
const hostname = parsed.hostname;
|
||||
const path = parsed.pathname + parsed.search;
|
||||
|
||||
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
|
||||
if (dnsErr || !addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = addresses.find((a) => !isPrivateHost(a.address));
|
||||
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
|
||||
reject(new Error('解析到内网地址'));
|
||||
return;
|
||||
}
|
||||
const targetIp = resolved ? resolved.address : addresses[0].address;
|
||||
const family = resolved ? resolved.family : addresses[0].family;
|
||||
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const requestStart = Date.now();
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: targetIp,
|
||||
port,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { ...headers, Host: hostname },
|
||||
servername: isHttps ? hostname : undefined,
|
||||
rejectUnauthorized: isHttps,
|
||||
family: family === 6 ? 6 : 4,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
const status = res.statusCode ?? 500;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
res.destroy();
|
||||
return reject(new Error('禁止重定向'));
|
||||
}
|
||||
|
||||
const contentType = res.headers['content-type'] ?? null;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error('响应过大'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve({ status, contentType, body, latencyMs });
|
||||
});
|
||||
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('连接超时'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
import { decrypt, encrypt, validateAndNormalizeBaseUrl, validateDnsNotPrivate } from './ai-config.helpers';
|
||||
import { fetchModels, testConnection } from './ai-config.probe';
|
||||
import type { AiConfigProbeContext } from './ai-config.probe';
|
||||
|
||||
@Injectable()
|
||||
export class AiConfigService {
|
||||
export class AiConfigService implements AiConfigProbeContext {
|
||||
private readonly logger = new Logger(AiConfigService.name);
|
||||
|
||||
constructor(
|
||||
@@ -381,8 +30,12 @@ export class AiConfigService {
|
||||
private readonly repo: Repository<AiConfig>,
|
||||
) {}
|
||||
|
||||
save(config: AiConfig): Promise<AiConfig> {
|
||||
return this.repo.save(config);
|
||||
}
|
||||
|
||||
/** Resolve the effective API key: DB first, then env, then none */
|
||||
private resolveApiKey(config: AiConfig | null): {
|
||||
resolveApiKey(config: AiConfig | null): {
|
||||
plaintext: string | null;
|
||||
source: 'database' | 'environment' | 'none';
|
||||
} {
|
||||
@@ -495,7 +148,6 @@ export class AiConfigService {
|
||||
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
|
||||
// Validate and normalize baseUrl
|
||||
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
|
||||
|
||||
// DNS SSRF check for all providers
|
||||
@@ -566,251 +218,12 @@ export class AiConfigService {
|
||||
|
||||
/** Test connection — uses saved config or request body overrides */
|
||||
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Determine effective provider / baseUrl
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine effective defaultModel
|
||||
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
|
||||
|
||||
// DNS check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
let result: AiConfigTestResultDto;
|
||||
try {
|
||||
const { status, contentType, body, latencyMs } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
// Classify by HTTP status first, then content-type
|
||||
if (status === 401 || status === 403) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '认证失败,请检查 API Key',
|
||||
};
|
||||
} else if (status >= 500) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '服务不可用',
|
||||
};
|
||||
} else if (status >= 400) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: `服务返回错误状态 ${status}`,
|
||||
};
|
||||
} else if (!contentType || !contentType.includes('application/json')) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
} else {
|
||||
let data: { data?: Array<{ id: string }> };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
|
||||
data = parsed;
|
||||
} catch {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = latencyMs;
|
||||
config.verified = false;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
const modelCount = models.length;
|
||||
const modelAvailable =
|
||||
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
|
||||
|
||||
const message = modelAvailable
|
||||
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
|
||||
: effectiveDefaultModel
|
||||
? '连接成功,但未找到目标模型'
|
||||
: models.length > 0
|
||||
? `连接成功,可用模型 ${models.length} 个`
|
||||
: '连接成功,但未返回可用模型';
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
latencyMs,
|
||||
modelCount,
|
||||
modelAvailable,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message === '连接超时'
|
||||
? '连接超时'
|
||||
: err.message === '响应过大'
|
||||
? '响应过大'
|
||||
: err.message === '禁止重定向'
|
||||
? '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL';
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last tested info on config
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = result.latencyMs;
|
||||
config.verified = result.success;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
return testConnection(this, dto);
|
||||
}
|
||||
|
||||
/** Fetch available model list from the configured provider */
|
||||
async fetchModels(dto?: FetchModelsDto): Promise<FetchModelsResultDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return { success: false, models: [], message };
|
||||
}
|
||||
|
||||
// DNS SSRF check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return { success: false, models: [], message };
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return { success: false, models: [], message: '未配置 API Key' };
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
try {
|
||||
const { status, contentType, body } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return { success: false, models: [], message: '认证失败,请检查 API Key' };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { success: false, models: [], message: '服务不可用' };
|
||||
}
|
||||
if (status >= 400) {
|
||||
return { success: false, models: [], message: `服务返回错误状态 ${status}` };
|
||||
}
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return { success: false, models: [], message: '响应格式无效' };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return { success: false, models: [], message: '响应格式无效' };
|
||||
}
|
||||
|
||||
const data = parsed as { data?: Array<{ id: string }> };
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
return { success: true, models };
|
||||
} catch {
|
||||
return { success: false, models: [], message: '获取模型列表失败,请检查配置' };
|
||||
}
|
||||
return fetchModels(this, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,9 @@ const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COM
|
||||
export const REASONING_EFFORT_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
|
||||
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
|
||||
// aislop-ignore-next-line: hardcoded-url -- OpenAI 官方 API 固定端点
|
||||
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
|
||||
// aislop-ignore-next-line: hardcoded-url -- DeepSeek 官方 API 固定端点
|
||||
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
|
||||
[AiProvider.OPENAI_COMPATIBLE]: '',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user