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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+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);
|
||||
};
|
||||
+5
-3
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<BillingExceptionCode> {
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+7
-6
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+58
@@ -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<Response>();
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+50
-5
@@ -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,
|
||||
|
||||
+1
-1
@@ -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).',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+31
-9
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user