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

@@ -15,7 +15,11 @@ const actor: SafeUser = {
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 = {
getSystemSettings: vi.fn().mockResolvedValue({
activeAiProvider: 'kimi',
@@ -24,6 +28,7 @@ const createService = (modelName = 'moonshot-v1', endpoint = 'https://provider.e
endpoint,
apiKey: 'test-key',
modelName,
fallbackModelName,
},
},
}),
@@ -86,6 +91,34 @@ describe('AiService', () => {
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 () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }),

View File

@@ -7,6 +7,7 @@ interface AiProvider {
endpoint: string;
apiKey: string;
modelName: string;
fallbackModelName?: string;
}
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
@@ -42,6 +43,7 @@ export class AiService {
provider: {
endpoint: provider.endpoint,
modelName: provider.modelName,
fallbackModelName: provider.fallbackModelName || '',
},
raw: payload,
};
@@ -55,18 +57,12 @@ export class AiService {
const provider = await this.getActiveProvider(actor);
const input = result.data;
const model = this.selectModel(provider, input);
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);
const modelCandidates = this.selectModelCandidates(provider, input);
const { response, responsePayload } = await this.fetchChatWithModelFallback(
provider,
input,
modelCandidates,
);
if (!response.ok) {
throw this.createProviderException(response.status, responsePayload);
@@ -82,6 +78,7 @@ export class AiService {
const endpoint = provider?.endpoint?.replace(/\/+$/, '') || '';
const apiKey = provider?.apiKey || '';
const modelName = provider?.modelName || '';
const fallbackModelName = provider?.fallbackModelName || '';
if (!endpoint) {
throw new BadRequestException('尚未配置 AI 接口地址');
@@ -93,7 +90,7 @@ export class AiService {
throw new BadRequestException('尚未配置 AI 模型名称');
}
return { endpoint, apiKey, modelName };
return { endpoint, apiKey, modelName, fallbackModelName };
}
private headers(provider: AiProvider) {
@@ -115,18 +112,29 @@ export class AiService {
return normalized;
}
private selectModel(provider: AiProvider, input: Record<string, unknown>) {
const configuredModel = provider.modelName || (typeof input.model === 'string' ? input.model : '');
if (!this.isMoonshotProvider(provider)) return configuredModel;
private selectModelCandidates(provider: AiProvider, input: Record<string, unknown>) {
const primaryModel = this.selectModel(provider, input, provider.modelName);
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);
if (hasImages && !this.supportsImageInput(configuredModel)) {
if (hasImages && !this.supportsImageInput(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 configuredModel;
return model;
}
private isMoonshotProvider(provider: AiProvider) {
@@ -134,7 +142,11 @@ export class AiService {
}
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) {
@@ -197,6 +209,51 @@ export class AiService {
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) {
const message =
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',
apiKey: DEMO_AI_API_KEY,
modelName: 'kimi-k2-turbo-preview',
fallbackModelName: 'moonshot-v1-auto',
},
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' },
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: '' },
},
autoInsertFrames: true,
autoInsertDelay: 1,

View File

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

View File

@@ -4,6 +4,7 @@ export const aiProviderSchema = z.object({
endpoint: z.string().default(''),
apiKey: z.string().default(''),
modelName: z.string().default(''),
fallbackModelName: z.string().default(''),
}).passthrough();
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';
const DEFAULT_AI_PROVIDERS = {
kimi: { endpoint: 'https://api.moonshot.cn/v1', apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey, modelName: 'kimi-k2-turbo-preview' },
deepseek: { endpoint: 'https://api.deepseek.com/v1', apiKey: '', modelName: 'deepseek-chat' },
openai: { endpoint: 'https://api.openai.com/v1', apiKey: '', modelName: 'gpt-4o' },
custom: { endpoint: '', apiKey: '', modelName: '' },
kimi: {
endpoint: 'https://api.moonshot.cn/v1',
apiKey: DEMO_SYSTEM_SETTINGS.aiProviders.kimi.apiKey,
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 = {
@@ -267,10 +272,19 @@ export class SettingsService {
}
private normalize(input: SystemSettingsInput): SystemSettingsInput {
const aiProviders = {
...DEFAULT_AI_PROVIDERS,
...(input.aiProviders || {}),
};
const providerKeys = new Set([
...Object.keys(DEFAULT_AI_PROVIDERS),
...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)]
.map((value) => Math.round(value * 100) / 100);