diff --git a/packages/twenty-front/src/modules/ai/components/AIChatApiKeyNotConfiguredMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatApiKeyNotConfiguredMessage.tsx
new file mode 100644
index 00000000000..df26fc79547
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatApiKeyNotConfiguredMessage.tsx
@@ -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 (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatBanner.tsx b/packages/twenty-front/src/modules/ai/components/AIChatBanner.tsx
new file mode 100644
index 00000000000..dce8ea73e4f
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatBanner.tsx
@@ -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 (
+
+
+ {variant === 'default' ? (
+
+ ) : (
+
+ )}
+
+ {message}
+ {isDefined(buttonTitle) && isDefined(buttonOnClick) && (
+
+ )}
+ {isDefined(tooltipMessage) && (
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx
new file mode 100644
index 00000000000..c97e3b98468
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatCreditsExhaustedMessage.tsx
@@ -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 (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx
new file mode 100644
index 00000000000..139d84de1a5
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatErrorRenderer.tsx
@@ -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 ;
+ }
+
+ if (isApiKeyNotConfiguredError(error)) {
+ return ;
+ }
+
+ return ;
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
index 32bac32251e..282703f3dd2 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx
@@ -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 = ({
))}
)}
- {showError && }
+ {showError && }
{message.parts.length > 0 && message.metadata?.createdAt && (
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx
new file mode 100644
index 00000000000..3b10da24dc6
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/components/AIChatStandaloneError.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
index 718343353d0..e5294d2bc36 100644
--- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
+++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx
@@ -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 (
{
/>
);
})}
+ {error &&
+ !isStreaming &&
+ messages.at(-1)?.role === AgentMessageRole.USER && (
+
+ )}
)}
- {messages.length === 0 && }
+ {messages.length === 0 && !error && }
+ {messages.length === 0 && error && !isLoading && (
+
+ )}
{isLoading && messages.length === 0 && }
diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
index 3df36fc9020..105abc3c0a2 100644
--- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
+++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts
@@ -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,
diff --git a/packages/twenty-front/src/modules/ai/utils/AIChatErrorCode.ts b/packages/twenty-front/src/modules/ai/utils/AIChatErrorCode.ts
new file mode 100644
index 00000000000..dc8af19d9cf
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/AIChatErrorCode.ts
@@ -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];
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts
new file mode 100644
index 00000000000..6ac10d48daa
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/extractErrorCode.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts
new file mode 100644
index 00000000000..c64d5739e04
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/isAIChatErrorOfType.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts
new file mode 100644
index 00000000000..7375bf172fa
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/isApiKeyNotConfiguredError.test.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts
new file mode 100644
index 00000000000..2329c91e04b
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/__tests__/isBillingCreditsExhaustedError.test.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts b/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts
new file mode 100644
index 00000000000..d500de6be57
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/extractErrorCode.ts
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts b/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts
new file mode 100644
index 00000000000..a57a8714225
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/isAIChatErrorOfType.ts
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts b/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts
new file mode 100644
index 00000000000..6ff60fa618e
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/isApiKeyNotConfiguredError.ts
@@ -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);
+};
diff --git a/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts b/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts
new file mode 100644
index 00000000000..11ed65d9f0d
--- /dev/null
+++ b/packages/twenty-front/src/modules/ai/utils/isBillingCreditsExhaustedError.ts
@@ -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);
+};
diff --git a/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
index 7d8245686b5..16231a2e184 100644
--- a/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/billing/InformationBannerEndTrialPeriod.tsx
@@ -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}
/>
diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
index 939f8bbc153..e20c7c81f93 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts
@@ -30,6 +30,7 @@ export enum BillingExceptionCode {
BILLING_PRICE_INVALID = 'BILLING_PRICE_INVALID',
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND = 'BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND',
+ BILLING_CREDITS_EXHAUSTED = 'BILLING_CREDITS_EXHAUSTED',
}
const billingExceptionUserFriendlyMessages: Record<
@@ -60,6 +61,7 @@ const billingExceptionUserFriendlyMessages: Record<
[BillingExceptionCode.BILLING_PRICE_INVALID]: msg`Invalid price.`,
[BillingExceptionCode.BILLING_SUBSCRIPTION_PHASE_NOT_FOUND]: msg`Subscription phase not found.`,
[BillingExceptionCode.BILLING_TOO_MUCH_SUBSCRIPTIONS_FOUND]: msg`Multiple subscriptions found where one was expected.`,
+ [BillingExceptionCode.BILLING_CREDITS_EXHAUSTED]: msg`You have exhausted your credits. Please upgrade your plan to continue.`,
};
export class BillingException extends CustomException {
diff --git a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
index 2f69004229a..c416caeafc6 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/filters/billing-api-exception.filter.ts
@@ -66,6 +66,12 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
response,
400,
);
+ case BillingExceptionCode.BILLING_CREDITS_EXHAUSTED:
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ 402,
+ );
default:
return this.httpExceptionHandlerService.handleError(
exception,
diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
index 55925d6d462..42f826fbde2 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts
@@ -3,12 +3,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { isDefined } from 'class-validator';
-import { Repository } from 'typeorm';
+import { isDefined } from 'twenty-shared/utils';
+import { type Repository } from 'typeorm';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
-import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
+import { type BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
diff --git a/packages/twenty-server/src/engine/core-modules/exception-handler/http-exception-handler.service.ts b/packages/twenty-server/src/engine/core-modules/exception-handler/http-exception-handler.service.ts
index 69738355119..f064c582063 100644
--- a/packages/twenty-server/src/engine/core-modules/exception-handler/http-exception-handler.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/exception-handler/http-exception-handler.service.ts
@@ -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;
}
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>,
+ response: Response,
errorCode?: number,
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ): Response> | 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,
});
};
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts
new file mode 100644
index 00000000000..0e02a0989e1
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts
@@ -0,0 +1,58 @@
+import {
+ type ArgumentsHost,
+ Catch,
+ type ExceptionFilter,
+} from '@nestjs/common';
+
+import type { Response } from 'express';
+
+import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
+import {
+ AgentException,
+ AgentExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
+
+@Catch(AgentException)
+export class AgentRestApiExceptionFilter implements ExceptionFilter {
+ constructor(
+ private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
+ ) {}
+
+ catch(exception: AgentException, host: ArgumentsHost) {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+
+ switch (exception.code) {
+ case AgentExceptionCode.AGENT_NOT_FOUND:
+ case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND:
+ case AgentExceptionCode.ROLE_NOT_FOUND:
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ 404,
+ );
+ case AgentExceptionCode.API_KEY_NOT_CONFIGURED:
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ 503, // Service Unavailable - the AI service is not configured
+ );
+ case AgentExceptionCode.AGENT_EXECUTION_FAILED:
+ case AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
+ case AgentExceptionCode.INVALID_AGENT_INPUT:
+ case AgentExceptionCode.AGENT_ALREADY_EXISTS:
+ case AgentExceptionCode.AGENT_IS_STANDARD:
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ 400,
+ );
+ default:
+ return this.httpExceptionHandlerService.handleError(
+ exception,
+ response,
+ 500,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
index aad174d5dab..ec5f6cedb39 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
+import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -37,6 +38,7 @@ import { ChatExecutionService } from './services/chat-execution.service';
UserWorkspaceEntity,
]),
AiAgentExecutionModule,
+ BillingModule,
ThrottlerModule,
FeatureFlagModule,
FileUploadModule,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts
index 60355ca2694..19bcbc10806 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts
@@ -7,26 +7,48 @@ import {
UseGuards,
} from '@nestjs/common';
-import { Response } from 'express';
-import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { PermissionFlagType } from 'twenty-shared/constants';
+import type { Response } from 'express';
+import type { ExtendedUIMessage } from 'twenty-shared/ai';
+
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
-import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import {
+ BillingException,
+ BillingExceptionCode,
+} from 'src/engine/core-modules/billing/billing.exception';
+import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
+import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
+import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
+import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
-import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
+import {
+ AgentException,
+ AgentExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
+import { AgentRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter';
+import type { BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
+import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Controller('rest/agent-chat')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
-@UseFilters(RestApiExceptionFilter)
+@UseFilters(
+ AgentRestApiExceptionFilter,
+ BillingRestApiExceptionFilter,
+ RestApiExceptionFilter,
+)
export class AgentChatController {
constructor(
private readonly agentStreamingService: AgentChatStreamingService,
+ private readonly billingService: BillingService,
+ private readonly twentyConfigService: TwentyConfigService,
+ private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@Post('stream')
@@ -42,6 +64,29 @@ export class AgentChatController {
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() response: Response,
) {
+ const availableModels = this.aiModelRegistryService.getAvailableModels();
+
+ if (availableModels.length === 0) {
+ throw new AgentException(
+ 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
+ }
+
+ if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
+ const canBill = await this.billingService.canBillMeteredProduct(
+ workspace.id,
+ BillingProductKey.WORKFLOW_NODE_EXECUTION,
+ );
+
+ if (!canBill) {
+ throw new BillingException(
+ 'Credits exhausted',
+ BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
+ );
+ }
+ }
+
this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
messages: body.messages,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts
index daf1b407e76..1e74c18bd7a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts
@@ -89,7 +89,7 @@ describe('AiModelRegistryService', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
- 'No AI models are available. Please configure at least one provider.',
+ 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
);
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts
index e851beab9e4..cfc445e78fd 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts
@@ -6,6 +6,10 @@ import { xai } from '@ai-sdk/xai';
import { type LanguageModel } from 'ai';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
+import {
+ AgentException,
+ AgentExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
@@ -164,6 +168,13 @@ export class AiModelRegistryService {
model = availableModels[0];
}
+ if (!model) {
+ throw new AgentException(
+ 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
+ }
+
return model;
}
@@ -179,22 +190,24 @@ export class AiModelRegistryService {
model = availableModels[0];
}
+ if (!model) {
+ throw new AgentException(
+ 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
+ }
+
return model;
}
getEffectiveModelConfig(modelId: string): AIModelConfig {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
+ // getDefaultSpeedModel/getDefaultPerformanceModel will throw AgentException if no models available
const defaultModel =
modelId === DEFAULT_FAST_MODEL
? this.getDefaultSpeedModel()
: this.getDefaultPerformanceModel();
- if (!defaultModel) {
- throw new Error(
- 'No AI models are available. Please configure at least one provider.',
- );
- }
-
const modelConfig = AI_MODELS.find(
(model) => model.modelId === defaultModel.modelId,
);
@@ -220,7 +233,10 @@ export class AiModelRegistryService {
return this.createDefaultConfigForCustomModel(registeredModel);
}
- throw new Error(`Model with ID ${modelId} not found`);
+ throw new AgentException(
+ `Model with ID ${modelId} not found`,
+ AgentExceptionCode.AGENT_EXECUTION_FAILED,
+ );
}
private createDefaultConfigForCustomModel(
@@ -252,7 +268,10 @@ export class AiModelRegistryService {
const registeredModel = this.getModel(aiModel.modelId);
if (!registeredModel) {
- throw new Error(`Model ${aiModel.modelId} not found in registry`);
+ throw new AgentException(
+ `Model ${aiModel.modelId} not found in registry`,
+ AgentExceptionCode.AGENT_EXECUTION_FAILED,
+ );
}
return registeredModel;
@@ -279,7 +298,10 @@ export class AiModelRegistryService {
}
if (!apiKey) {
- throw new Error(`${provider.toUpperCase()} API key not configured`);
+ throw new AgentException(
+ `${provider.toUpperCase()} API key not configured. Please set the appropriate environment variable.`,
+ AgentExceptionCode.API_KEY_NOT_CONFIGURED,
+ );
}
}
}