feat: add AI chat error handling for billing and API key errors (#16797)

## Summary

This PR adds user-friendly error handling for AI chat features,
specifically for **billing credits exhausted** and **API key not
configured** errors.

## Changes

### Backend
- Added `BILLING_CREDITS_EXHAUSTED` exception code with 402 status
- Added `API_KEY_NOT_CONFIGURED` exception code with 503 status
- Added billing check before AI chat streaming in
`agent-chat.controller.ts`
- Added error code to HTTP exception response body for frontend error
type detection
- Created `AgentRestApiExceptionFilter` for agent-specific errors

### Frontend
- Created `AIChatBanner` - reusable banner component for error/warning
messages
- Created `AIChatCreditsExhaustedMessage` - shows upgrade prompts based
on user permissions
- Created `AIChatApiKeyNotConfiguredMessage` - shows configuration
guidance with docs link
- Created `AIChatErrorRenderer` - encapsulates error type switching
logic (fixes nested ternary)
- Created `AIChatStandaloneError` - displays errors when there are no
messages
- Split `aiChatErrorUtils.ts` into separate files (1 export per file):
  - `AIChatErrorCode.ts`
  - `extractErrorCode.ts`
  - `isAIChatErrorOfType.ts`
  - `isBillingCreditsExhaustedError.ts`
  - `isApiKeyNotConfiguredError.ts`
- Added comprehensive test coverage (27 tests)

### Other
- Updated trial period banner messaging

## Testing
- All lint checks pass
- All 27 new tests pass
- TypeScript typecheck passes
This commit is contained in:
Félix Malfait
2025-12-24 15:30:28 +01:00
committed by GitHub
parent bc0ffc98bb
commit b46e9d2e64
27 changed files with 767 additions and 35 deletions
@@ -21,11 +21,11 @@ import {
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { handleException } from 'src/engine/utils/global-exception-handler.util';
import { CustomException } from 'src/utils/custom-exception';
interface RequestAndParams {
request: Request | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: any;
params: Record<string, string | undefined>;
}
const getErrorNameFromStatusCode = (statusCode: number) => {
@@ -34,6 +34,8 @@ const getErrorNameFromStatusCode = (statusCode: number) => {
return 'BadRequestException';
case 401:
return 'UnauthorizedException';
case 402:
return 'PaymentRequiredException';
case 403:
return 'ForbiddenException';
case 404:
@@ -66,13 +68,11 @@ export class HttpExceptionHandlerService {
handleError = (
exception: Error | HttpException,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response: Response<any, Record<string, any>>,
response: Response,
errorCode?: number,
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Response<any, Record<string, any>> | undefined => {
): Response | undefined => {
const params = this.request?.params;
if (params?.workspaceId) {
@@ -121,6 +121,7 @@ export class HttpExceptionHandlerService {
statusCode,
error: exception.name ?? getErrorNameFromStatusCode(statusCode),
messages: [exception?.message],
code: exception instanceof CustomException ? exception.code : undefined,
});
};
}