Compare commits

...

1 Commits

Author SHA1 Message Date
Félix Malfait 85effd6750 fix(billing): gate AI credit-cap at entry points instead of workflow executor
Restores the credit-cap enforcement that was lifted in #19904 (which had
correctly identified that gating per-workflow-step was too coarse), but
moves it to the AI entry points where the actual cost lives.

- agent-async-executor.service: gate executeAgent.
- ai-generate-text.controller: gate the REST handler.
- agent-title-generation.service: gate generateThreadTitle.
- workflow-executor.workspace-service: replace #19904's TODO with an
  absolute-behavior comment documenting why the gate is not here.

Chat resolver was already gated; no change there. Repair-tool-call util
is a sub-call inside an already-gated flow and is left ungated.

Net effect: a workspace that exhausts credits via chat or AI agent stops
making AI calls, but its non-AI workflows (DB CRUD, branching, action
steps) continue running normally.

Addresses the root cause of the 2026-04-26 token-usage incident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:52:00 +02:00
7 changed files with 90 additions and 7 deletions
@@ -1,6 +1,7 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
@@ -27,6 +28,7 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
AiBillingModule,
AiModelsModule,
AiAgentModule,
BillingModule,
FileUrlModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
@@ -14,9 +14,16 @@ import { type Repository } from 'typeorm';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
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 { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
@@ -49,6 +56,8 @@ export class AgentAsyncExecutorService {
private readonly aiModelConfigService: AiModelConfigService,
private readonly toolRegistry: ToolRegistryService,
private readonly nativeToolBinder: NativeToolBinderService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@InjectRepository(WorkspaceEntity)
@@ -113,6 +122,20 @@ export class AgentAsyncExecutorService {
rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
}): Promise<AgentExecutionResult> {
if (agent && this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
agent.workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
try {
if (agent) {
const workspace = await this.workspaceRepository.findOneBy({
@@ -328,8 +328,10 @@ export class AgentChatService {
return null;
}
const title =
await this.titleGenerationService.generateThreadTitle(messageContent);
const title = await this.titleGenerationService.generateThreadTitle(
messageContent,
workspaceId,
);
await this.threadRepository.update(threadId, { title });
@@ -2,6 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { generateText } from 'ai';
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 { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@@ -11,9 +18,28 @@ export class AgentTitleGenerationService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateThreadTitle(messageContent: string): Promise<string> {
async generateThreadTitle(
messageContent: string,
workspaceId: string,
): Promise<string> {
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspaceId,
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!canBill) {
throw new BillingException(
'Credits exhausted',
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
);
}
}
try {
const defaultModel = this.aiModelRegistryService.getDefaultSpeedModel();
@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { AiGenerateTextController } from './controllers/ai-generate-text.controller';
@Module({
imports: [TokenModule, WorkspaceCacheStorageModule, PermissionsModule],
imports: [
TokenModule,
WorkspaceCacheStorageModule,
PermissionsModule,
BillingModule,
],
controllers: [AiGenerateTextController],
})
export class AiGenerateTextModule {}
@@ -4,6 +4,13 @@ import { generateText } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
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 { 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 { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
@@ -23,6 +30,8 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
export class AiGenerateTextController {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly billingService: BillingService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Post('generate-text')
@@ -38,6 +47,20 @@ export class AiGenerateTextController {
);
}
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,
);
}
}
const resolvedModelId = body.modelId ?? workspace.fastModel;
this.aiModelRegistryService.validateModelAvailability(
@@ -453,9 +453,10 @@ export class WorkflowExecutorWorkspaceService {
workflowRunId: string;
workspaceId: string;
}) {
// TODO: re-enable workflow node execution credit cap once billing limits are revisited.
// Previously gated on BillingService.canBillMeteredProduct(WORKFLOW_NODE_EXECUTION);
// temporarily disabled so workflows keep running when the period cap is reached.
// Credit-cap enforcement lives at the AI entry points (chat resolver,
// executeAgent, generate-text controller, title generation). Cheap
// workflow steps (DB CRUD, branching, actions) are not gated here so a
// chat-driven cap exhaustion does not block non-AI automations.
const stepId = step.id;
const workflowAction = this.workflowActionFactory.get(step.type);