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,22 @@
import { AIChatBanner } from '@/ai/components/AIChatBanner';
import { t } from '@lingui/core/macro';
import { IconExternalLink } from 'twenty-ui/display';
const DOCS_URL =
'https://twenty.com/developers/section/self-hosting/self-hosting-var#ai-features';
export const AIChatApiKeyNotConfiguredMessage = () => {
const handleDocsClick = () => {
window.open(DOCS_URL, '_blank', 'noopener,noreferrer');
};
return (
<AIChatBanner
message={t`AI not configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY in your environment.`}
variant="warning"
buttonTitle={t`View Docs`}
buttonIcon={IconExternalLink}
buttonOnClick={handleDocsClick}
/>
);
};
@@ -0,0 +1,106 @@
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
type IconComponent,
IconAlertTriangle,
IconInfoCircle,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
type AIChatBannerVariant = 'default' | 'warning';
const StyledBanner = styled.div<{ variant: AIChatBannerVariant }>`
align-items: center;
background-color: ${({ theme, variant }) =>
variant === 'warning'
? theme.background.transparent.orange
: theme.accent.secondary};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledIconContainer = styled.div<{ variant: AIChatBannerVariant }>`
align-items: center;
color: ${({ theme, variant }) =>
variant === 'warning' ? theme.color.orange : theme.color.blue};
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
width: 16px;
`;
const StyledMessage = styled.p<{ variant: AIChatBannerVariant }>`
color: ${({ theme, variant }) =>
variant === 'warning' ? theme.color.orange : theme.color.blue};
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: 1.4;
margin: 0;
min-width: 0;
`;
export type AIChatBannerProps = {
message: string;
variant?: AIChatBannerVariant;
tooltipMessage?: string;
buttonTitle?: string;
buttonIcon?: IconComponent;
buttonOnClick?: () => void;
isButtonDisabled?: boolean;
isButtonLoading?: boolean;
};
export const AIChatBanner = ({
message,
variant = 'default',
tooltipMessage,
buttonTitle,
buttonIcon,
buttonOnClick,
isButtonDisabled = false,
isButtonLoading = false,
}: AIChatBannerProps) => {
const tooltipId = 'ai-chat-banner-tooltip';
return (
<StyledBanner
variant={variant}
data-tooltip-id={tooltipMessage ? tooltipId : undefined}
>
<StyledIconContainer variant={variant}>
{variant === 'default' ? (
<IconInfoCircle size={16} />
) : (
<IconAlertTriangle size={16} />
)}
</StyledIconContainer>
<StyledMessage variant={variant}>{message}</StyledMessage>
{isDefined(buttonTitle) && isDefined(buttonOnClick) && (
<Button
variant="secondary"
size="small"
Icon={buttonIcon}
onClick={buttonOnClick}
disabled={isButtonDisabled || isButtonLoading}
title={buttonTitle}
/>
)}
{isDefined(tooltipMessage) && (
<AppTooltip
anchorSelect={`[data-tooltip-id='${tooltipId}']`}
content={tooltipMessage}
place="bottom"
/>
)}
</StyledBanner>
);
};
@@ -0,0 +1,84 @@
import { AIChatBanner } from '@/ai/components/AIChatBanner';
import { useEndSubscriptionTrialPeriod } from '@/billing/hooks/useEndSubscriptionTrialPeriod';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconSparkles } from 'twenty-ui/display';
import {
PermissionFlagType,
SubscriptionStatus,
useBillingPortalSessionQuery,
} from '~/generated-metadata/graphql';
export const AIChatCreditsExhaustedMessage = () => {
const { redirect } = useRedirect();
const subscriptionStatus = useSubscriptionStatus();
const { endTrialPeriod, isLoading: isEndingTrial } =
useEndSubscriptionTrialPeriod();
const [isProcessing, setIsProcessing] = useState(false);
const isTrialing = subscriptionStatus === SubscriptionStatus.Trialing;
const { [PermissionFlagType.WORKSPACE]: hasPermissionToManageBilling } =
usePermissionFlagMap();
const { data: billingPortalData, loading: isBillingPortalLoading } =
useBillingPortalSessionQuery({
variables: {
returnUrlPath: getSettingsPath(SettingsPath.Billing),
},
});
const openBillingPortal = () => {
if (
isDefined(billingPortalData) &&
isDefined(billingPortalData.billingPortalSession.url)
) {
redirect(billingPortalData.billingPortalSession.url);
}
};
const handleUpgradeClick = async () => {
if (!isTrialing) {
openBillingPortal();
return;
}
setIsProcessing(true);
const result = await endTrialPeriod();
setIsProcessing(false);
// If no payment method, redirect to billing portal to add one
if (!result.success) {
openBillingPortal();
}
};
const isLoading = isEndingTrial || isBillingPortalLoading || isProcessing;
const message = hasPermissionToManageBilling
? isTrialing
? t`Free trial credits exhausted. Subscribe now to continue using AI features.`
: t`Credits exhausted. Upgrade your plan to get more credits.`
: t`Credits exhausted. Please contact your workspace admin to upgrade.`;
const buttonTitle = isTrialing ? t`Subscribe Now` : t`Upgrade Plan`;
return (
<AIChatBanner
message={message}
variant="warning"
buttonTitle={hasPermissionToManageBilling ? buttonTitle : undefined}
buttonIcon={IconSparkles}
buttonOnClick={
hasPermissionToManageBilling ? handleUpgradeClick : undefined
}
isButtonDisabled={isLoading}
isButtonLoading={isLoading}
/>
);
};
@@ -0,0 +1,21 @@
import { AIChatApiKeyNotConfiguredMessage } from '@/ai/components/AIChatApiKeyNotConfiguredMessage';
import { AIChatCreditsExhaustedMessage } from '@/ai/components/AIChatCreditsExhaustedMessage';
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
import { isApiKeyNotConfiguredError } from '@/ai/utils/isApiKeyNotConfiguredError';
import { isBillingCreditsExhaustedError } from '@/ai/utils/isBillingCreditsExhaustedError';
type AIChatErrorRendererProps = {
error: Error;
};
export const AIChatErrorRenderer = ({ error }: AIChatErrorRendererProps) => {
if (isBillingCreditsExhaustedError(error)) {
return <AIChatCreditsExhaustedMessage />;
}
if (isApiKeyNotConfiguredError(error)) {
return <AIChatApiKeyNotConfiguredMessage />;
}
return <AIChatErrorMessage error={error} />;
};
@@ -7,7 +7,7 @@ import { AgentChatFilePreview } from '@/ai/components/internal/AgentChatFilePrev
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
@@ -194,7 +194,7 @@ export const AIChatMessage = ({
))}
</StyledFilesContainer>
)}
{showError && <AIChatErrorMessage error={error} />}
{showError && <AIChatErrorRenderer error={error} />}
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
@@ -0,0 +1,55 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, IconSparkles } from 'twenty-ui/display';
import { AIChatErrorRenderer } from '@/ai/components/AIChatErrorRenderer';
const StyledErrorContainer = styled.div`
display: flex;
flex-direction: row;
align-items: flex-start;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledAvatarContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledContent = styled.div`
min-width: 0;
width: 100%;
`;
type AIChatStandaloneErrorProps = {
error: Error;
};
export const AIChatStandaloneError = ({
error,
}: AIChatStandaloneErrorProps) => {
const theme = useTheme();
return (
<StyledErrorContainer>
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
<StyledContent>
<AIChatErrorRenderer error={error} />
</StyledContent>
</StyledErrorContainer>
);
};
@@ -9,8 +9,10 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
@@ -93,7 +95,9 @@ export const AIChatTab = () => {
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastMessageStreaming = isStreaming && isLastMessage;
const shouldShowError = error && isLastMessage;
const isLastAssistantMessage =
isLastMessage && message.role === AgentMessageRole.ASSISTANT;
const shouldShowError = error && isLastAssistantMessage;
return (
<AIChatMessage
@@ -104,9 +108,17 @@ export const AIChatTab = () => {
/>
);
})}
{error &&
!isStreaming &&
messages.at(-1)?.role === AgentMessageRole.USER && (
<AIChatStandaloneError error={error} />
)}
</StyledScrollWrapper>
)}
{messages.length === 0 && <AIChatEmptyState />}
{messages.length === 0 && !error && <AIChatEmptyState />}
{messages.length === 0 && error && !isLoading && (
<AIChatStandaloneError error={error} />
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
@@ -89,13 +89,27 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
fetch: async (input, init) => {
const response = await fetch(input, init);
if (response.status !== 401) {
return response;
if (response.status === 401) {
const retriedResponse = await retryFetchWithRenewedToken(input, init);
return retriedResponse ?? response;
}
const retriedResponse = await retryFetchWithRenewedToken(input, init);
// For non-2xx responses, parse the error body and throw with the code
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
const error = new Error(
errorBody.messages?.[0] ||
`Request failed with status ${response.status}`,
) as Error & { code?: string };
return retriedResponse ?? response;
if (isDefined(errorBody.code)) {
error.code = errorBody.code;
}
throw error;
}
return response;
},
}),
messages: uiMessages,
@@ -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);
};
@@ -17,10 +17,12 @@ export const InformationBannerEndTrialPeriod = () => {
variant="danger"
message={
hasPermissionToEndTrialPeriod
? t`No free workflow executions left. End trial period and activate your billing to continue.`
: t`No free workflow executions left. Please contact your admin.`
? t`End trial period to continue using Workflow or AI features.`
: t`Contact your admin to continue using Workflow or AI features.`
}
buttonTitle={
hasPermissionToEndTrialPeriod ? t`End Trial Period` : undefined
}
buttonTitle={hasPermissionToEndTrialPeriod ? t`Activate` : undefined}
buttonOnClick={async () => await endTrialPeriod()}
isButtonDisabled={isLoading}
/>