Add AI fallback model setting

This commit is contained in:
2026-05-11 18:03:19 +08:00
parent 0512d09d09
commit 91c7db0863
10 changed files with 200 additions and 43 deletions

View File

@@ -541,7 +541,8 @@ pageSize?: number
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。 - 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
- 不允许跨部门检索报告作为上下文。 - 不允许跨部门检索报告作为上下文。
- 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。 - 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。
- 上游模型返回 `429/5xx` 等临时错误时,后端会对 `/chat/completions` 做短暂重试;重试后仍失败时保留上游 HTTP 状态码,并通过错误码区分 `AI_PROVIDER_OVERLOADED``AI_PROVIDER_RATE_LIMITED``AI_PROVIDER_UNAVAILABLE``AI_PROVIDER_ERROR` - Provider 可配置 `fallbackModelName` 作为备用模型;主模型在短暂重试后仍返回 `429/5xx` 或请求超时时,后端会自动改用备用模型再请求一次
- 上游模型返回 `429/5xx` 等临时错误时,后端会对 `/chat/completions` 做短暂重试;无可用备用模型或备用模型仍失败时保留上游 HTTP 状态码,并通过错误码区分 `AI_PROVIDER_OVERLOADED``AI_PROVIDER_RATE_LIMITED``AI_PROVIDER_UNAVAILABLE``AI_PROVIDER_TIMEOUT``AI_PROVIDER_ERROR`
## Speech API ## Speech API

View File

@@ -15,7 +15,11 @@ const actor: SafeUser = {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const createService = (modelName = 'moonshot-v1', endpoint = 'https://provider.example/v1') => { const createService = (
modelName = 'moonshot-v1',
endpoint = 'https://provider.example/v1',
fallbackModelName = '',
) => {
const settingsService = { const settingsService = {
getSystemSettings: vi.fn().mockResolvedValue({ getSystemSettings: vi.fn().mockResolvedValue({
activeAiProvider: 'kimi', activeAiProvider: 'kimi',
@@ -24,6 +28,7 @@ const createService = (modelName = 'moonshot-v1', endpoint = 'https://provider.e
endpoint, endpoint,
apiKey: 'test-key', apiKey: 'test-key',
modelName, modelName,
fallbackModelName,
}, },
}, },
}), }),
@@ -86,6 +91,34 @@ describe('AiService', () => {
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
}); });
it('falls back to the configured backup model after retryable provider failures', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"备用模型已完善"}' } }] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await createService(
'moonshot-v1-32k',
'https://api.moonshot.cn/v1',
'moonshot-v1-auto',
).chat(actor, {
messages: [{ role: 'user', content: '请继续完善手术步骤' }],
temperature: 0.3,
});
const requestedModels = fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)).model);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(requestedModels).toEqual([
'moonshot-v1-32k',
'moonshot-v1-32k',
'moonshot-v1-32k',
'moonshot-v1-auto',
]);
expect(response).toEqual({ choices: [{ message: { content: '{"reply":"备用模型已完善"}' } }] });
});
it('removes unsupported sampling options for Kimi K2 models', async () => { it('removes unsupported sampling options for Kimi K2 models', async () => {
const fetchMock = vi.fn().mockResolvedValue( const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }), new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }),

View File

@@ -7,6 +7,7 @@ interface AiProvider {
endpoint: string; endpoint: string;
apiKey: string; apiKey: string;
modelName: string; modelName: string;
fallbackModelName?: string;
} }
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]); const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
@@ -42,6 +43,7 @@ export class AiService {
provider: { provider: {
endpoint: provider.endpoint, endpoint: provider.endpoint,
modelName: provider.modelName, modelName: provider.modelName,
fallbackModelName: provider.fallbackModelName || '',
}, },
raw: payload, raw: payload,
}; };
@@ -55,18 +57,12 @@ export class AiService {
const provider = await this.getActiveProvider(actor); const provider = await this.getActiveProvider(actor);
const input = result.data; const input = result.data;
const model = this.selectModel(provider, input); const modelCandidates = this.selectModelCandidates(provider, input);
const payload = this.normalizeProviderPayload({ const { response, responsePayload } = await this.fetchChatWithModelFallback(
...input, provider,
model, input,
}); modelCandidates,
);
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
});
const responsePayload = await this.parseProviderResponse(response);
if (!response.ok) { if (!response.ok) {
throw this.createProviderException(response.status, responsePayload); throw this.createProviderException(response.status, responsePayload);
@@ -82,6 +78,7 @@ export class AiService {
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || ''; const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
const apiKey = provider?.apiKey || ''; const apiKey = provider?.apiKey || '';
const modelName = provider?.modelName || ''; const modelName = provider?.modelName || '';
const fallbackModelName = provider?.fallbackModelName || '';
if (!endpoint) { if (!endpoint) {
throw new BadRequestException('尚未配置 AI 接口地址'); throw new BadRequestException('尚未配置 AI 接口地址');
@@ -93,7 +90,7 @@ export class AiService {
throw new BadRequestException('尚未配置 AI 模型名称'); throw new BadRequestException('尚未配置 AI 模型名称');
} }
return { endpoint, apiKey, modelName }; return { endpoint, apiKey, modelName, fallbackModelName };
} }
private headers(provider: AiProvider) { private headers(provider: AiProvider) {
@@ -115,18 +112,29 @@ export class AiService {
return normalized; return normalized;
} }
private selectModel(provider: AiProvider, input: Record<string, unknown>) { private selectModelCandidates(provider: AiProvider, input: Record<string, unknown>) {
const configuredModel = provider.modelName || (typeof input.model === 'string' ? input.model : ''); const primaryModel = this.selectModel(provider, input, provider.modelName);
if (!this.isMoonshotProvider(provider)) return configuredModel; const fallbackModel = provider.fallbackModelName
? this.selectModel(provider, input, provider.fallbackModelName)
: '';
return [primaryModel, fallbackModel].filter((model, index, models): model is string => (
Boolean(model) && models.indexOf(model) === index
));
}
private selectModel(provider: AiProvider, input: Record<string, unknown>, configuredModel: string) {
const model = configuredModel || (typeof input.model === 'string' ? input.model : '');
if (!this.isMoonshotProvider(provider)) return model;
const hasImages = this.hasImageInput(input.messages); const hasImages = this.hasImageInput(input.messages);
if (hasImages && !this.supportsImageInput(configuredModel)) { if (hasImages && !this.supportsImageInput(model)) {
return process.env.AI_KIMI_VISION_MODEL || DEFAULT_KIMI_VISION_MODEL; return process.env.AI_KIMI_VISION_MODEL || DEFAULT_KIMI_VISION_MODEL;
} }
if (!hasImages && /^kimi-k2(?:[.-]|$)/i.test(configuredModel)) { if (!hasImages && this.isKimiMultimodalModel(model)) {
return process.env.AI_KIMI_TEXT_MODEL || DEFAULT_KIMI_TEXT_MODEL; return process.env.AI_KIMI_TEXT_MODEL || DEFAULT_KIMI_TEXT_MODEL;
} }
return configuredModel; return model;
} }
private isMoonshotProvider(provider: AiProvider) { private isMoonshotProvider(provider: AiProvider) {
@@ -134,7 +142,11 @@ export class AiService {
} }
private supportsImageInput(model: string) { private supportsImageInput(model: string) {
return /vision|kimi-k2(?:[.-]|$)/i.test(model); return /vision/i.test(model) || this.isKimiMultimodalModel(model);
}
private isKimiMultimodalModel(model: string) {
return /^kimi-k2\.(?:5|6)$/i.test(model);
} }
private hasImageInput(messages: unknown) { private hasImageInput(messages: unknown) {
@@ -197,6 +209,51 @@ export class AiService {
return response; return response;
} }
private async fetchChatWithModelFallback(
provider: AiProvider,
input: Record<string, unknown>,
models: string[],
) {
let lastError: unknown;
for (let index = 0; index < models.length; index += 1) {
const model = models[index];
try {
const payload = this.normalizeProviderPayload({
...input,
model,
});
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
});
const responsePayload = await this.parseProviderResponse(response);
if (response.ok || index === models.length - 1 || !this.shouldFallbackFromStatus(response.status)) {
return { response, responsePayload };
}
lastError = this.createProviderException(response.status, responsePayload);
} catch (error) {
if (index === models.length - 1 || !this.shouldFallbackFromError(error)) {
throw error;
}
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new BadRequestException('AI 服务请求失败');
}
private shouldFallbackFromStatus(status: number) {
return RETRYABLE_PROVIDER_STATUSES.has(status);
}
private shouldFallbackFromError(error: unknown) {
return error instanceof HttpException && this.shouldFallbackFromStatus(error.getStatus());
}
private formatProviderError(status: number, payload: unknown) { private formatProviderError(status: number, payload: unknown) {
const message = const message =
typeof payload === 'object' && payload !== null && 'error' in payload typeof payload === 'object' && payload !== null && 'error' in payload

View File

@@ -21,10 +21,11 @@ export const DEMO_SYSTEM_SETTINGS = {
endpoint: 'https://api.moonshot.cn/v1', endpoint: 'https://api.moonshot.cn/v1',
apiKey: DEMO_AI_API_KEY, apiKey: DEMO_AI_API_KEY,
modelName: 'kimi-k2-turbo-preview', modelName: 'kimi-k2-turbo-preview',
fallbackModelName: 'moonshot-v1-auto',
}, },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat', fallbackModelName: '' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
custom: { endpoint: '', apiKey: '', modelName: '' }, custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' },
}, },
autoInsertFrames: true, autoInsertFrames: true,
autoInsertDelay: 1, autoInsertDelay: 1,

View File

@@ -19,6 +19,7 @@ describe('settings schemas', () => {
expect(result.framePositions).toEqual([75, 25]); expect(result.framePositions).toEqual([75, 25]);
expect(result.frameMode).toBe('keep'); expect(result.frameMode).toBe('keep');
expect(result.aiProviders.custom.apiKey).toBe(''); expect(result.aiProviders.custom.apiKey).toBe('');
expect(result.aiProviders.custom.fallbackModelName).toBe('');
}); });
it('rejects invalid frame positions', () => { it('rejects invalid frame positions', () => {

View File

@@ -4,6 +4,7 @@ export const aiProviderSchema = z.object({
endpoint: z.string().default(''), endpoint: z.string().default(''),
apiKey: z.string().default(''), apiKey: z.string().default(''),
modelName: z.string().default(''), modelName: z.string().default(''),
fallbackModelName: z.string().default(''),
}).passthrough(); }).passthrough();
export const xfSpeechConfigSchema = z.object({ export const xfSpeechConfigSchema = z.object({

View File

@@ -13,10 +13,15 @@ import { PrismaService } from '../prisma/prisma.service.js';
import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js'; import { systemSettingsSchema, type SystemSettingsInput } from './settings.schemas.js';
const DEFAULT_AI_PROVIDERS = { const DEFAULT_AI_PROVIDERS = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'kimi-k2-turbo-preview' }, kimi: {
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, endpoint: 'https://api.moonshot.cn/v1',
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey,
custom: { endpoint: '', apiKey: '', modelName: '' }, modelName: 'kimi-k2-turbo-preview',
fallbackModelName: 'moonshot-v1-auto',
},
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat', fallbackModelName: '' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' },
}; };
const DEFAULT_SETTINGS: SystemSettingsInput = { const DEFAULT_SETTINGS: SystemSettingsInput = {
@@ -267,10 +272,19 @@ export class SettingsService {
} }
private normalize(input: SystemSettingsInput): SystemSettingsInput { private normalize(input: SystemSettingsInput): SystemSettingsInput {
const aiProviders = { const providerKeys = new Set([
...DEFAULT_AI_PROVIDERS, ...Object.keys(DEFAULT_AI_PROVIDERS),
...(input.aiProviders || {}), ...Object.keys(input.aiProviders || {}),
}; ]);
const aiProviders = Object.fromEntries(
Array.from(providerKeys).map((key) => [
key,
{
...(DEFAULT_AI_PROVIDERS[key as keyof typeof DEFAULT_AI_PROVIDERS] || DEFAULT_AI_PROVIDERS.custom),
...(input.aiProviders?.[key] || {}),
},
]),
);
const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)] const framePositions = [...(input.framePositions || DEFAULT_SETTINGS.framePositions)]
.map((value) => Math.round(value * 100) / 100); .map((value) => Math.round(value * 100) / 100);

View File

@@ -5,6 +5,7 @@ export interface AiModelsResponse {
provider: { provider: {
endpoint: string; endpoint: string;
modelName: string; modelName: string;
fallbackModelName?: string;
}; };
raw?: unknown; raw?: unknown;
} }

View File

@@ -14,10 +14,19 @@ const normalizeSettings = (
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>, input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
templates: Template[], templates: Template[],
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => { ): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
const aiProviders = { const providerKeys = new Set([
...DEFAULT_AI_PROVIDERS, ...Object.keys(DEFAULT_AI_PROVIDERS),
...(input.aiProviders || {}), ...Object.keys(input.aiProviders || {}),
}; ]);
const aiProviders = Object.fromEntries(
Array.from(providerKeys).map((key) => [
key,
{
...(DEFAULT_AI_PROVIDERS[key] || DEFAULT_AI_PROVIDERS.custom),
...(input.aiProviders?.[key] || {}),
},
]),
);
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS); const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
return { return {
@@ -96,7 +105,8 @@ export default function SystemSettings() {
providers.kimi = { providers.kimi = {
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint, endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
apiKey: (savedSettings as any).kimiApiKey || '', apiKey: (savedSettings as any).kimiApiKey || '',
modelName: 'kimi-k2-turbo-preview' modelName: 'kimi-k2-turbo-preview',
fallbackModelName: 'moonshot-v1-auto',
}; };
} }
savedSettings.aiProviders = providers; savedSettings.aiProviders = providers;
@@ -443,7 +453,7 @@ export default function SystemSettings() {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Model Name)</label> <label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Primary Model)</label>
{availableModels.length > 0 ? ( {availableModels.length > 0 ? (
<select <select
value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''} value={settings.aiProviders[settings.activeAiProvider]?.modelName || ''}
@@ -473,6 +483,38 @@ export default function SystemSettings() {
)} )}
<p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p> <p className="text-[11px] text-text-muted">{availableModels.length > 0 ? '已从服务商获取可用模型列表' : '点击"测试连接"成功后,此处可下拉选择模型'}</p>
</div> </div>
<div className="space-y-1.5">
<label className="block text-xs font-bold text-text-main uppercase tracking-wider"> (Fallback Model)</label>
{availableModels.length > 0 ? (
<select
value={settings.aiProviders[settings.activeAiProvider]?.fallbackModelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], fallbackModelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
className="input-minimal bg-white"
>
<option value="">使</option>
{availableModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
) : (
<input
type="text"
value={settings.aiProviders[settings.activeAiProvider]?.fallbackModelName || ''}
onChange={(e) => {
const next = { ...settings.aiProviders };
next[settings.activeAiProvider] = { ...next[settings.activeAiProvider], fallbackModelName: e.target.value };
setSettings({ ...settings, aiProviders: next });
}}
placeholder="moonshot-v1-auto"
className="input-minimal"
/>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -84,6 +84,7 @@ export interface AiProviderConfig {
endpoint: string; endpoint: string;
apiKey: string; apiKey: string;
modelName: string; modelName: string;
fallbackModelName?: string;
} }
export interface XfSpeechConfig { export interface XfSpeechConfig {
@@ -112,10 +113,15 @@ export interface SystemSettings {
} }
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = { export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: '', modelName: 'kimi-k2-turbo-preview' }, kimi: {
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' }, endpoint: 'https://api.moonshot.cn/v1',
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' }, apiKey: '',
custom: { endpoint: '', apiKey: '', modelName: '' } modelName: 'kimi-k2-turbo-preview',
fallbackModelName: 'moonshot-v1-auto'
},
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat', fallbackModelName: '' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o', fallbackModelName: '' },
custom: { endpoint: '', apiKey: '', modelName: '', fallbackModelName: '' }
}; };
export interface BindableField { export interface BindableField {