Refactor AI config to step-based wizard with DeepSeek defaults
Some checks failed
CI / check (pull_request) Failing after 1m38s
Some checks failed
CI / check (pull_request) Failing after 1m38s
This commit is contained in:
@@ -26,6 +26,10 @@ interface StreamChoiceDelta {
|
||||
}
|
||||
|
||||
const MAX_UPSTREAM_EVENT_BYTES = 1024 * 1024;
|
||||
|
||||
// Known public provider hosts — trusted even if CDN resolves to private-range IPs
|
||||
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
|
||||
|
||||
const PRIVATE_IPV4_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
@@ -180,7 +184,9 @@ export class AiModelStreamService {
|
||||
const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
|
||||
lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {
|
||||
if (dnsError || !addresses?.length) return reject(new Error('DNS 解析失败'));
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
const allowPrivate =
|
||||
process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true' ||
|
||||
DNS_TRUSTED_HOSTS.has(parsed.hostname);
|
||||
if (!allowPrivate && addresses.some(({ address }) => this.isPrivateAddress(address))) {
|
||||
return reject(new Error('域名解析到内网地址'));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
|
||||
import { SaveAiConfigDto, TestAiConfigDto, FetchModelsDto } from './dto/ai-config.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user?: { id: number; username: string };
|
||||
@@ -47,7 +47,7 @@ export class AiConfigController {
|
||||
action: 'save',
|
||||
targetId: config.id,
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'} enabled=${body.enabled ?? config.enabled}`,
|
||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -73,6 +73,13 @@ export class AiConfigController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('models')
|
||||
@RequirePermission('ai:config:read')
|
||||
async fetchModels(@Body() body: FetchModelsDto) {
|
||||
const result = await this.service.fetchModels(body);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('clear-key')
|
||||
@RequirePermission('ai:config:write')
|
||||
async clearKey(@Req() req: AuthenticatedRequest) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class AiConfig {
|
||||
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
|
||||
singletonKey: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
|
||||
@Column({ type: 'varchar', length: 50, default: AiProvider.DEEPSEEK })
|
||||
provider: AiProvider;
|
||||
|
||||
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
|
||||
@@ -45,7 +45,7 @@ export class AiConfig {
|
||||
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
|
||||
defaultModel: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
||||
|
||||
@@ -16,6 +16,8 @@ import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||
import {
|
||||
SaveAiConfigDto,
|
||||
TestAiConfigDto,
|
||||
FetchModelsDto,
|
||||
FetchModelsResultDto,
|
||||
AiConfigResponseDto,
|
||||
AiConfigTestResultDto,
|
||||
AiRuntimeConfig,
|
||||
@@ -163,6 +165,13 @@ const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
||||
[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';
|
||||
|
||||
@@ -243,6 +252,9 @@ async function resolveHostnames(hostname: string): Promise<{ address: string; fa
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -400,9 +412,9 @@ export class AiConfigService {
|
||||
if (!config) {
|
||||
config = this.repo.create({
|
||||
singletonKey: SINGLETON_KEY,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
|
||||
enabled: false,
|
||||
provider: AiProvider.DEEPSEEK,
|
||||
baseUrl: DEFAULT_BASE_URLS[AiProvider.DEEPSEEK],
|
||||
enabled: true,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
try {
|
||||
@@ -420,7 +432,22 @@ export class AiConfigService {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -492,21 +519,11 @@ export class AiConfigService {
|
||||
config.keyLast4 = dto.apiKey.slice(-4);
|
||||
}
|
||||
|
||||
// enabled validation
|
||||
// AI is always enabled by default — the enable switch has been removed
|
||||
if (dto.enabled !== undefined) {
|
||||
if (dto.enabled) {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
throw new BadRequestException('未配置 API Key,无法启用。请先保存 API Key 再启用');
|
||||
}
|
||||
// defaultModel is required when enabled
|
||||
const effectiveDefaultModel =
|
||||
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
|
||||
if (!effectiveDefaultModel) {
|
||||
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
|
||||
}
|
||||
}
|
||||
config.enabled = dto.enabled;
|
||||
} else {
|
||||
config.enabled = true;
|
||||
}
|
||||
|
||||
return this.repo.save(config);
|
||||
@@ -709,6 +726,75 @@ export class AiConfigService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 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: '获取模型列表失败,请检查配置' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only runtime config — for future AI adapters.
|
||||
* Re-validates the stored base URL and DNS at runtime to guard
|
||||
|
||||
@@ -24,8 +24,8 @@ export class SaveAiConfigDto {
|
||||
@IsIn(PROVIDERS)
|
||||
provider!: AiProvider;
|
||||
|
||||
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
|
||||
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
|
||||
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || (o.baseUrl !== undefined && o.baseUrl !== ''))
|
||||
@IsNotEmpty({ message: 'Base URL 不能为空' })
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
@@ -113,4 +113,32 @@ export interface AiRuntimeConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** DTO for POST /api/ai/config/models — fetch available model list from provider */
|
||||
export class FetchModelsDto {
|
||||
@IsOptional()
|
||||
@IsIn(PROVIDERS)
|
||||
provider?: AiProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Response shape for POST /api/ai/config/models */
|
||||
export interface FetchModelsResultDto {
|
||||
success: boolean;
|
||||
models: Array<{ id: string }>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export { DEFAULT_BASE_URLS };
|
||||
|
||||
Reference in New Issue
Block a user