Files
gongxue-base/apps/server/src/ai-config/ai-config.service.ts
T

272 lines
8.8 KiB
TypeScript

import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
import {
AiConfigResponseDto,
AiRuntimeConfig,
DEFAULT_BASE_URLS,
FetchModelsDto,
FetchModelsResultDto,
SaveAiConfigDto,
TestAiConfigDto,
AiConfigTestResultDto,
} from './dto/ai-config.dto';
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 implements AiConfigProbeContext {
private readonly logger = new Logger(AiConfigService.name);
constructor(
@InjectRepository(AiConfig)
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 */
resolveApiKey(config: AiConfig | null): {
plaintext: string | null;
source: 'database' | 'environment' | 'none';
} {
// DB stored key
if (config?.encryptedApiKey && config?.apiKeyIv && config?.apiKeyAuthTag) {
try {
const plaintext = decrypt(config.encryptedApiKey, config.apiKeyIv, config.apiKeyAuthTag);
return { plaintext, source: 'database' };
} catch {
this.logger.error('解密数据库 API Key 失败,密文可能已损坏');
throw new InternalServerErrorException('无法解密 API Key');
}
}
// Environment fallback
const envKey = process.env.AI_API_KEY;
if (envKey) {
return { plaintext: envKey, source: 'environment' };
}
return { plaintext: null, source: 'none' };
}
/** Load or create the singleton config row */
async getOrCreateConfig(): Promise<AiConfig> {
let config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (!config) {
config = this.repo.create({
singletonKey: SINGLETON_KEY,
provider: AiProvider.DEEPSEEK,
baseUrl: DEFAULT_BASE_URLS[AiProvider.DEEPSEEK],
enabled: true,
timeoutMs: 30000,
});
try {
config = await this.repo.save(config);
} catch (err: unknown) {
// Unique constraint violation → another request created it first
const isErrWithCode = err !== null && typeof err === 'object' && 'code' in err;
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
const errno = isErrWithCode ? (err as Record<string, unknown>).errno : undefined;
// MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062
if (code === 'ER_DUP_ENTRY' || errno === 1062) {
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (existing) return existing;
}
throw err;
}
return config;
}
// Migrate old defaults: if provider is still OPENAI (old default) and config was never
// explicitly configured (no API key, never verified), switch to DeepSeek silently.
if (
config.provider === AiProvider.OPENAI &&
config.baseUrl === DEFAULT_BASE_URLS[AiProvider.OPENAI] &&
!config.encryptedApiKey &&
!config.verified
) {
config.provider = AiProvider.DEEPSEEK;
config.baseUrl = DEFAULT_BASE_URLS[AiProvider.DEEPSEEK];
await this.repo.save(config);
}
return config;
}
/** Build masked key display */
private buildMaskedKey(keyLast4: string | null): string | null {
if (keyLast4 && keyLast4.length === 4) {
return `••••${keyLast4}`;
}
return null;
}
/** GET response */
async getConfig(): Promise<AiConfigResponseDto> {
const config = await this.getOrCreateConfig();
const { source } = this.resolveApiKey(config);
const hasDbKey = !!(config.encryptedApiKey && config.apiKeyIv && config.apiKeyAuthTag);
return {
id: config.id,
provider: config.provider,
baseUrl: config.baseUrl,
hasApiKey: source !== 'none',
hasDatabaseKey: hasDbKey,
maskedApiKey: config.keyLast4
? this.buildMaskedKey(config.keyLast4)
: source !== 'none'
? '••••'
: null,
keySource: source,
defaultModel: config.defaultModel ?? null,
enabled: config.enabled,
supportsVision: config.supportsVision,
timeoutMs: config.timeoutMs,
reasoningEffort: config.reasoningEffort ?? null,
verified: config.verified,
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
createdAt: config.createdAt.toISOString(),
updatedAt: config.updatedAt.toISOString(),
};
}
/** PUT / save */
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
const config = await this.getOrCreateConfig();
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
// DNS SSRF check for all providers
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
config.provider = dto.provider;
config.baseUrl = normalizedBaseUrl;
if (dto.defaultModel !== undefined) {
config.defaultModel = dto.defaultModel || null;
}
if (dto.timeoutMs !== undefined) {
config.timeoutMs = dto.timeoutMs;
}
if (dto.reasoningEffort !== undefined) {
config.reasoningEffort = dto.reasoningEffort || null;
}
// Handle apiKey — empty/undefined = keep existing
if (dto.apiKey !== undefined && dto.apiKey !== '') {
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
config.encryptedApiKey = ciphertext;
config.apiKeyIv = iv;
config.apiKeyAuthTag = authTag;
config.keyLast4 = dto.apiKey.slice(-4);
}
// AI is always enabled by default — the enable switch has been removed
if (dto.enabled !== undefined) {
config.enabled = dto.enabled;
} else {
config.enabled = true;
}
if (dto.supportsVision !== undefined) {
config.supportsVision = dto.supportsVision;
}
if (dto.enabled === true) {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');
if (!config.defaultModel?.trim()) {
throw new BadRequestException('启用 AI 服务前必须配置默认模型');
}
}
return this.repo.save(config);
}
/** Clear DB key only */
async clearKey(): Promise<AiConfigResponseDto> {
const config = await this.getOrCreateConfig();
config.encryptedApiKey = null;
config.apiKeyIv = null;
config.apiKeyAuthTag = null;
config.keyLast4 = null;
// If no env key either, disable
const envKey = process.env.AI_API_KEY;
if (!envKey) {
config.enabled = false;
}
await this.repo.save(config);
return this.getConfig();
}
/** Test connection — uses saved config or request body overrides */
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
return testConnection(this, dto);
}
/** Fetch available model list from the configured provider */
async fetchModels(dto?: FetchModelsDto): Promise<FetchModelsResultDto> {
return fetchModels(this, dto);
}
/**
* Server-only runtime config — for future AI adapters.
* Re-validates the stored base URL and DNS at runtime to guard
* against config-table tampering or DNS record changes.
* Future adapters should still use a restricted transport helper.
*/
async getRuntimeConfig(): Promise<AiRuntimeConfig> {
const config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (!config) {
throw new InternalServerErrorException('AI 配置未初始化');
}
if (!config.enabled) {
throw new BadRequestException('AI 服务未启用');
}
// Re-validate and normalize the stored base URL
const normalizedBaseUrl = validateAndNormalizeBaseUrl(config.baseUrl, config.provider);
// Re-check DNS at runtime
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
throw new BadRequestException('未配置 API Key');
}
// defaultModel is required for actual AI calls
if (!config.defaultModel) {
throw new BadRequestException('未配置默认模型');
}
return {
provider: config.provider,
baseUrl: normalizedBaseUrl,
apiKey: plaintext,
defaultModel: config.defaultModel,
timeoutMs: config.timeoutMs,
enabled: config.enabled,
supportsVision: config.supportsVision,
reasoningEffort: config.reasoningEffort ?? null,
};
}
}