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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
@@ -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);
|
||||
});
|
||||
});
|
||||
+31
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user