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,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,
@@ -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,
@@ -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).',
);
});
@@ -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,
);
}
}
}