fix(billing): gate AI credit-cap at entry points instead of workflow executor (#20096)
## Background The 2026-04-26 incident saw 716M Sonnet 4.6 tokens consumed in a single trial workspace. Two causes: failed agent executions weren't billed (addressed by #20065) and the credit-cap gate had been removed from `WorkflowExecutorWorkspaceService.executeStep` in #19904, leaving no enforcement point at all. ## Why not just revert #19904 #19904 was right that gating at the workflow executor is too coarse. When one user exhausted a workspace's credits via chat, *all* workflows hard-failed mid-run — including cheap DB/CRUD/branch automations costing essentially nothing. Reverting would re-introduce that cliff. ## New design: gate at the AI entry points The chat resolver already gates this way (`agent-chat.resolver.ts:137-148`). This PR replicates the same pattern at every other point where the workspace can incur real AI cost: - `executeAgent` in `agent-async-executor.service.ts` - the REST handler in `ai-generate-text.controller.ts` - `generateThreadTitle` in `agent-title-generation.service.ts` In each, after auth/validation: skip if `IS_BILLING_ENABLED` is false; otherwise call `BillingService.canBillMeteredProduct(workspaceId, BillingProductKey.WORKFLOW_NODE_EXECUTION)`; on `false`, throw `BillingException(BILLING_CREDITS_EXHAUSTED)`. No new method, no new exception code, no new product key. This matches industry convention (Lovable/Replit also gate at the expensive-operation boundary, not at every cheap step). ## Deliberately not gated - `WorkflowExecutorWorkspaceService.executeStep` — the design choice is now intentional, so the #19904 TODO is replaced by a one-line absolute-behavior comment explaining why the gate isn't here. Cheap workflow steps (DB CRUD, branching, action steps) are not gated, so a chat-driven cap exhaustion does not block non-AI automations. - `repair-tool-call.util` — repair is a sub-call inside an already-gated AI flow. If the parent is gated, repair will naturally not run. Adding a gate here adds complexity without value. ## Net effect A workspace that exhausts credits via chat or AI agent stops making AI calls. Its non-AI workflows continue running normally. A workflow with both AI and non-AI steps fails at the AI step with `BILLING_CREDITS_EXHAUSTED`, but downstream non-AI steps that don't depend on the AI output still run. ## Conflicts This PR overlaps with three other in-flight PRs in the same files. None of them touch the gate logic; rebasing on top of any of them is trivial: - #20065 (agent-async-executor): adds `workspaceId` to `executeAgent` args and bills in `finally`. The gate at the top of `executeAgent` from this PR sits naturally above that. - #20066 (REST controller): adds usage billing to the controller. - #20067 (title gen): adds usage billing to title generation and tool-call repair. Recommend landing #20065/#20066/#20067 first; this PR rebases trivially on top. ## Tests Out of scope per the PR series convention. The existing chat-resolver gate isn't unit-tested either; this PR follows the same precedent. Follow-up: add integration coverage that exercises a workspace at `hasReachedCurrentPeriodCap=true` against each of the three new gates plus the pre-existing chat-resolver gate. ## Future follow-ups - Per-user soft cap inside a workspace (the Lovable Business-tier pattern), so one user can't exhaust the workspace's cap. - Pre-flight cost estimate so the user sees an "approaching cap" warning before the hard stop. - Rename `BillingProductKey.WORKFLOW_NODE_EXECUTION` — the name predates this design choice and is misleading now that it gates AI entry points rather than workflow nodes. ## Test plan - [ ] Trigger a workspace into `hasReachedCurrentPeriodCap=true`. - [ ] Send a chat message — expect failure with `BILLING_CREDITS_EXHAUSTED`. - [ ] Run a workflow whose only AI step is an `ai-agent` action — expect that step to fail with `BILLING_CREDITS_EXHAUSTED`, downstream non-AI steps still run. - [ ] POST to `/rest/ai/generate-text` — expect `BILLING_CREDITS_EXHAUSTED`. - [ ] Create a new chat thread (which kicks off `generateThreadTitle`) — expect `BILLING_CREDITS_EXHAUSTED`. - [ ] Run a workflow with no AI step (only DB CRUD/branching/actions) — expect it to run unaffected. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a713f8d87f
commit
842e679cc6
+2
@@ -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,
|
||||
|
||||
+4
@@ -16,6 +16,7 @@ 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 { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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';
|
||||
@@ -69,6 +70,7 @@ export class AgentAsyncExecutorService {
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly nativeToolBinder: NativeToolBinderService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
@InjectRepository(RoleTargetEntity)
|
||||
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
@@ -139,6 +141,8 @@ export class AgentAsyncExecutorService {
|
||||
userWorkspaceId?: string | null;
|
||||
operationType?: UsageOperationType;
|
||||
}): Promise<AgentExecutionResult> {
|
||||
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
|
||||
|
||||
let accumulatedUsage: LanguageModelUsage = EMPTY_USAGE;
|
||||
let cacheCreationTokens = 0;
|
||||
let nativeWebSearchCallCount = 0;
|
||||
|
||||
+1
-18
@@ -16,13 +16,8 @@ import { Repository } from 'typeorm';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -58,7 +53,6 @@ export class AgentChatResolver {
|
||||
private readonly eventPublisherService: AgentChatEventPublisherService,
|
||||
private readonly systemPromptBuilderService: SystemPromptBuilderService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
@InjectRepository(AgentChatThreadEntity)
|
||||
@@ -133,18 +127,7 @@ export class AgentChatResolver {
|
||||
workspace,
|
||||
);
|
||||
|
||||
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
const canBill = await this.billingUsageService.hasAvailableCredits(
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!canBill) {
|
||||
throw new BillingException(
|
||||
'Credits exhausted',
|
||||
BillingExceptionCode.BILLING_CREDITS_EXHAUSTED,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.billingUsageService.hasAvailableCreditsOrThrow(workspace.id);
|
||||
|
||||
const thread = await this.threadRepository.findOne({
|
||||
where: { id: threadId, userWorkspaceId },
|
||||
|
||||
+4
@@ -7,6 +7,7 @@ import {
|
||||
generateText,
|
||||
} from 'ai';
|
||||
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
@@ -20,6 +21,7 @@ export class AgentTitleGenerationService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
) {}
|
||||
|
||||
async generateThreadTitle(
|
||||
@@ -27,6 +29,8 @@ export class AgentTitleGenerationService {
|
||||
workspaceId: string,
|
||||
userWorkspaceId: string | null,
|
||||
): Promise<string> {
|
||||
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
|
||||
|
||||
const defaultModel = this.aiModelRegistryService.getDefaultSpeedModel();
|
||||
|
||||
if (!defaultModel) {
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
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 { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
@@ -12,6 +13,7 @@ import { AiGenerateTextController } from './controllers/ai-generate-text.control
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
PermissionsModule,
|
||||
BillingModule,
|
||||
AiBillingModule,
|
||||
],
|
||||
controllers: [AiGenerateTextController],
|
||||
|
||||
+4
@@ -4,6 +4,7 @@ import { generateText } from 'ai';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -27,6 +28,7 @@ export class AiGenerateTextController {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly aiBillingService: AiBillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
) {}
|
||||
|
||||
@Post('generate-text')
|
||||
@@ -43,6 +45,8 @@ export class AiGenerateTextController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.billingUsageService.hasAvailableCreditsOrThrow(workspace.id);
|
||||
|
||||
const resolvedModelId = body.modelId ?? workspace.fastModel;
|
||||
|
||||
this.aiModelRegistryService.validateModelAvailability(
|
||||
|
||||
Reference in New Issue
Block a user