diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index a13af5b05f..9de942767e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -70,9 +70,14 @@ export type Agent = { export type AgentChatThread = { __typename?: 'AgentChatThread'; + contextWindowTokens?: Maybe; createdAt: Scalars['DateTime']; id: Scalars['UUID']; title?: Maybe; + totalInputCredits: Scalars['Int']; + totalInputTokens: Scalars['Int']; + totalOutputCredits: Scalars['Int']; + totalOutputTokens: Scalars['Int']; updatedAt: Scalars['DateTime']; }; @@ -5114,7 +5119,7 @@ export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{ export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, createdAt: string, updatedAt: string }> }; +export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> }; export type TrackAnalyticsMutationVariables = Exact<{ type: AnalyticsType; @@ -7722,6 +7727,11 @@ export const GetChatThreadsDocument = gql` chatThreads { id title + totalInputTokens + totalOutputTokens + contextWindowTokens + totalInputCredits + totalOutputCredits createdAt updatedAt } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c1774b5967..feeead973c 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -70,9 +70,14 @@ export type Agent = { export type AgentChatThread = { __typename?: 'AgentChatThread'; + contextWindowTokens?: Maybe; createdAt: Scalars['DateTime']; id: Scalars['UUID']; title?: Maybe; + totalInputCredits: Scalars['Int']; + totalInputTokens: Scalars['Int']; + totalOutputCredits: Scalars['Int']; + totalOutputTokens: Scalars['Int']; updatedAt: Scalars['DateTime']; }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index 1368a915a0..718343353d 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -11,6 +11,7 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState'; import { AIChatMessage } from '@/ai/components/AIChatMessage'; +import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContextUsageButton'; import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader'; import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview'; import { SendMessageButton } from '@/ai/components/internal/SendMessageButton'; @@ -138,6 +139,7 @@ export const AIChatTab = () => { onClick={() => createChatThread()} /> + diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx index efede6ea68..d0c533bee3 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx @@ -1,9 +1,11 @@ +import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { IconSparkles } from 'twenty-ui/display'; import { type AgentChatThread } from '~/generated-metadata/graphql'; @@ -76,8 +78,35 @@ export const AIChatThreadGroup = ({ const { t } = useLingui(); const theme = useTheme(); const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState); + const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); + const handleThreadClick = (thread: AgentChatThread) => { + setCurrentAIChatThread(thread.id); + + const totalTokens = thread.totalInputTokens + thread.totalOutputTokens; + const hasUsageData = + totalTokens > 0 && isDefined(thread.contextWindowTokens); + + setAgentChatUsage( + hasUsageData + ? { + inputTokens: thread.totalInputTokens, + outputTokens: thread.totalOutputTokens, + totalTokens, + contextWindowTokens: thread.contextWindowTokens ?? 0, + inputCredits: thread.totalInputCredits, + outputCredits: thread.totalOutputCredits, + } + : null, + ); + + openAskAIPage({ + pageTitle: thread.title, + resetNavigationStack: false, + }); + }; + if (threads.length === 0) { return null; } @@ -88,13 +117,7 @@ export const AIChatThreadGroup = ({ {threads.map((thread) => ( { - setCurrentAIChatThread(thread.id); - openAskAIPage({ - pageTitle: thread.title, - resetNavigationStack: false, - }); - }} + onClick={() => handleThreadClick(thread)} key={thread.id} > diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx new file mode 100644 index 0000000000..02a0d8e370 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx @@ -0,0 +1,188 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ProgressBar } from 'twenty-ui/feedback'; + +import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing'; +import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; + +const StyledContainer = styled.div` + position: relative; +`; + +const StyledTrigger = styled.div<{ hasUsage: boolean }>` + align-items: center; + background: transparent; + border: 1px solid ${({ theme }) => theme.background.transparent.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + height: 24px; + padding: 0 ${({ theme }) => theme.spacing(2)}; + transition: background 0.1s ease; + + &:hover { + background: ${({ theme, hasUsage }) => + hasUsage ? theme.background.transparent.light : 'transparent'}; + } +`; + +const StyledPercentage = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledHoverCard = styled.div` + background: ${({ theme }) => theme.background.primary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + min-width: 240px; + position: absolute; + right: 0; + bottom: calc(100% + 8px); + z-index: ${({ theme }) => theme.lastLayerZIndex}; +`; + +const StyledHeader = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledRow = styled.div` + align-items: center; + display: flex; + justify-content: space-between; +`; + +const StyledBody = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(3)}; + padding-top: 0; +`; + +const StyledLabel = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledValue = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledFooter = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.secondary}; + border-top: 1px solid ${({ theme }) => theme.border.color.light}; + border-radius: 0 0 ${({ theme }) => theme.border.radius.md} + ${({ theme }) => theme.border.radius.md}; + display: flex; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(3)}; +`; + +const formatTokenCount = (count: number): string => { + if (count >= 1_000_000_000) { + return `${(count / 1_000_000_000).toFixed(1)}B`; + } + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}K`; + } + return count.toString(); +}; + +export const AIChatContextUsageButton = () => { + const theme = useTheme(); + const [isHovered, setIsHovered] = useState(false); + const agentChatUsage = useRecoilValue(agentChatUsageState); + + if (!agentChatUsage) { + return ( + + + + 0% + + + ); + } + + const percentage = Math.min( + (agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100, + 100, + ); + const formattedPercentage = percentage.toFixed(1); + const totalCredits = + agentChatUsage.inputCredits + agentChatUsage.outputCredits; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + {formattedPercentage}% + + + {isHovered && ( + + + + {formattedPercentage}% + + {formatTokenCount(agentChatUsage.totalTokens)} /{' '} + {formatTokenCount(agentChatUsage.contextWindowTokens)} + + + 80 + ? theme.color.red + : percentage > 60 + ? theme.color.orange + : theme.color.blue + } + backgroundColor={theme.background.quaternary} + withBorderRadius + /> + + + + + Input + + {formatTokenCount(agentChatUsage.inputTokens)} •{' '} + {agentChatUsage.inputCredits.toLocaleString()} credits + + + + Output + + {formatTokenCount(agentChatUsage.outputTokens)} •{' '} + {agentChatUsage.outputCredits.toLocaleString()} credits + + + + + + Total credits + {totalCredits.toLocaleString()} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/components/internal/ContextUsageProgressRing.tsx b/packages/twenty-front/src/modules/ai/components/internal/ContextUsageProgressRing.tsx new file mode 100644 index 0000000000..4873b06bab --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/internal/ContextUsageProgressRing.tsx @@ -0,0 +1,64 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +type ContextUsageProgressRingProps = { + percentage: number; + size?: number; + strokeWidth?: number; +}; + +const StyledSvg = styled.svg` + transform: rotate(-90deg); +`; + +const StyledBackgroundCircle = styled.circle` + fill: none; + stroke: ${({ theme }) => theme.background.quaternary}; +`; + +const StyledProgressCircle = styled.circle` + fill: none; + transition: stroke-dashoffset 0.3s ease; +`; + +export const ContextUsageProgressRing = ({ + percentage, + size = 16, + strokeWidth = 2, +}: ContextUsageProgressRingProps) => { + const theme = useTheme(); + + const normalizedPercentage = Math.min(Math.max(percentage, 0), 100); + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = + circumference - (normalizedPercentage / 100) * circumference; + + const progressColor = + normalizedPercentage > 80 + ? theme.color.red + : normalizedPercentage > 60 + ? theme.color.orange + : theme.color.blue; + + return ( + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts b/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts index 6e25990790..5f5c6a1698 100644 --- a/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts +++ b/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts @@ -5,6 +5,11 @@ export const GET_CHAT_THREADS = gql` chatThreads { id title + totalInputTokens + totalOutputTokens + contextWindowTokens + totalInputCredits + totalOutputCredits createdAt updatedAt } diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index a0f8d72392..24ebe9dd3c 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext'; import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState'; import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState'; +import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; @@ -19,6 +20,7 @@ import { agentChatInputState } from '../states/agentChatInputState'; export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const setTokenPair = useSetRecoilState(tokenPairState); + const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const { getBrowsingContext } = useGetBrowsingContext(); @@ -99,6 +101,34 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { messages: uiMessages, id: `${currentAIChatThread}-${uiMessages.length}`, experimental_throttle: 100, + onFinish: ({ message }) => { + type UsageMetadata = { + inputTokens: number; + outputTokens: number; + inputCredits: number; + outputCredits: number; + }; + type ModelMetadata = { + contextWindowTokens: number; + }; + const metadata = message.metadata as + | { usage?: UsageMetadata; model?: ModelMetadata } + | undefined; + const usage = metadata?.usage; + const model = metadata?.model; + + if (isDefined(usage) && isDefined(model)) { + setAgentChatUsage((prev) => ({ + inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens, + outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens, + totalTokens: + (prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens, + contextWindowTokens: model.contextWindowTokens, + inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits, + outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits, + })); + } + }, }); const isStreaming = status === 'streaming'; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts index 549eac6490..6daf814b6e 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts @@ -1,17 +1,48 @@ import { useAgentChatScrollToBottom } from '@/ai/hooks/useAgentChatScrollToBottom'; +import { + agentChatUsageState, + type AgentChatUsageState, +} from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { mapDBMessagesToUIMessages } from '@/ai/utils/mapDBMessagesToUIMessages'; -import { useRecoilState } from 'recoil'; +import { + type SetterOrUpdater, + useRecoilState, + useSetRecoilState, +} from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { + type AgentChatThread, useGetChatMessagesQuery, useGetChatThreadsQuery, } from '~/generated-metadata/graphql'; +const setUsageFromThread = ( + thread: AgentChatThread, + setAgentChatUsage: SetterOrUpdater, +) => { + const totalTokens = thread.totalInputTokens + thread.totalOutputTokens; + const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens); + + setAgentChatUsage( + hasUsageData + ? { + inputTokens: thread.totalInputTokens, + outputTokens: thread.totalOutputTokens, + totalTokens, + contextWindowTokens: thread.contextWindowTokens ?? 0, + inputCredits: thread.totalInputCredits, + outputCredits: thread.totalOutputCredits, + } + : null, + ); +}; + export const useAgentChatData = () => { const [currentAIChatThread, setCurrentAIChatThread] = useRecoilState( currentAIChatThreadState, ); + const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const { scrollToBottom } = useAgentChatScrollToBottom(); @@ -19,7 +50,10 @@ export const useAgentChatData = () => { skip: isDefined(currentAIChatThread), onCompleted: (data) => { if (data.chatThreads.length > 0) { - setCurrentAIChatThread(data.chatThreads[0].id); + const firstThread = data.chatThreads[0]; + + setCurrentAIChatThread(firstThread.id); + setUsageFromThread(firstThread, setAgentChatUsage); } }, }); diff --git a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts index 0d6c5965a7..a872e5f98d 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useCreateNewAIChatThread.ts @@ -1,15 +1,18 @@ +import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { useCreateChatThreadMutation } from '~/generated-metadata/graphql'; export const useCreateNewAIChatThread = () => { const [, setCurrentAIChatThread] = useRecoilState(currentAIChatThreadState); + const setAgentChatUsage = useSetRecoilState(agentChatUsageState); const { openAskAIPage } = useOpenAskAIPageInCommandMenu(); const [createChatThread] = useCreateChatThreadMutation({ onCompleted: (data) => { setCurrentAIChatThread(data.createChatThread.id); + setAgentChatUsage(null); openAskAIPage({ resetNavigationStack: false }); }, }); diff --git a/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts b/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts new file mode 100644 index 0000000000..acc8370b75 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts @@ -0,0 +1,15 @@ +import { atom } from 'recoil'; + +export type AgentChatUsageState = { + inputTokens: number; + outputTokens: number; + totalTokens: number; + contextWindowTokens: number; + inputCredits: number; + outputCredits: number; +}; + +export const agentChatUsageState = atom({ + key: 'agentChatUsageState', + default: null, +}); diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts index 64642250ba..c4c035488f 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts @@ -5,6 +5,11 @@ describe('groupThreadsByDate', () => { const baseThread: Omit = { title: 'Test Thread', updatedAt: new Date().toISOString(), + totalInputTokens: 0, + totalOutputTokens: 0, + contextWindowTokens: null, + totalInputCredits: 0, + totalOutputCredits: 0, }; const today = new Date(); diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1764700000000-add-usage-columns-to-agent-chat-thread.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1764700000000-add-usage-columns-to-agent-chat-thread.ts new file mode 100644 index 0000000000..da796d8c60 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1764700000000-add-usage-columns-to-agent-chat-thread.ts @@ -0,0 +1,43 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddUsageColumnsToAgentChatThread1764700000000 + implements MigrationInterface +{ + name = 'AddUsageColumnsToAgentChatThread1764700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalInputTokens" integer NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalOutputTokens" integer NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "contextWindowTokens" integer`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalInputCredits" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "totalOutputCredits" bigint NOT NULL DEFAULT 0`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalOutputCredits"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalInputCredits"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "contextWindowTokens"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalOutputTokens"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "totalInputTokens"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier.ts index 866dfb27b5..ec4bdb1e98 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier.ts @@ -1,2 +1,2 @@ // Configuration: $0.00001 = 1 credit -export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar +export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000001 = 1 000 000 credits per dollar diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts index 432df17211..300a8fd1e2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, Int, ObjectType } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -10,6 +10,21 @@ export class AgentChatThreadDTO { @Field({ nullable: true }) title: string; + @Field(() => Int) + totalInputTokens: number; + + @Field(() => Int) + totalOutputTokens: number; + + @Field(() => Int, { nullable: true }) + contextWindowTokens: number | null; + + @Field(() => Int) + totalInputCredits: number; + + @Field(() => Int) + totalOutputCredits: number; + @Field() createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts index 63dc2318e8..b5d6220114 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts @@ -34,6 +34,21 @@ export class AgentChatThreadEntity { @Column({ nullable: true, type: 'varchar' }) title: string; + @Column({ type: 'int', default: 0 }) + totalInputTokens: number; + + @Column({ type: 'int', default: 0 }) + totalOutputTokens: number; + + @Column({ type: 'int', nullable: true }) + contextWindowTokens: number | null; + + @Column({ type: 'bigint', default: 0 }) + totalInputCredits: number; + + @Column({ type: 'bigint', default: 0 }) + totalOutputCredits: number; + @OneToMany(() => AgentTurnEntity, (turn) => turn.thread) turns: Relation; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index 8a989043ee..7b334b8389 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -13,6 +13,7 @@ import { AgentExceptionCode, } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; +import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { AgentChatService } from './agent-chat.service'; @@ -63,12 +64,13 @@ export class AgentChatStreamingService { try { const uiStream = createUIMessageStream({ execute: async ({ writer }) => { - const { stream } = await this.chatExecutionService.streamChat({ - workspace, - userWorkspaceId, - messages, - browsingContext, - }); + const { stream, modelConfig } = + await this.chatExecutionService.streamChat({ + workspace, + userWorkspaceId, + messages, + browsingContext, + }); // Write initial status writer.write({ @@ -80,6 +82,14 @@ export class AgentChatStreamingService { }, }); + // Track usage from the stream for persisting to thread + let streamUsage = { + inputTokens: 0, + outputTokens: 0, + inputCredits: 0, + outputCredits: 0, + }; + // Merge the AI stream writer.merge( stream.toUIMessageStream({ @@ -89,6 +99,48 @@ export class AgentChatStreamingService { return error instanceof Error ? error.message : String(error); }, sendStart: false, + messageMetadata: ({ part }) => { + if (part.type === 'finish') { + const inputTokens = part.totalUsage?.inputTokens ?? 0; + const outputTokens = part.totalUsage?.outputTokens ?? 0; + + const inputCostInCents = + (inputTokens / 1000) * + modelConfig.inputCostPer1kTokensInCents; + const outputCostInCents = + (outputTokens / 1000) * + modelConfig.outputCostPer1kTokensInCents; + + const inputCredits = Math.round( + convertCentsToBillingCredits(inputCostInCents), + ); + const outputCredits = Math.round( + convertCentsToBillingCredits(outputCostInCents), + ); + + streamUsage = { + inputTokens, + outputTokens, + inputCredits, + outputCredits, + }; + + return { + createdAt: new Date().toISOString(), + usage: { + inputTokens, + outputTokens, + inputCredits, + outputCredits, + }, + model: { + contextWindowTokens: modelConfig.contextWindowTokens, + }, + }; + } + + return undefined; + }, onFinish: async ({ responseMessage }) => { if (responseMessage.parts.length === 0) { return; @@ -136,6 +188,19 @@ export class AgentChatStreamingService { uiMessage: responseMessage, turnId: userMessage.turnId, }); + + // Update thread usage statistics + await this.threadRepository.update(validThreadId, { + totalInputTokens: () => + `"totalInputTokens" + ${streamUsage.inputTokens}`, + totalOutputTokens: () => + `"totalOutputTokens" + ${streamUsage.outputTokens}`, + totalInputCredits: () => + `"totalInputCredits" + ${streamUsage.inputCredits}`, + totalOutputCredits: () => + `"totalOutputCredits" + ${streamUsage.outputCredits}`, + contextWindowTokens: modelConfig.contextWindowTokens, + }); } catch (saveError) { this.logger.error( 'Failed to save messages:', 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 5d41b466e6..cc70f4e9d1 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 @@ -34,7 +34,10 @@ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agen import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const'; -import { ModelProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; +import { + type AIModelConfig, + ModelProvider, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; 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'; @@ -48,6 +51,7 @@ export type ChatExecutionOptions = { export type ChatExecutionResult = { stream: ReturnType; preloadedTools: string[]; + modelConfig: AIModelConfig; }; // Common tools to pre-load for quick access @@ -109,6 +113,10 @@ export class ChatExecutionService { const registeredModel = this.aiModelRegistryService.getDefaultPerformanceModel(); + const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig( + registeredModel.modelId, + ); + const activeTools: ToolSet = { ...preloadedTools, ...this.getNativeWebSearchTool(registeredModel.provider), @@ -181,6 +189,7 @@ export class ChatExecutionService { return { stream, preloadedTools: preloadedToolNames, + modelConfig, }; } diff --git a/packages/twenty-shared/src/ai/index.ts b/packages/twenty-shared/src/ai/index.ts index 72f38977fd..9c29b34085 100644 --- a/packages/twenty-shared/src/ai/index.ts +++ b/packages/twenty-shared/src/ai/index.ts @@ -12,6 +12,10 @@ export type { AgentResponseSchema, } from './types/agent-response-schema.type'; export type { DataMessagePart } from './types/DataMessagePart'; -export type { ExtendedUIMessage } from './types/ExtendedUIMessage'; +export type { + AIChatUsageMetadata, + AIChatModelMetadata, + ExtendedUIMessage, +} from './types/ExtendedUIMessage'; export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart'; export type { ModelConfiguration } from './types/model-configuration.type'; diff --git a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts index 4b200b90bd..5204f03f3f 100644 --- a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts +++ b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts @@ -1,8 +1,21 @@ import { type DataMessagePart } from '@/ai/types/DataMessagePart'; import { type UIMessage } from 'ai'; +export type AIChatUsageMetadata = { + inputTokens: number; + outputTokens: number; + inputCredits: number; + outputCredits: number; +}; + +export type AIChatModelMetadata = { + contextWindowTokens: number; +}; + type Metadata = { createdAt: string; + usage?: AIChatUsageMetadata; + model?: AIChatModelMetadata; }; export type ExtendedUIMessage = UIMessage;