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 ff8f3ace02..1fa3446f41 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 @@ -4,6 +4,7 @@ import { type MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { assertUnreachable } from 'twenty-shared/utils'; +import { getBillingExceptionStatusCode } from 'src/engine/core-modules/billing/utils/get-billing-exception-status-code.util'; import { CustomException } from 'src/utils/custom-exception'; export enum BillingExceptionCode { @@ -107,5 +108,6 @@ export class BillingException extends CustomException { userFriendlyMessage: userFriendlyMessage ?? getBillingExceptionUserFriendlyMessage(code), }); + this.statusCode = getBillingExceptionStatusCode(this); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index d74109dff5..c91cfafae3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -4,6 +4,7 @@ import { convertToModelMessages, hasToolCall, type LanguageModelUsage, + NoOutputGeneratedError, stepCountIs, type StepResult, streamText, @@ -335,6 +336,7 @@ export class ChatExecutionService { let stepStartedAt = streamStartedAt; let ttftRecorded = false; let stepIndex = 0; + let lastUnderlyingStreamError: unknown; const emitTurnUsageEvent = async (steps: StepResult[]) => { const usage = steps.reduce( @@ -482,6 +484,12 @@ export class ChatExecutionService { }); } }, + onError: ({ error }) => { + lastUnderlyingStreamError = error; + this.logger.error( + `Stream ${streamId} emitted an error: ${error instanceof Error ? error.message : String(error)}`, + ); + }, experimental_onToolCallFinish: (event) => { this.metricsService.recordHistogram({ key: MetricsKeys.AiChatToolExecutionDurationMs, @@ -599,6 +607,43 @@ export class ChatExecutionService { if (error?.name === 'AbortError') { return; } + + if ( + error instanceof AiException && + error.code === AiExceptionCode.STREAM_INTERRUPTED + ) { + return; + } + + if (NoOutputGeneratedError.isInstance(error)) { + const underlying = lastUnderlyingStreamError; + + this.exceptionHandlerService.captureExceptions([ + Object.assign( + new Error( + `AI chat stream produced no output. ${JSON.stringify({ + modelId: registeredModel.modelId, + provider: registeredModel.sdkPackage, + workspaceId: workspace.id, + threadId, + streamId, + turnId, + messageCount: messages.length, + conversationSizeTokens, + elapsedMs: Math.round(performance.now() - streamStartedAt), + underlyingError: + underlying instanceof Error + ? `${underlying.name}: ${underlying.message}` + : String(underlying ?? 'none-recorded'), + })}`, + ), + { cause: underlying }, + ), + ]); + + return; + } + this.exceptionHandlerService.captureExceptions([error]); }); diff --git a/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts b/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts index 3e7507a3e9..1154d159f3 100644 --- a/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/utils/global-exception-handler.util.ts @@ -18,7 +18,8 @@ import { TimeoutError, ValidationError, } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { type CustomException } from 'src/utils/custom-exception'; +import { CustomException } from 'src/utils/custom-exception'; +import { isDefined } from 'twenty-shared/utils'; const graphQLPredefinedExceptions = { 400: ValidationError, @@ -62,6 +63,14 @@ export const shouldCaptureException = ( exception: Error, statusCode?: number, ): boolean => { + if ( + exception instanceof CustomException && + isDefined(exception.statusCode) && + exception.statusCode < 500 + ) { + return false; + } + if ( exception instanceof GraphQLError && (exception?.extensions?.http?.status ?? 500) < 500 diff --git a/packages/twenty-server/src/utils/custom-exception.ts b/packages/twenty-server/src/utils/custom-exception.ts index 321dd5b7f8..db473a57e1 100644 --- a/packages/twenty-server/src/utils/custom-exception.ts +++ b/packages/twenty-server/src/utils/custom-exception.ts @@ -22,11 +22,14 @@ export abstract class CustomException< > extends CustomError { code: ExceptionCode; userFriendlyMessage: MessageDescriptor; + statusCode?: number; constructor( message: ExceptionMessage, code: ExceptionCode, - { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + { + userFriendlyMessage, + }: { userFriendlyMessage: MessageDescriptor; statusCode?: number }, ) { super(message); this.code = code;