Handle overloaded AI provider responses
- Preserve upstream AI provider HTTP status codes and expose AI-specific error codes for overload, rate limit, unavailable, and generic provider failures. - Add short retries for transient AI provider 429/5xx chat completion responses, with configurable retry delays. - Show friendly AI busy/unavailable messages in the report editor instead of raw provider JSON. - Preserve custom backend error codes in the shared API exception filter. - Add AI service tests for retry behavior and overload error mapping. - Update API, feature, and testing documentation for AI proxy retry and error handling.
This commit is contained in:
@@ -541,6 +541,7 @@ 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`。
|
||||
|
||||
## Speech API
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览,并优先通过 `/api/files` 以 `kind = VIDEO` 写入后端文件资源。 |
|
||||
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG,关键帧优先通过 `/api/files` 以 `kind = FRAME` 写入后端文件资源;百分比支持两位小数并保留配置顺序,实际截图按时间顺序执行,自动插入按配置顺序执行。 |
|
||||
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
|
||||
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
|
||||
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`;上游临时过载时会短暂重试并保留 429/5xx 状态供前端友好提示;AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
|
||||
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
|
||||
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、安全上下文(`localhost` 或 HTTPS)、有效配置和网络;Docker 提供 `https://localhost:4443` 演示入口。 |
|
||||
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |
|
||||
|
||||
@@ -32,7 +32,7 @@ npm run build
|
||||
| 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 |
|
||||
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
|
||||
| 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 |
|
||||
| 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 |
|
||||
| 后端 AI 代理入参和错误处理 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容;上游 429/5xx 会短暂重试,并保留上游状态与 AI 专用错误码。 |
|
||||
| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容,并把上游文本消息按字符串转发给浏览器。 |
|
||||
| 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 |
|
||||
| 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
|
||||
@@ -104,6 +104,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
|
||||
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
|
||||
| 演示模式默认值 | 已覆盖 | `server/src/demo/demo-defaults.test.ts` 覆盖后端默认模板与前端报告编辑器默认内容一致,并校验语音演示配置完整。 |
|
||||
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
|
||||
| 后端 AI 代理重试和错误码 | 已覆盖 | `server/src/ai/ai.service.test.ts` |
|
||||
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
|
||||
| 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` |
|
||||
| 后端文件 schema | 已覆盖 | `server/src/files/files.schemas.test.ts` |
|
||||
|
||||
82
server/src/ai/ai.service.test.ts
Normal file
82
server/src/ai/ai.service.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SafeUser } from '../auth/auth.types';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
const actor: SafeUser = {
|
||||
id: 'user-1',
|
||||
username: 'admin',
|
||||
role: 'super',
|
||||
name: '管理员',
|
||||
tenantId: 'tenant-1',
|
||||
departmentId: 'dept-1',
|
||||
departmentName: '外科',
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const createService = () => {
|
||||
const settingsService = {
|
||||
getSystemSettings: vi.fn().mockResolvedValue({
|
||||
activeAiProvider: 'kimi',
|
||||
aiProviders: {
|
||||
kimi: {
|
||||
endpoint: 'https://provider.example/v1',
|
||||
apiKey: 'test-key',
|
||||
modelName: 'moonshot-v1',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return new AiService(settingsService as never);
|
||||
};
|
||||
|
||||
describe('AiService', () => {
|
||||
const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalRetryDelays === undefined) {
|
||||
delete process.env.AI_PROVIDER_RETRY_DELAYS_MS;
|
||||
} else {
|
||||
process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays;
|
||||
}
|
||||
});
|
||||
|
||||
it('retries transient provider overloads and then returns a completion', 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({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const response = await createService().chat(actor, {
|
||||
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(response).toEqual({ choices: [{ message: { content: '{"reply":"已完善"}' } }] });
|
||||
});
|
||||
|
||||
it('preserves provider overload status and exposes an AI-specific error code', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(createService().chat(actor, {
|
||||
messages: [{ role: 'user', content: '请完善报告内容' }],
|
||||
})).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'AI_PROVIDER_OVERLOADED',
|
||||
message: expect.stringContaining('AI 服务请求失败:429'),
|
||||
}),
|
||||
status: 429,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
|
||||
import type { SafeUser } from '../auth/auth.types.js';
|
||||
import { SettingsService } from '../settings/settings.service.js';
|
||||
import { aiChatSchema } from './ai.schemas.js';
|
||||
@@ -9,6 +9,9 @@ interface AiProvider {
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
|
||||
const DEFAULT_RETRY_DELAYS_MS = [600, 1200];
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
@@ -22,7 +25,7 @@ export class AiService {
|
||||
|
||||
const payload = await this.parseProviderResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(this.formatProviderError(response.status, payload));
|
||||
throw this.createProviderException(response.status, payload);
|
||||
}
|
||||
|
||||
const models = Array.isArray((payload as { data?: unknown[] }).data)
|
||||
@@ -54,7 +57,7 @@ export class AiService {
|
||||
model: provider.modelName || input.model,
|
||||
};
|
||||
|
||||
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
|
||||
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(provider),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -62,7 +65,7 @@ export class AiService {
|
||||
const responsePayload = await this.parseProviderResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
|
||||
throw this.createProviderException(response.status, responsePayload);
|
||||
}
|
||||
|
||||
return responsePayload;
|
||||
@@ -114,6 +117,17 @@ export class AiService {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchProviderWithRetry(url: string, init: RequestInit) {
|
||||
const retryDelays = this.retryDelays();
|
||||
let response = await this.fetchProvider(url, init);
|
||||
for (const delayMs of retryDelays) {
|
||||
if (!RETRYABLE_PROVIDER_STATUSES.has(response.status)) break;
|
||||
await this.sleep(delayMs);
|
||||
response = await this.fetchProvider(url, init);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private formatProviderError(status: number, payload: unknown) {
|
||||
const message =
|
||||
typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
@@ -123,4 +137,47 @@ export class AiService {
|
||||
: JSON.stringify(payload);
|
||||
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
|
||||
}
|
||||
|
||||
private createProviderException(status: number, payload: unknown) {
|
||||
return new HttpException(
|
||||
{
|
||||
code: this.providerErrorCode(status, payload),
|
||||
message: this.formatProviderError(status, payload),
|
||||
},
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
private providerErrorCode(status: number, payload: unknown) {
|
||||
const providerType =
|
||||
typeof payload === 'object' && payload !== null && 'type' in payload
|
||||
? String((payload as { type: unknown }).type)
|
||||
: typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
? this.extractProviderErrorType((payload as { error: unknown }).error)
|
||||
: '';
|
||||
|
||||
if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED';
|
||||
if (status === 429) return 'AI_PROVIDER_RATE_LIMITED';
|
||||
if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE';
|
||||
return 'AI_PROVIDER_ERROR';
|
||||
}
|
||||
|
||||
private extractProviderErrorType(error: unknown) {
|
||||
return typeof error === 'object' && error !== null && 'type' in error
|
||||
? String((error as { type: unknown }).type)
|
||||
: '';
|
||||
}
|
||||
|
||||
private retryDelays() {
|
||||
const raw = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
|
||||
if (!raw) return DEFAULT_RETRY_DELAYS_MS;
|
||||
return raw
|
||||
.split(',')
|
||||
.map((value) => Number(value.trim()))
|
||||
.filter((value) => Number.isFinite(value) && value >= 0);
|
||||
}
|
||||
|
||||
private sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
|
||||
response.status(status).json({
|
||||
error: {
|
||||
code: this.resolveCode(status),
|
||||
code: this.resolveCode(status, payload),
|
||||
message,
|
||||
},
|
||||
requestId: response.getHeader('x-request-id') ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCode(status: number) {
|
||||
private resolveCode(status: number, payload?: unknown) {
|
||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||
const code = (payload as { code?: unknown }).code;
|
||||
if (typeof code === 'string' && code) return code;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case HttpStatus.BAD_REQUEST:
|
||||
return 'BAD_REQUEST';
|
||||
@@ -41,10 +46,14 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
||||
return 'UNAUTHORIZED';
|
||||
case HttpStatus.FORBIDDEN:
|
||||
return 'FORBIDDEN';
|
||||
case HttpStatus.TOO_MANY_REQUESTS:
|
||||
return 'TOO_MANY_REQUESTS';
|
||||
case HttpStatus.NOT_FOUND:
|
||||
return 'NOT_FOUND';
|
||||
case HttpStatus.CONFLICT:
|
||||
return 'CONFLICT';
|
||||
case HttpStatus.SERVICE_UNAVAILABLE:
|
||||
return 'SERVICE_UNAVAILABLE';
|
||||
case HttpStatus.UNPROCESSABLE_ENTITY:
|
||||
return 'VALIDATION_ERROR';
|
||||
default:
|
||||
|
||||
@@ -47,6 +47,21 @@ const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const getAiErrorMessage = (error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 401) return '登录状态已失效,请重新登录后再使用 AI。';
|
||||
if (error.status === 429 || error.code === 'AI_PROVIDER_OVERLOADED' || error.code === 'AI_PROVIDER_RATE_LIMITED') {
|
||||
return 'AI 服务当前繁忙或请求过多,请稍后重试。';
|
||||
}
|
||||
if (error.status >= 500 || error.code === 'AI_PROVIDER_UNAVAILABLE') {
|
||||
return 'AI 服务暂时不可用,请稍后重试或切换其他模型。';
|
||||
}
|
||||
return error.message || 'AI 服务请求失败,请稍后重试。';
|
||||
}
|
||||
if (error instanceof Error) return error.message || 'AI 服务请求失败,请稍后重试。';
|
||||
return 'AI 服务请求失败,请稍后重试。';
|
||||
};
|
||||
|
||||
export default function ReportEditor() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -1428,7 +1443,7 @@ export default function ReportEditor() {
|
||||
setAiSelectedEditorImages([]);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
|
||||
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统提示】: ${getAiErrorMessage(error)}` }]);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user