import { loadAdminAiConfig, loadHostAppConfig, readSomaEnv, resolveHostDir, } from '../../api/common/AdminHostConfig'; import { AiApiError, AiErrorCodes } from './AiError'; export interface AiRuntimeConfig { baseUrl: string; model: string; timeoutMs: number; maxConcurrency: number; aiApi: { model?: string; /** 主生成模型连续空输出后的非思考模型旁路;仍为空时由 SOMA 交付确定性降级声明。 */ emptyOutputFallbackModel?: string; maxOutputTokens: number; retryOnParseError: number; depAllowedExtensions: string[]; }; assistant: { model?: string; maxSteps: number; systemPrompt?: string; knowledge: { roots: string[]; allowedExtensions: string[]; maxFileBytes: number; }; /** 工具包开关与透传配置;整段可省略,未列出的包缺省启用。 */ toolkits: Record; }; hostDir: string; } export interface AssistantToolkitSetting { enabled: boolean; options: Record; } const DEFAULT_DEP_EXTENSIONS = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv', '.sql']; const DEFAULT_KNOWLEDGE_EXTENSIONS = ['.md', '.txt', '.json', '.yaml', '.yml']; const AI_API_KEY = new WeakMap(); export function resolveAiRuntimeConfig(hostDir = resolveHostDir()): AiRuntimeConfig { const source = loadAdminAiConfig(hostDir); if (!source || typeof source !== 'object') { throw configError(); } if (Object.prototype.hasOwnProperty.call(source, 'provider')) { throw configError('provider 无需配置,AI 服务固定使用 OpenAI 兼容协议'); } const apiKey = optionalString(source.apiKey, 'apiKey'); const baseUrl = optionalHttpBaseUrl(source.baseUrl, 'baseUrl', readSomaEnv()); if (!apiKey || !baseUrl) { throw configError(); } const aiApi = source.aiApi && typeof source.aiApi === 'object' ? source.aiApi : {}; const platformAssistant = source.assistant && typeof source.assistant === 'object' ? source.assistant : {}; const hostAssistant = loadHostAppConfig(hostDir).aiHost?.assistant ?? {}; const assistant = { ...platformAssistant, ...hostAssistant }; const knowledge = { ...(platformAssistant.knowledge && typeof platformAssistant.knowledge === 'object' ? platformAssistant.knowledge : {}), ...(hostAssistant.knowledge && typeof hostAssistant.knowledge === 'object' ? hostAssistant.knowledge : {}), }; // 工具包开关按包名逐个覆盖(而不是整段替换):宿主想关一个内置包时, // 不该被迫把平台侧其余包的配置抄一遍。 const toolkits = { ...(platformAssistant.toolkits && typeof platformAssistant.toolkits === 'object' ? platformAssistant.toolkits : {}), ...(hostAssistant.toolkits && typeof hostAssistant.toolkits === 'object' ? hostAssistant.toolkits : {}), }; const config: AiRuntimeConfig = { baseUrl, model: optionalString(source.model, 'ai.config.model') ?? 'deepseek-chat', // 默认必须是非思考模型:v4-flash 等思考模型不支持强制 tool_choice(实弹结论) timeoutMs: positiveInteger(source.timeoutMs, 'ai.config.timeoutMs', 60_000), maxConcurrency: positiveInteger(source.maxConcurrency, 'ai.config.maxConcurrency', 8), aiApi: { model: optionalString(aiApi.model, 'ai.config.aiApi.model'), emptyOutputFallbackModel: optionalString( aiApi.emptyOutputFallbackModel, 'ai.config.aiApi.emptyOutputFallbackModel', ) ?? 'deepseek-chat', maxOutputTokens: positiveInteger( aiApi.maxOutputTokens, 'ai.config.aiApi.maxOutputTokens', 4_096, ), retryOnParseError: nonNegativeInteger( aiApi.retryOnParseError, 'ai.config.aiApi.retryOnParseError', 1, ), depAllowedExtensions: extensionList( aiApi.depAllowedExtensions, 'ai.config.aiApi.depAllowedExtensions', DEFAULT_DEP_EXTENSIONS, ), }, assistant: { model: optionalString(assistant.model, 'ai.config.assistant.model'), maxSteps: positiveInteger(assistant.maxSteps, 'ai.config.assistant.maxSteps', 8), systemPrompt: optionalString(assistant.systemPrompt, 'ai.config.assistant.systemPrompt'), knowledge: { roots: stringList(knowledge.roots, 'ai.config.assistant.knowledge.roots', ['knowledge']), allowedExtensions: extensionList( knowledge.allowedExtensions, 'ai.config.assistant.knowledge.allowedExtensions', DEFAULT_KNOWLEDGE_EXTENSIONS, ), maxFileBytes: positiveInteger( knowledge.maxFileBytes, 'ai.config.assistant.knowledge.maxFileBytes', 256 * 1024, ), }, toolkits: toolkitSettings(toolkits, 'ai.config.assistant.toolkits'), }, hostDir, }; AI_API_KEY.set(config, apiKey); return config; } export function readAiApiKey(config: AiRuntimeConfig): string { const apiKey = AI_API_KEY.get(config); if (!apiKey) { throw configError(); } return apiKey; } function optionalString(value: unknown, field: string): string | undefined { if (value === undefined || value === null) { return undefined; } if (typeof value !== 'string' || !value.trim()) { throw configError(`${field} 必须是非空字符串`); } return value.trim(); } function optionalHttpBaseUrl( value: unknown, field: string, environment: 'test' | 'prod', ): string | undefined { const raw = optionalString(value, field); if (!raw) { return undefined; } let parsed: URL; try { parsed = new URL(raw); } catch { throw invalidBaseUrlError(field); } if ( !['http:', 'https:'].includes(parsed.protocol) || hasForbiddenUrlComponents(raw, parsed) || (parsed.protocol === 'http:' && (environment !== 'test' || !isLoopbackHostname(parsed.hostname))) ) { throw invalidBaseUrlError(field); } return parsed.href.replace(/\/+$/, ''); } function hasForbiddenUrlComponents(raw: string, parsed: URL): boolean { const authorityStart = raw.indexOf('//') + 2; const authority = raw.slice(authorityStart).split(/[/?#]/, 1)[0] ?? ''; return ( authority.includes('@') || Boolean(parsed.username) || Boolean(parsed.password) || raw.includes('?') || raw.includes('#') ); } function isLoopbackHostname(rawHostname: string): boolean { const hostname = rawHostname.toLowerCase(); if (hostname === 'localhost' || hostname === '[::1]') { return true; } const octets = hostname.split('.'); return octets.length === 4 && octets.every((octet) => /^\d+$/.test(octet) && Number(octet) <= 255) && Number(octets[0]) === 127; } function invalidBaseUrlError(field: string): AiApiError { return configError( `${field} 必须是无凭据、查询参数和片段的 HTTPS URL;仅 test/Mock 允许 loopback HTTP`, ); } function positiveInteger(value: unknown, field: string, fallback: number): number { if (value === undefined || value === null) { return fallback; } if (!Number.isSafeInteger(value) || Number(value) < 1) { throw configError(`${field} 必须是正整数`); } return Number(value); } function nonNegativeInteger(value: unknown, field: string, fallback: number): number { if (value === undefined || value === null) { return fallback; } if (!Number.isSafeInteger(value) || Number(value) < 0) { throw configError(`${field} 必须是非负整数`); } return Number(value); } function stringList(value: unknown, field: string, fallback: string[]): string[] { if (value === undefined || value === null) { return [...fallback]; } if (!Array.isArray(value) || value.length === 0) { throw configError(`${field} 必须是非空字符串数组`); } const normalized = value.map((entry) => optionalString(entry, field)); if (normalized.some((entry) => !entry)) { throw configError(`${field} 必须是非空字符串数组`); } return [...new Set(normalized as string[])]; } function toolkitSettings( value: Record, field: string, ): Record { const settings: Record = {}; for (const [name, raw] of Object.entries(value)) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw configError(`${field}.${name} 必须是对象`); } const { enabled, ...options } = raw as { enabled?: unknown } & Record; if (enabled !== undefined && typeof enabled !== 'boolean') { throw configError(`${field}.${name}.enabled 必须是布尔值`); } settings[name] = { enabled: enabled ?? true, options }; } return settings; } function extensionList(value: unknown, field: string, fallback: string[]): string[] { const entries = stringList(value, field, fallback).map((entry) => entry.toLowerCase()); if (entries.some((entry) => !/^\.[a-z0-9]+$/.test(entry))) { throw configError(`${field} 包含非法扩展名`); } return [...new Set(entries)]; } function configError(message = 'AI 服务未配置'): AiApiError { return new AiApiError(AiErrorCodes.AI_CONFIG_MISSING, message); }