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
@@ -0,0 +1,8 @@
// Error codes matching backend AgentExceptionCode and BillingExceptionCode
export const AIChatErrorCode = {
BILLING_CREDITS_EXHAUSTED: 'BILLING_CREDITS_EXHAUSTED',
API_KEY_NOT_CONFIGURED: 'API_KEY_NOT_CONFIGURED',
} as const;
export type AIChatErrorCodeType =
(typeof AIChatErrorCode)[keyof typeof AIChatErrorCode];
@@ -0,0 +1,66 @@
import { extractErrorCode } from '@/ai/utils/extractErrorCode';
describe('extractErrorCode', () => {
describe('direct error code', () => {
it('should extract code from error with direct code property', () => {
const error = { code: 'BILLING_CREDITS_EXHAUSTED', message: 'test' };
expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
});
it('should extract code from Error object with code property', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
});
});
describe('nested error structure', () => {
it('should extract code from nested error structure', () => {
const error = {
error: { code: 'BILLING_CREDITS_EXHAUSTED' },
};
expect(extractErrorCode(error)).toBe('BILLING_CREDITS_EXHAUSTED');
});
it('should extract code from deeply nested error structure', () => {
const error = {
data: {
error: { code: 'API_KEY_NOT_CONFIGURED' },
},
};
expect(extractErrorCode(error)).toBe('API_KEY_NOT_CONFIGURED');
});
});
describe('invalid inputs', () => {
it('should return undefined for null', () => {
expect(extractErrorCode(null)).toBeUndefined();
});
it('should return undefined for undefined', () => {
expect(extractErrorCode(undefined)).toBeUndefined();
});
it('should return undefined for error without code', () => {
const error = { message: 'test error' };
expect(extractErrorCode(error)).toBeUndefined();
});
it('should return undefined for error with non-string code', () => {
const error = { code: 123 };
expect(extractErrorCode(error)).toBeUndefined();
});
it('should return undefined for string input', () => {
expect(extractErrorCode('error string')).toBeUndefined();
});
it('should return undefined for number input', () => {
expect(extractErrorCode(42)).toBeUndefined();
});
it('should return undefined for empty object', () => {
expect(extractErrorCode({})).toBeUndefined();
});
});
});
@@ -0,0 +1,60 @@
import { AIChatErrorCode } from '@/ai/utils/AIChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
describe('isAIChatErrorOfType', () => {
describe('matching error codes', () => {
it('should return true when error code matches BILLING_CREDITS_EXHAUSTED', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(true);
});
it('should return true when error code matches API_KEY_NOT_CONFIGURED', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED),
).toBe(true);
});
});
describe('non-matching error codes', () => {
it('should return false when error code does not match', () => {
const error = new Error('test') as Error & { code: string };
error.code = 'SOME_OTHER_ERROR';
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
it('should return false when error has no code', () => {
const error = new Error('test');
expect(
isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
});
describe('null and undefined handling', () => {
it('should return false for null error', () => {
expect(
isAIChatErrorOfType(null, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED),
).toBe(false);
});
it('should return false for undefined error', () => {
expect(
isAIChatErrorOfType(
undefined,
AIChatErrorCode.BILLING_CREDITS_EXHAUSTED,
),
).toBe(false);
});
});
});
@@ -0,0 +1,31 @@
import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
describe('isApiKeyNotConfiguredError', () => {
it('should return true for API key not configured error', () => {
const error = new Error('API key not set') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(isApiKeyNotConfiguredError(error)).toBe(true);
});
it('should return false for billing credits exhausted error', () => {
const error = new Error('Credits exhausted') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(isApiKeyNotConfiguredError(error)).toBe(false);
});
it('should return false for generic error', () => {
const error = new Error('Something went wrong');
expect(isApiKeyNotConfiguredError(error)).toBe(false);
});
it('should return false for null', () => {
expect(isApiKeyNotConfiguredError(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isApiKeyNotConfiguredError(undefined)).toBe(false);
});
});
@@ -0,0 +1,31 @@
import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
describe('isBillingCreditsExhaustedError', () => {
it('should return true for billing credits exhausted error', () => {
const error = new Error('Credits exhausted') as Error & { code: string };
error.code = 'BILLING_CREDITS_EXHAUSTED';
expect(isBillingCreditsExhaustedError(error)).toBe(true);
});
it('should return false for API key not configured error', () => {
const error = new Error('API key not set') as Error & { code: string };
error.code = 'API_KEY_NOT_CONFIGURED';
expect(isBillingCreditsExhaustedError(error)).toBe(false);
});
it('should return false for generic error', () => {
const error = new Error('Something went wrong');
expect(isBillingCreditsExhaustedError(error)).toBe(false);
});
it('should return false for null', () => {
expect(isBillingCreditsExhaustedError(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isBillingCreditsExhaustedError(undefined)).toBe(false);
});
});
@@ -0,0 +1,53 @@
import { isDefined } from 'twenty-shared/utils';
// Type guard for error objects with a code property
const isErrorWithCode = (
error: unknown,
): error is { code: string; message?: string } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string'
);
};
// Type guard for nested error structures (e.g., { error: { code: '...' } })
const isNestedErrorWithCode = (
error: unknown,
): error is { error: { code: string } } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'error' in error &&
isErrorWithCode((error as { error: unknown }).error)
);
};
// Type guard for deeply nested error structures (e.g., { data: { error: { code: '...' } } })
const isDeepNestedErrorWithCode = (
error: unknown,
): error is { data: { error: { code: string } } } => {
return (
isDefined(error) &&
typeof error === 'object' &&
'data' in error &&
isNestedErrorWithCode((error as { data: unknown }).data)
);
};
export const extractErrorCode = (error: unknown): string | undefined => {
if (isErrorWithCode(error)) {
return error.code;
}
if (isNestedErrorWithCode(error)) {
return error.error.code;
}
if (isDeepNestedErrorWithCode(error)) {
return error.data.error.code;
}
return undefined;
};
@@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { type AIChatErrorCodeType } from '@/ai/utils/AIChatErrorCode';
import { extractErrorCode } from '@/ai/utils/extractErrorCode';
export const isAIChatErrorOfType = (
error: Error | null | undefined,
errorCode: AIChatErrorCodeType,
): boolean => {
if (!isDefined(error)) {
return false;
}
return extractErrorCode(error) === errorCode;
};
@@ -0,0 +1,8 @@
import { AIChatErrorCode } from '@/ai/utils/AIChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
export const isApiKeyNotConfiguredError = (
error: Error | null | undefined,
): boolean => {
return isAIChatErrorOfType(error, AIChatErrorCode.API_KEY_NOT_CONFIGURED);
};
@@ -0,0 +1,8 @@
import { AIChatErrorCode } from '@/ai/utils/AIChatErrorCode';
import { isAIChatErrorOfType } from '@/ai/utils/isAIChatErrorOfType';
export const isBillingCreditsExhaustedError = (
error: Error | null | undefined,
): boolean => {
return isAIChatErrorOfType(error, AIChatErrorCode.BILLING_CREDITS_EXHAUSTED);
};