Add AI fallback model setting
This commit is contained in:
@@ -541,7 +541,8 @@ pageSize?: number
|
||||
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
|
||||
- 不允许跨部门检索报告作为上下文。
|
||||
- 当前实现接收 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface AiModelsResponse {
|
||||
provider: {
|
||||
endpoint: string;
|
||||
modelName: string;
|
||||
fallbackModelName?: string;
|
||||
};
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,19 @@ const normalizeSettings = (
|
||||
input: Partial<ISystemSettings & { frameMode?: 'uniform' | 'keep' }>,
|
||||
templates: Template[],
|
||||
): ISystemSettings & { frameMode?: 'uniform' | 'keep' } => {
|
||||
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] || DEFAULT_AI_PROVIDERS.custom),
|
||||
...(input.aiProviders?.[key] || {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const framePositions = normalizeFramePositions(input.framePositions, DEFAULT_FRAME_POSITIONS);
|
||||
|
||||
return {
|
||||
@@ -96,7 +105,8 @@ export default function SystemSettings() {
|
||||
providers.kimi = {
|
||||
endpoint: (savedSettings as any).kimiApiEndpoint || providers.kimi.endpoint,
|
||||
apiKey: (savedSettings as any).kimiApiKey || '',
|
||||
modelName: 'kimi-k2-turbo-preview'
|
||||
modelName: 'kimi-k2-turbo-preview',
|
||||
fallbackModelName: 'moonshot-v1-auto',
|
||||
};
|
||||
}
|
||||
savedSettings.aiProviders = providers;
|
||||
@@ -443,7 +453,7 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<select
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
14
src/types.ts
14
src/types.ts
@@ -84,6 +84,7 @@ export interface AiProviderConfig {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
fallbackModelName?: string;
|
||||
}
|
||||
|
||||
export interface XfSpeechConfig {
|
||||
@@ -112,10 +113,15 @@ export interface SystemSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_PROVIDERS: Record<string, AiProviderConfig> = {
|
||||
kimi: { endpoint: 'https://api.moonshot.cn/v1', 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: '',
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user