Files
twenty/packages/twenty-server/src/engine/metadata-modules/ai/ai-generate-text/controllers/ai-generate-text.controller.ts
T
Félix Malfait e632b7dbb9 fix(ai-billing): bill POST /rest/ai/generate-text usage to ClickHouse (#20066)
## Summary

`POST /rest/ai/generate-text` calls `generateText` and returns `usage`
to the client without emitting a `usageEvent`. Authenticated, gated only
by `PermissionFlagType.AI` — any workspace user with that permission
could call it in a loop without billing. Identified during the
2026-04-26 incident audit.

## What changed

- Inject `AiBillingService` into `AiGenerateTextController`.
- Add `@AuthUserWorkspaceId() userWorkspaceId: string` to source the
user-workspace identifier.
- Wrap the `generateText` call in `try { ... return ... } finally { ...
}` so billing fires even if the controller throws after Anthropic was
paid.
- Bill with `UsageOperationType.AI_WORKFLOW_TOKEN` and
`cacheCreationTokens: result.usage.inputTokenDetails?.cacheWriteTokens
?? 0`.
- Inner `try/catch` around the billing emit so a billing error can't
break the response.
- One-line module change: `AiGenerateTextModule` imports
`AiBillingModule` (NestJS DI requirement).

## Test plan
- [ ] Call `POST /rest/ai/generate-text` with a small prompt; verify a
`usageEvent` row appears in ClickHouse for the workspace with the
correct token count and `operationType = AI_WORKFLOW_TOKEN`.
- [ ] Call with a malformed model id that throws after the API key is
validated — verify no spurious billing call occurs (no Anthropic call
was made).

## Notes for review
- Response shape unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-27 21:16:19 +02:00

92 lines
3.3 KiB
TypeScript

import { Body, Controller, Post, UseFilters, UseGuards } from '@nestjs/common';
import { generateText } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
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';
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 {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/filters/ai-api-exception.filter';
import { GenerateTextInput } from 'src/engine/metadata-modules/ai/ai-generate-text/dtos/generate-text.input';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Controller('rest/ai')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(AiRestApiExceptionFilter, RestApiExceptionFilter)
export class AiGenerateTextController {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiBillingService: AiBillingService,
) {}
@Post('generate-text')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI))
async handleGenerateText(
@Body() body: GenerateTextInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AiException(
'No AI models are available. Please configure at least one AI provider API key.',
AiExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
const resolvedModelId = body.modelId ?? workspace.fastModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent({
modelId: resolvedModelId,
});
let result: Awaited<ReturnType<typeof generateText>> | undefined;
try {
result = await generateText({
model: registeredModel.model,
system: body.systemPrompt,
prompt: body.userPrompt,
});
return {
text: result.text,
usage: {
inputTokens: result.usage?.inputTokens ?? 0,
outputTokens: result.usage?.outputTokens ?? 0,
},
};
} finally {
if (result) {
this.aiBillingService.calculateAndBillUsage(
resolvedModelId,
{
usage: result.usage,
cacheCreationTokens:
result.usage.inputTokenDetails?.cacheWriteTokens ?? 0,
},
workspace.id,
UsageOperationType.AI_WORKFLOW_TOKEN,
null,
userWorkspaceId,
);
}
}
}
}