Add AI fallback model setting
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user