Add ai usage to billing (#10138)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina
2025-10-24 12:02:39 +04:00
committed by GitHub
parent 6867dc9211
commit f2e0ca1453
45 changed files with 1001 additions and 65 deletions
+3 -2
View File
@@ -5642,7 +5642,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-ai-bot@file:projects/pod-ai-bot.tgz':
resolution: {integrity: sha512-i7ARoUNEidIH+C+xWIrmtTsSR88zlHArLoCwIjeuC5NjjAgd5erkDkPL9IyJ3hwSFjAZ+V0TTtOVLJavcs03ZA==, tarball: file:projects/pod-ai-bot.tgz}
resolution: {integrity: sha512-LQ42nJT9Obn8nZgE5e5XKErxbtZj2LxZs3IvE9xPMW179nVDdQFAc9fGlZ7adrmCvYpRPpNIgXV2O9rimBeL5g==, tarball: file:projects/pod-ai-bot.tgz}
version: 0.0.0
'@rush-temp/pod-analytics-collector@file:projects/pod-analytics-collector.tgz':
@@ -5754,7 +5754,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-translate@file:projects/pod-translate.tgz':
resolution: {integrity: sha512-YWZPxkubAeu8Se4YwOfZept8ufirQy4ndQ1N+QD9EUAla6VUuRPC0zhcG1Vl3LGKAp9hUR9bZTy0EIPdFHY/AA==, tarball: file:projects/pod-translate.tgz}
resolution: {integrity: sha512-cdak2NAfd+s4CAv3kGdBPeAgM5o/QJCH2OYfS6NI8GhTL0BsdmbiLbn2hQxzpRlpCBFnvOaUzO6HcFUa0owrcA==, tarball: file:projects/pod-translate.tgz}
version: 0.0.0
'@rush-temp/pod-worker@file:projects/pod-worker.tgz':
@@ -27302,6 +27302,7 @@ snapshots:
'@hcengineering/platform': 0.7.5
'@hcengineering/platform-rig': 0.7.19(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3)))(postcss@8.5.3)(sass@1.93.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3))
'@hcengineering/rank': 0.7.5
'@hcengineering/retry': 0.7.5
'@hcengineering/server-client': 0.7.8(bufferutil@4.0.8)(utf-8-validate@6.0.4)
'@hcengineering/server-core': 0.7.8
'@hcengineering/server-storage': 0.7.9
+25
View File
@@ -1,6 +1,8 @@
import { concatLink, WorkspaceUuid } from '@hcengineering/core'
import { BillingError, NetworkError } from './error'
import {
AiTokensData,
AiTranscriptData,
BillingStats,
DatalakeStats,
LiveKitEgressData,
@@ -83,6 +85,29 @@ export class BillingClient {
const body = JSON.stringify(egress)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
async getAiTranscriptLastData (): Promise<AiTranscriptData | undefined> {
const path = '/api/v1/ai/transcript/last'
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, { headers: { ...this.headers } })
return (await response.json()) as AiTranscriptData | undefined
}
async postAiTranscriptData (data: AiTranscriptData[]): Promise<void> {
const path = '/api/v1/ai/transcript'
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify(data)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
async postAiTokensData (data: AiTokensData[]): Promise<void> {
const path = '/api/v1/ai/tokens'
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify(data)
await fetchSafe(url, { method: 'POST', headers: { ...this.headers }, body })
}
}
async function fetchSafe (url: string | URL, init?: RequestInit): Promise<Response> {
+33
View File
@@ -1,6 +1,9 @@
import { WorkspaceUuid } from '@hcengineering/core'
export interface BillingStats {
liveKitStats: LiveKitStats
datalakeStats: DatalakeStats
aiStats: AiStats
}
export interface DatalakeStats {
@@ -42,3 +45,33 @@ export interface LiveKitEgressData {
egressEnd: string
duration: number
}
export interface AiTranscriptStats {
totalDurationSeconds: number
}
export interface AiTokensStats {
reason: string
totalTokens: number
}
export interface AiStats {
transcript: AiTranscriptStats
tokens: AiTokensStats[]
}
export interface AiTranscriptData {
workspace: WorkspaceUuid
day: string
lastRequestId: string
lastStartTime: string
durationSeconds: number
usd: number
}
export interface AiTokensData {
workspace: WorkspaceUuid
reason: string
tokens: number
date: string
}
+8
View File
@@ -355,6 +355,14 @@ export async function formatDuration (duration: number, language: string): Promi
return text
}
export function formatNumberCompact (num: number, maximumFractionDigits = 2): string {
const locale = new Intl.NumberFormat().resolvedOptions().locale
return new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits
}).format(num)
}
export function pushRootBarComponent (pos: 'left' | 'right', component: AnyComponent, order?: number): void {
rootBarExtensions.update((cur) => {
if (cur.find((p) => p[1].component === component) === undefined) {
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Počet souborů",
"OfficeSessionsDuration": "Doba schůzek",
"OfficeSessionsBandwidth": "Šířka pásma schůzek",
"OfficeEgressDuration": "Doba záznamu"
"OfficeEgressDuration": "Doba záznamu",
"AI": "AI",
"TranscriptionTime": "Doba přepisu",
"TotalTokens": "Celkem tokenů"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Dateianzahl",
"OfficeSessionsDuration": "Besprechungsdauer",
"OfficeSessionsBandwidth": "Besprechungsbandbreite",
"OfficeEgressDuration": "Aufzeichnungsdauer"
"OfficeEgressDuration": "Aufzeichnungsdauer",
"AI": "AI",
"TranscriptionTime": "Transkriptionszeit",
"TotalTokens": "Gesamte Tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Files count",
"OfficeSessionsDuration": "Meetings time",
"OfficeSessionsBandwidth": "Meetings bandwidth",
"OfficeEgressDuration": "Recording time"
"OfficeEgressDuration": "Recording time",
"AI": "AI",
"TranscriptionTime": "Transcription time",
"TotalTokens": "Total tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Cantidad de archivos",
"OfficeSessionsDuration": "Tiempo de reuniones",
"OfficeSessionsBandwidth": "Ancho de banda de reuniones",
"OfficeEgressDuration": "Tiempo de grabación"
"OfficeEgressDuration": "Tiempo de grabación",
"AI": "IA",
"TranscriptionTime": "Tiempo de transcripción",
"TotalTokens": "Total de tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Nombre de fichiers",
"OfficeSessionsDuration": "Durée des réunions",
"OfficeSessionsBandwidth": "Bande passante des réunions",
"OfficeEgressDuration": "Durée d'enregistrement"
"OfficeEgressDuration": "Durée d'enregistrement",
"AI": "IA",
"TranscriptionTime": "Durée de transcription",
"TotalTokens": "Nombre total de jetons"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Conteggio file",
"OfficeSessionsDuration": "Durata riunioni",
"OfficeSessionsBandwidth": "Larghezza di banda riunioni",
"OfficeEgressDuration": "Durata registrazione"
"OfficeEgressDuration": "Durata registrazione",
"AI": "AI",
"TranscriptionTime": "Tempo di trascrizione",
"TotalTokens": "Totale token"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "ファイル数",
"OfficeSessionsDuration": "会議時間",
"OfficeSessionsBandwidth": "会議帯域幅",
"OfficeEgressDuration": "録画時間"
"OfficeEgressDuration": "録画時間",
"AI": "AI",
"TranscriptionTime": "文字起こし時間",
"TotalTokens": "合計トークン"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Quantidade de arquivos",
"OfficeSessionsDuration": "Tempo de reuniões",
"OfficeSessionsBandwidth": "Largura de banda de reuniões",
"OfficeEgressDuration": "Tempo de gravação"
"OfficeEgressDuration": "Tempo de gravação",
"AI": "IA",
"TranscriptionTime": "Tempo de transcrição",
"TotalTokens": "Total de tokens"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Количество файлов",
"OfficeSessionsDuration": "Время встреч",
"OfficeSessionsBandwidth": "Трафик встреч",
"OfficeEgressDuration": "Время записи"
"OfficeEgressDuration": "Время записи",
"AI": "AI",
"TranscriptionTime": "Время транскрипции",
"TotalTokens": "Всего токенов"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "Dosya sayısı",
"OfficeSessionsDuration": "Toplantı süresi",
"OfficeSessionsBandwidth": "Toplantı bant genişliği",
"OfficeEgressDuration": "Kayıt süresi"
"OfficeEgressDuration": "Kayıt süresi",
"AI": "AI",
"TranscriptionTime": "Transkripsiyon süresi",
"TotalTokens": "Toplam belirteçler"
}
}
+4 -1
View File
@@ -5,6 +5,9 @@
"DriveCount": "文件数量",
"OfficeSessionsDuration": "会议时间",
"OfficeSessionsBandwidth": "会议带宽",
"OfficeEgressDuration": "录制时间"
"OfficeEgressDuration": "录制时间",
"AI": "AI",
"TranscriptionTime": "转录时间",
"TotalTokens": "总令牌数"
}
}
@@ -13,15 +13,25 @@
// limitations under the License.
-->
<script lang="ts">
import { getBillingClient } from '../utils'
import { Breadcrumb, Header, Loading, Scroller, formatDuration, themeStore } from '@hcengineering/ui'
import {
Breadcrumb,
Header,
Loading,
Scroller,
formatDuration,
themeStore,
formatNumberCompact
} from '@hcengineering/ui'
import { getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import billingPlugin from '@hcengineering/billing'
import filesize from 'filesize'
import StatsCard from './StatsCard.svelte'
import drivePlugin from '@hcengineering/drive'
import Category from './Category.svelte'
import love from '@hcengineering/love'
import drivePlugin from '@hcengineering/drive'
import view from '@hcengineering/view'
import { getBillingClient } from '../utils'
import StatsCard from './StatsCard.svelte'
import Category from './Category.svelte'
import ChartCard from './ChartCard.svelte'
const billingClient = getBillingClient()
@@ -34,6 +44,8 @@
let sessionsDurationByDay: { date: number, value: number }[] = []
let sessionsBandwidthByDay: { date: number, value: number }[] = []
let egressDurationByDay: { date: number, value: number }[] = []
let totalTranscriptDuration = 0
let totalTokensCount = 0
async function loadBillingData (): Promise<void> {
if (billingClient == null) return
@@ -43,6 +55,8 @@
totalSessionsDuration = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.minutes, 0) * 60000
totalSessionsBandwidth = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.bandwidth, 0)
totalEgressDuration = billingStats.liveKitStats.egress.reduce((sum, e) => sum + e.minutes, 0) * 60000
totalTranscriptDuration = billingStats.aiStats.transcript.totalDurationSeconds * 1000
totalTokensCount = billingStats.aiStats.tokens.reduce((sum, s) => sum + s.totalTokens, 0)
sessionsDurationByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
@@ -80,6 +94,15 @@
<StatsCard label={billingPlugin.string.DriveCount} text={totalDatalakeCount.toString()} />
</div>
</Category>
<Category icon={view.icon.AiStar} label={billingPlugin.string.AI}>
<div class="row">
<StatsCard
label={billingPlugin.string.TranscriptionTime}
text={formatDuration(totalTranscriptDuration, $themeStore.language)}
/>
<StatsCard label={billingPlugin.string.TotalTokens} text={formatNumberCompact(totalTokensCount)} />
</div>
</Category>
<Category icon={love.icon.Love} label={love.string.Office}>
<div class="row">
<StatsCard
+4 -1
View File
@@ -31,7 +31,10 @@ export const billingPlugin = plugin(billingId, {
DriveCount: '' as IntlString,
OfficeSessionsDuration: '' as IntlString,
OfficeSessionsBandwidth: '' as IntlString,
OfficeEgressDuration: '' as IntlString
OfficeEgressDuration: '' as IntlString,
AI: '' as IntlString,
TotalTokens: '' as IntlString,
TranscriptionTime: '' as IntlString
},
icon: {
Billing: '' as Asset
+10 -1
View File
@@ -278,4 +278,13 @@
<symbol id="print" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 4C7.89543 4 7 4.89543 7 6V26C7 27.1046 7.89543 28 9 28H23C24.1046 28 25 27.1046 25 26V12H21C18.7909 12 17 10.2091 17 8V4H9ZM19 4.41421V8C19 9.10457 19.8954 10 21 10H24.5858L19 4.41421ZM5 6C5 3.79086 6.79086 2 9 2H18.5858C19.1162 2 19.6249 2.21071 20 2.58579L26.4142 9C26.7893 9.37507 27 9.88378 27 10.4142V26C27 28.2091 25.2091 30 23 30H9C6.79086 30 5 28.2091 5 26V6ZM10 17C10 16.4477 10.4477 16 11 16H21C21.5523 16 22 16.4477 22 17C22 17.5523 21.5523 18 21 18H11C10.4477 18 10 17.5523 10 17ZM10 23C10 22.4477 10.4477 22 11 22H21C21.5523 22 22 22.4477 22 23C22 23.5523 21.5523 24 21 24H11C10.4477 24 10 23.5523 10 23Z" />
</symbol>
</svg>
<symbol id="ai-star" viewBox="0 0 24 24">
<path d="M19 22C19 22.5523 18.5523 23 18 23C17.4477 23 17 22.5523 17 22C17 21.4477 17.4477 21 18 21C18.5523 21 19 21.4477 19 22Z" fill="currentColor"/>
<path d="M23 18C23 18.5523 22.5523 19 22 19C21.4477 19 21 18.5523 21 18C21 17.4477 21.4477 17 22 17C22.5523 17 23 17.4477 23 18Z" fill="currentColor"/>
<path d="M3 6C3 6.55228 2.55228 7 2 7C1.44772 7 1 6.55228 1 6C1 5.44772 1.44772 5 2 5C2.55228 5 3 5.44772 3 6Z" fill="currentColor"/>
<path d="M7 2C7 2.55228 6.55228 3 6 3C5.44772 3 5 2.55228 5 2C5 1.44772 5.44772 1 6 1C6.55228 1 7 1.44772 7 2Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 6.48292L11.3478 9.17257C11.0871 10.2477 10.2477 11.0871 9.17257 11.3478L6.48292 12L9.17257 12.6522C10.2477 12.9129 11.0871 13.7523 11.3478 14.8274L12 17.5171L12.6522 14.8274C12.9129 13.7523 13.7523 12.9129 14.8274 12.6522L17.5171 12L14.8274 11.3478C13.7523 11.0871 12.9129 10.2477 12.6522 9.17257L12 6.48292ZM13.3052 5.02656C12.9733 3.65781 11.0267 3.65781 10.6948 5.02656L9.78151 8.79277C9.66301 9.28146 9.28146 9.66301 8.79277 9.78151L5.02656 10.6948C3.65781 11.0267 3.65781 12.9733 5.02656 13.3052L8.79277 14.2185C9.28146 14.337 9.66301 14.7185 9.78151 15.2072L10.6948 18.9734C11.0267 20.3422 12.9733 20.3422 13.3052 18.9734L14.2185 15.2072C14.337 14.7185 14.7185 14.337 15.2072 14.2185L18.9734 13.3052C20.3422 12.9733 20.3422 11.0267 18.9734 10.6948L15.2072 9.78151C14.7185 9.66301 14.337 9.28146 14.2185 8.79277L13.3052 5.02656Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.00462 22.3147C8.83081 22.7804 8.31241 23.017 7.84673 22.8432C4.8303 21.7173 2.40732 19.3801 1.16973 16.4228C0.977847 15.9643 1.194 15.4371 1.65252 15.2452C2.11105 15.0533 2.63831 15.2694 2.8302 15.728C3.8762 18.2274 5.92654 20.2052 8.47615 21.1568C8.94183 21.3306 9.17843 21.849 9.00462 22.3147Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.3153 9.02077C21.8498 9.19493 21.3312 8.95873 21.157 8.49319C20.1979 5.92954 18.1971 3.87072 15.6693 2.83251C15.2095 2.64366 14.9899 2.11784 15.1787 1.65806C15.3676 1.19827 15.8934 0.978632 16.3532 1.16748C19.3427 2.39533 21.7076 4.82798 22.8429 7.86246C23.0171 8.32801 22.7809 8.8466 22.3153 9.02077Z" fill="currentColor"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+2 -1
View File
@@ -68,7 +68,8 @@ loadMetadata(view.icon, {
MasterDetail: `${icons}#master-detail`,
Tree: `${icons}#tree`,
Document: `${icons}#document`,
Print: `${icons}#print`
Print: `${icons}#print`,
AiStar: `${icons}#ai-star`
})
loadMetadata(core.icon, {
TypeString: `${icons}#string`,
+2 -1
View File
@@ -304,7 +304,8 @@ const view = plugin(viewId, {
MasterDetail: '' as Asset,
Tree: '' as Asset,
Document: '' as Asset,
Print: '' as Asset
Print: '' as Asset,
AiStar: '' as Asset
},
category: {
General: '' as Ref<ActionCategory>,
+1 -1
View File
@@ -34,7 +34,7 @@
"typescript": "^5.6.3"
},
"dependencies": {
"@deepgram/sdk": "^3.12.1",
"@deepgram/sdk": "^4.11.2",
"@livekit/agents": "^0.7.4",
"@livekit/rtc-node": "^0.13.11",
"dotenv": "^16.4.5",
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@deepgram/sdk':
specifier: ^3.12.1
version: 3.12.1
specifier: ^4.11.2
version: 4.11.2
'@livekit/agents':
specifier: ^0.7.4
version: 0.7.4(@livekit/rtc-node@0.13.11)
@@ -70,8 +70,8 @@ packages:
resolution: {integrity: sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==}
engines: {node: '>=18.0.0'}
'@deepgram/sdk@3.12.1':
resolution: {integrity: sha512-MNnCnlyxdf0IY4Dkt+YcgCb8sy14zAzJ5BCNMwzv20tSxweoj8mk6eM063zQTuHWc3x2giKvS9x6/IBtV9zJLg==}
'@deepgram/sdk@4.11.2':
resolution: {integrity: sha512-lKGxuXxlSixC8bB0BnzmIpbVjUSgYtz17cqvrgv0ZjmazgUPkuUj9egQPj6k+fbPX8wRzWEqlhrL/DXlXqeDXA==}
engines: {node: '>=18.0.0'}
'@esbuild/aix-ppc64@0.20.2':
@@ -1531,7 +1531,7 @@ snapshots:
dependencies:
dayjs: 1.11.13
'@deepgram/sdk@3.12.1':
'@deepgram/sdk@4.11.2':
dependencies:
'@deepgram/captions': 1.2.0
'@types/node': 18.19.68
+9 -1
View File
@@ -101,7 +101,15 @@ export default defineAgent({
return
}
const stt = getStt(ctx.room)
const workspace = (roomName.split('_')[0] ?? '').trim()
if (workspace === '') {
console.error('Workspace is not defined', roomName)
ctx.shutdown()
return
}
const stt = getStt(ctx.room, workspace)
if (stt === undefined) {
console.error('Transcription provider is not configured')
+1 -1
View File
@@ -35,7 +35,7 @@ interface Config {
DgVadEvents: boolean
DgPunctuate: boolean
DgSmartFormat: boolean
DgNoDelay: boolean,
DgNoDelay: boolean
DgSampleRate: number
}
@@ -44,7 +44,10 @@ export class STT implements Stt {
private transcriptionCount = 0
constructor (readonly room: Room) {
constructor (
readonly room: Room,
readonly workspace: string
) {
this.deepgram = createClient(config.DeepgramApiKey)
}
@@ -154,6 +157,7 @@ export class STT implements Stt {
return {
...options,
extra: `workspace:${this.workspace}`,
encoding: 'linear16',
channels: stream.numChannels,
sample_rate: stream.sampleRate,
+2 -2
View File
@@ -5,12 +5,12 @@ import * as openai from './openai/stt.js'
import config from './config.js'
import { Stt } from './type.js'
export function getStt (room: Room): Stt | undefined {
export function getStt (room: Room, worksapce: string): Stt | undefined {
const provider = config.SttProvider
switch (provider) {
case 'deepgram':
return new dg.STT(room)
return new dg.STT(room, worksapce)
case 'openai':
return new openai.STT(room)
}
+4 -2
View File
@@ -57,7 +57,9 @@
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.7",
"@hcengineering/ai-bot": "^0.7.0",
"@hcengineering/analytics-service": "^0.7.5",
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/billing-client": "^0.7.0",
"@hcengineering/chunter": "^0.7.0",
"@hcengineering/client": "^0.7.6",
"@hcengineering/client-resources": "^0.7.6",
@@ -70,6 +72,7 @@
"@hcengineering/openai": "^0.7.0",
"@hcengineering/platform": "^0.7.5",
"@hcengineering/rank": "^0.7.5",
"@hcengineering/retry": "^0.7.5",
"@hcengineering/server-ai-bot": "^0.7.0",
"@hcengineering/server-client": "^0.7.8",
"@hcengineering/server-core": "^0.7.8",
@@ -89,7 +92,6 @@
"mongodb": "^6.16.0",
"openai": "^4.56.0",
"uuid": "^8.3.2",
"ws": "^8.18.2",
"@hcengineering/analytics-service": "^0.7.5"
"ws": "^8.18.2"
}
}
+219
View File
@@ -0,0 +1,219 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { groupByArray, MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import {
getClient as getBillingClient,
type BillingClient,
AiTranscriptData,
AiTokensData
} from '@hcengineering/billing-client'
import { withRetry } from '@hcengineering/retry'
import config from './config'
interface DeepgramRequest {
request_id: string
path: string
created: string
response?: {
details?: {
usd?: number
duration?: number
metadata?: Record<string, any>
tags?: string[]
}
code: number
completed: string
}
}
interface DeepgramRequestsResponse {
page: number
limit: number
requests?: DeepgramRequest[]
}
export interface TranscriptData {
workspace: WorkspaceUuid
day: string
requestId: string
startTime: string
durationSeconds: number
usd: number
}
async function fetchDeepgramRequests (
ctx: MeasureContext,
start?: Date,
end?: Date,
page?: number
): Promise<DeepgramRequestsResponse> {
const url = new URL(`https://api.deepgram.com/v1/projects/${config.DeepgramProjectId}/requests`)
if (start != null) {
url.searchParams.set('start', start.toISOString())
}
if (end != null) {
url.searchParams.set('end', end.toISOString())
}
if (page != null) {
url.searchParams.set('page', page.toString())
}
url.searchParams.set('limit', '100')
const res = await fetch(url, {
headers: { Authorization: `Token ${config.DeepgramApiKey}` }
})
if (!res.ok) {
const text = await res.text()
ctx.error('Failed to fetch deepgram requests', { status: res.status, text })
throw new Error(`Failed to fetch deepgram requests ${res.status}: ${text}`)
}
return await res.json()
}
function extractExtra (path: string): Record<string, any> {
try {
const query = path.split('?')[1]
if (query == null) return {}
const params = new URLSearchParams(query)
const extras = params.getAll('extra')
return Object.fromEntries(extras.map((pair) => pair.split(':', 2)).filter(([k, v]) => k != null && v != null))
} catch {
return {}
}
}
function extractWorkspace (req: DeepgramRequest): WorkspaceUuid | undefined {
const metadata = req.response?.details?.metadata ?? {}
if (metadata?.workspace != null && metadata.workspace !== '') return metadata.workspace
const extra = extractExtra(req.path ?? '')
const ws = extra.workspace
if (ws == null) return undefined
if (typeof ws !== 'string') return undefined
if (ws.trim() === '') return undefined
return ws as WorkspaceUuid
}
async function fetchLastData (ctx: MeasureContext, billingClient: BillingClient): Promise<AiTranscriptData | undefined> {
try {
return await billingClient.getAiTranscriptLastData()
} catch (e: any) {
if (e.name === 'NetworkError') {
throw e
}
return undefined
}
}
export async function updateDeepgramBilling (ctx: MeasureContext): Promise<void> {
if (config.DeepgramApiKey === '' || config.DeepgramProjectId === '' || config.DeepgramTag === '') return
ctx.info('Starting deepgram billing update')
const token = generateToken(systemAccountUuid, undefined, { service: 'ai-bot' })
const billingClient = getBillingClient(config.BillingUrl, token)
const lastData = await withRetry(() => fetchLastData(ctx, billingClient))
ctx.info('Last deepgram request', lastData)
const start = lastData != null ? new Date(lastData.lastStartTime) : undefined
const end = new Date()
let page = 0
const data: TranscriptData[] = []
while (true) {
const res = await withRetry(() => fetchDeepgramRequests(ctx, start, end, page))
const requests = res.requests ?? []
for (const req of requests) {
if (lastData != null && lastData.lastRequestId === req.request_id) continue
const tags = req.response?.details?.tags ?? []
if (!tags.includes(config.DeepgramTag)) continue
const workspace = extractWorkspace(req)
if (workspace == null) continue
const endTime = req.response?.completed
const durationSeconds = req.response?.details?.duration
const usd = req.response?.details?.usd
if (endTime == null || durationSeconds == null || usd == null) {
continue
}
const day = new Date(req.created)
day.setHours(0, 0, 0, 0)
data.push({
workspace,
day: day.toISOString(),
requestId: req.request_id,
startTime: req.created,
durationSeconds,
usd
})
}
if (requests.length < res.limit) break
page++
}
const groupped = groupByArray(data, (it) => `${it.workspace}:${it.day}`)
const requestData: AiTranscriptData[] = []
for (const [, values] of groupped.entries()) {
if (values.length === 0) continue
const last = values.reduce((a, b) => (new Date(a.startTime).getTime() > new Date(b.startTime).getTime() ? a : b))
const totalDurationSeconds = values.reduce((sum, it) => sum + (it.durationSeconds ?? 0), 0)
const totalUsd = values.reduce((sum, it) => sum + (it.usd ?? 0), 0)
const req: AiTranscriptData = {
workspace: values[0].workspace,
day: values[0].day,
lastRequestId: last.requestId,
lastStartTime: last.startTime,
durationSeconds: totalDurationSeconds,
usd: totalUsd
}
requestData.push(req)
}
if (requestData.length > 0) {
await billingClient.postAiTranscriptData(requestData)
}
ctx.info('Finished deepgram billing update')
}
export async function pushTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
if (config.BillingUrl === '') return
try {
const token = generateToken(systemAccountUuid, undefined, { service: 'ai-bot' })
const billingClient = getBillingClient(config.BillingUrl, token)
await billingClient.postAiTokensData(data)
} catch (e) {
ctx.error('Failed to push tokens data', { e })
}
}
+11 -1
View File
@@ -37,6 +37,11 @@ interface Config {
Port: number
LoveEndpoint: string
DataLabApiKey: string
BillingUrl: string
DeepgramPollIntervalMinutes: number
DeepgramApiKey: string
DeepgramProjectId: string
DeepgramTag: string
}
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
@@ -63,7 +68,12 @@ const config: Config = (() => {
MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500,
Port: parseNumber(process.env.PORT) ?? 4010,
LoveEndpoint: process.env.LOVE_ENDPOINT ?? '',
DataLabApiKey: process.env.DATALAB_API_KEY ?? ''
DataLabApiKey: process.env.DATALAB_API_KEY ?? '',
BillingUrl: process.env.BILLING_URL ?? '',
DeepgramPollIntervalMinutes: parseNumber(process.env.DEEPGRAM_POLL_INTERVAL_MINUTES) ?? 60,
DeepgramApiKey: process.env.DEEPGRAM_API_KEY ?? '',
DeepgramProjectId: process.env.DEEPGRAM_PROJECT_ID ?? '',
DeepgramTag: process.env.DEEPGRAM_TAG ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
+3 -3
View File
@@ -218,12 +218,12 @@ export class AIControl {
return this.workspaces.get(workspace)
}
async translate (req: TranslateRequest): Promise<TranslateResponse | undefined> {
async translate (workspace: WorkspaceUuid, req: TranslateRequest): Promise<TranslateResponse | undefined> {
if (this.openai === undefined) {
return undefined
}
const html = jsonToHTML(markupToJSON(req.text))
const result = await translateHtml(this.openai, html, req.lang)
const result = await translateHtml(this.ctx, workspace, this.openai, html, req.lang)
const text = result !== undefined ? htmlToMarkup(result) : req.text
return {
text,
@@ -302,7 +302,7 @@ export class AIControl {
}
}
const summary = await summarizeMessages(this.openai, messagesToSummarize, req.lang)
const summary = await summarizeMessages(this.ctx, workspace, this.openai, messagesToSummarize, req.lang)
if (summary === undefined) return
const summaryMarkup = jsonToMarkup(markdownToMarkup(summary))
@@ -60,11 +60,11 @@ export function createServer (controller: AIControl, ctx: MeasureContext): Expre
app.post(
'/translate',
wrapRequest(async (req, res) => {
wrapRequest(async (req, res, token) => {
if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
throw new ApiError(400)
}
const response = await controller.translate(req.body as TranslateRequest)
const response = await controller.translate(token.workspace, req.body as TranslateRequest)
if (response === undefined) {
throw new ApiError(500)
}
+19
View File
@@ -27,6 +27,7 @@ import { registerLoaders } from './loaders'
import { createServer, listen } from './server/server'
import { getDbStorage } from './storage'
import { getAccountUuid } from './utils/account'
import { updateDeepgramBilling } from './billing'
export const start = async (): Promise<void> => {
setMetadata(serverToken.metadata.Secret, config.ServerSecret)
@@ -74,7 +75,25 @@ export const start = async (): Promise<void> => {
const app = createServer(aiControl, ctx)
const server = listen(app, config.Port)
let billingIntervalId: any | undefined
if (config.BillingUrl !== '') {
billingIntervalId = setInterval(
() => {
try {
void updateDeepgramBilling(ctx)
} catch {}
},
config.DeepgramPollIntervalMinutes * 60 * 1000
)
try {
void updateDeepgramBilling(ctx)
} catch {}
}
const onClose = (): void => {
if (billingIntervalId !== undefined) {
clearInterval(billingIntervalId)
}
void aiControl.close()
storage.close()
server.close(() => process.exit())
+75 -9
View File
@@ -13,7 +13,7 @@
// limitations under the License.
//
import { AccountUuid, Ref } from '@hcengineering/core'
import { AccountUuid, MeasureContext, Ref, WorkspaceUuid } from '@hcengineering/core'
import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken'
import OpenAI from 'openai'
@@ -24,8 +24,15 @@ import config from '../config'
import { HistoryRecord } from '../types'
import { WorkspaceClient } from '../workspace/workspaceClient'
import { getTools } from './tools'
import { pushTokensData } from '../billing'
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
export async function translateHtml (
ctx: MeasureContext,
workspace: WorkspaceUuid,
client: OpenAI,
html: string,
lang: string
): Promise<string | undefined> {
const response = await client.chat.completions.create({
model: config.OpenAISummaryModel,
messages: [
@@ -40,10 +47,25 @@ export async function translateHtml (client: OpenAI, html: string, lang: string)
]
})
return response.choices[0].message.content ?? undefined
const responseText = response.choices[0].message.content ?? undefined
if (response.usage != null) {
void pushTokensData(ctx, [
{
workspace,
reason: 'manual-translate',
tokens: response.usage.total_tokens,
date: new Date(response.created * 1000).toISOString()
}
])
}
return responseText
}
export async function summarizeMessages (
ctx: MeasureContext,
workspace: WorkspaceUuid,
client: OpenAI,
messages: PersonMessage[],
lang: string
@@ -95,6 +117,17 @@ export async function summarizeMessages (
]
})
if (response.usage != null) {
void pushTokensData(ctx, [
{
workspace,
reason: 'summarize',
tokens: response.usage.total_tokens,
date: new Date(response.created * 1000).toISOString()
}
])
}
let responseText = response.choices[0].message.content ?? undefined
if (responseText === undefined) return
@@ -110,18 +143,21 @@ export async function summarizeMessages (
}
export async function createChatCompletion (
ctx: MeasureContext,
workspace: WorkspaceUuid,
client: OpenAI,
message: OpenAI.ChatCompletionMessageParam,
user?: string,
history: OpenAI.ChatCompletionMessageParam[] = [],
skipCache = true
skipCache = true,
reason = 'chat'
): Promise<OpenAI.ChatCompletion | undefined> {
const opt: OpenAI.RequestOptions = {}
if (skipCache) {
opt.headers = { 'cf-skip-cache': 'true' }
}
try {
return await client.chat.completions.create(
const response = await client.chat.completions.create(
{
messages: [...history, message],
model: config.OpenAIModel,
@@ -130,6 +166,19 @@ export async function createChatCompletion (
},
opt
)
if (response.usage != null) {
void pushTokensData(ctx, [
{
workspace,
reason,
tokens: response.usage.total_tokens,
date: new Date(response.created * 1000).toISOString()
}
])
}
return response
} catch (e) {
console.error(e)
}
@@ -143,7 +192,8 @@ export async function createChatCompletionWithTools (
message: OpenAI.ChatCompletionMessageParam,
user?: AccountUuid,
history: OpenAI.ChatCompletionMessageParam[] = [],
skipCache = true
skipCache = true,
reason = 'chat'
): Promise<
| {
completion: string | undefined
@@ -152,6 +202,7 @@ export async function createChatCompletionWithTools (
| undefined
> {
const opt: OpenAI.RequestOptions = {}
const date = new Date()
if (skipCache) {
opt.headers = { 'cf-skip-cache': 'true' }
}
@@ -172,11 +223,24 @@ export async function createChatCompletionWithTools (
},
opt
)
const str = await res.finalContent()
const usage = (await res.totalUsage()).completion_tokens
const usage = await res.totalUsage()
if (usage != null) {
void pushTokensData(workspaceClient.ctx, [
{
workspace: workspaceClient.wsIds.uuid,
reason,
tokens: usage.total_tokens,
date: date.toISOString()
}
])
}
return {
completion: str ?? undefined,
usage
usage: usage.completion_tokens
}
} catch (e) {
console.error(e)
@@ -186,6 +250,8 @@ export async function createChatCompletionWithTools (
}
export async function requestSummary (
ctx: MeasureContext,
workspace: WorkspaceUuid,
aiClient: OpenAI,
encoding: Tiktoken,
history: HistoryRecord[]
@@ -198,7 +264,7 @@ export async function requestSummary (
role: 'user'
}
const response = await createChatCompletion(aiClient, summaryPrompt, undefined, [
const response = await createChatCompletion(ctx, workspace, aiClient, summaryPrompt, undefined, [
{ role: 'system', content: 'Make a summary of messages history' }
])
@@ -248,7 +248,13 @@ export class WorkspaceClient {
}
this.summarizing.add(objectId)
const { summary, tokens } = await requestSummary(this.openai, this.openaiEncoding, toSummarize)
const { summary, tokens } = await requestSummary(
this.ctx,
this.wsIds.uuid,
this.openai,
this.openaiEncoding,
toSummarize
)
if (summary === undefined) {
this.ctx.error('Failed to summarize history', { objectId, objectClass, user })
+64 -2
View File
@@ -15,7 +15,7 @@
import type { Request, Response } from 'express'
import { MeasureContext, systemAccountUuid, WorkspaceUuid } from '@hcengineering/core'
import { LiveKitSessionData, BillingDB, LiveKitEgressData } from './types'
import { LiveKitSessionData, BillingDB, LiveKitEgressData, AiUsageData, AiTranscriptData, AiTokensData } from './types'
import { generateToken } from '@hcengineering/server-token'
import { StorageConfig } from '@hcengineering/server-core'
import { createDatalakeClient, DatalakeConfig, WorkspaceStats } from '@hcengineering/datalake'
@@ -78,7 +78,12 @@ export async function handleGetStats (
const { fromDate, toDate } = parseDateParameters(req)
const liveKitStats = await db.getLiveKitStats(ctx, workspace, fromDate, toDate)
const datalakeStats = await collectDatalakeStats(ctx, workspace, storageConfigs)
res.status(200).json({ liveKitStats, datalakeStats })
const aiStats: AiUsageData = {
transcript: await db.getAiTranscriptStats(ctx, workspace, fromDate, toDate),
tokens: await db.getAiTokensStats(ctx, workspace, fromDate, toDate)
}
res.status(200).json({ liveKitStats, datalakeStats, aiStats })
}
export async function handleGetLiveKitStats (
@@ -104,6 +109,63 @@ export async function handleGetDatalakeStats (
res.status(200).json(await collectDatalakeStats(ctx, workspace, storageConfigs))
}
export async function handleGetAiStats (
ctx: MeasureContext,
db: BillingDB,
storageConfigs: StorageConfig[],
req: Request,
res: Response
): Promise<void> {
const workspace = getWorkspaceUuid(req)
const { fromDate, toDate } = parseDateParameters(req)
const usage: AiUsageData = {
transcript: await db.getAiTranscriptStats(ctx, workspace, fromDate, toDate),
tokens: await db.getAiTokensStats(ctx, workspace, fromDate, toDate)
}
res.status(200).json(usage)
}
export async function handleGetAiTranscriptLastData (
ctx: MeasureContext,
db: BillingDB,
storageConfigs: StorageConfig[],
req: Request,
res: Response
): Promise<void> {
const last = await db.getAiTranscriptLastData(ctx)
if (last === undefined) {
res.status(404).send()
return
}
res.status(200).json(last)
}
export async function handlePushAiTranscriptData (
ctx: MeasureContext,
db: BillingDB,
storageConfigs: StorageConfig[],
req: Request,
res: Response
): Promise<void> {
const data = (await req.body) as AiTranscriptData[]
await db.pushAiTranscriptData(ctx, data)
res.status(204).send()
}
export async function handlePushAiTokensData (
ctx: MeasureContext,
db: BillingDB,
storageConfigs: StorageConfig[],
req: Request,
res: Response
): Promise<void> {
const data = (await req.body) as AiTokensData[]
await db.pushAiTokensData(ctx, data)
res.status(204).send()
}
async function collectDatalakeStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
+42 -1
View File
@@ -14,7 +14,16 @@
//
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
import { BillingDB, LiveKitEgressData, LiveKitSessionData, LiveKitUsageData } from '../types'
import {
AiTokensData,
AiTokensUsage,
AiTranscriptData,
AiTranscriptUsage,
BillingDB,
LiveKitEgressData,
LiveKitSessionData,
LiveKitUsageData
} from '../types'
export class LoggedDB implements BillingDB {
constructor (
@@ -46,4 +55,36 @@ export class LoggedDB implements BillingDB {
async setLiveKitEgress (ctx: MeasureContext, data: LiveKitEgressData[]): Promise<void> {
await ctx.with('db.setLiveKitEgress', {}, () => this.db.setLiveKitEgress(this.ctx, data))
}
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
await ctx.with('db.pushAiTranscriptData', {}, () => this.db.pushAiTranscriptData(this.ctx, data))
}
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
return await ctx.with('db.getAiTranscriptLastData', {}, () => this.db.getAiTranscriptLastData(this.ctx))
}
async getAiTranscriptStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTranscriptUsage> {
return await ctx.with('db.getAiTranscriptStats', {}, () =>
this.db.getAiTranscriptStats(this.ctx, workspace, start, end)
)
}
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
await ctx.with('db.pushAiTokensData', {}, () => this.db.pushAiTokensData(ctx, data))
}
async getAiTokensStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTokensUsage[]> {
return await ctx.with('db.getAiTokensStats', {}, () => this.db.getAiTokensStats(ctx, workspace, start, end))
}
}
@@ -14,7 +14,7 @@
//
export function getMigrations (): [string, string][] {
return [migrationV1()]
return [migrationV1(), migrationV2()]
}
function migrationV1 (): [string, string] {
@@ -50,3 +50,30 @@ function migrationV1 (): [string, string] {
`
return ['init_tables_01', sql]
}
function migrationV2 (): [string, string] {
const sql = `
CREATE TABLE IF NOT EXISTS billing.ai_transcript_usage (
workspace UUID NOT NULL,
day DATE NOT NULL,
last_request_id STRING(255) NOT NULL,
last_start_time TIMESTAMP NOT NULL,
total_duration_seconds FLOAT NOT NULL,
total_usd DECIMAL(12,6) NOT NULL,
PRIMARY KEY (workspace, day)
);
CREATE INDEX IF NOT EXISTS idx_ai_transcript_usage_day ON billing.ai_transcript_usage (day);
CREATE TABLE IF NOT EXISTS billing.ai_tokens_usage (
workspace UUID NOT NULL,
day DATE NOT NULL,
reason STRING(255) NOT NULL,
total_tokens INT8 NOT NULL,
PRIMARY KEY (workspace, day, reason)
);
CREATE INDEX IF NOT EXISTS idx_ai_tokens_usage_day ON billing.ai_transcript_usage (day);
`
return ['init_ai_usage_tables_02', sql]
}
+188 -5
View File
@@ -14,12 +14,16 @@
//
import {
LiveKitEgressData,
LiveKitSessionData,
LiveKitUsageData,
AiTokensData,
AiTokensUsage,
AiTranscriptData,
AiTranscriptUsage,
BillingDB,
LiveKitEgressData,
LiveKitEgressUsageData,
LiveKitSessionData,
LiveKitSessionsUsageData,
LiveKitEgressUsageData
LiveKitUsageData
} from '../types'
import postgres, { type Row, Sql } from 'postgres'
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
@@ -52,7 +56,7 @@ export async function createDb (ctx: MeasureContext, connectionString: string):
return new LoggedDB(ctx, new RetryDB(db, { retries: 5 }))
}
export class PostgresDB implements BillingDB {
class PostgresDB implements BillingDB {
private constructor (private readonly sql: Sql) {}
static async create (ctx: MeasureContext, sql: Sql): Promise<PostgresDB> {
@@ -242,8 +246,187 @@ export class PostgresDB implements BillingDB {
await this.execute(query, params)
}
}
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
if (batch.length === 0) continue
const values: string[] = []
const params: any[] = []
let paramIndex = 1
for (const item of batch) {
const { workspace, lastRequestId, lastStartTime, durationSeconds, usd, day } = item
values.push(
`($${paramIndex++}::uuid, DATE($${paramIndex++}::timestamp), $${paramIndex++}::string, $${paramIndex++}::timestamp, $${paramIndex++}::float, $${paramIndex++}::decimal)`
)
params.push(workspace, day, lastRequestId, lastStartTime, durationSeconds, usd)
}
const query = `
INSERT INTO billing.ai_transcript_usage
(workspace, day, last_request_id, last_start_time, total_duration_seconds, total_usd)
VALUES ${values.join(',')}
ON CONFLICT (workspace, day)
DO UPDATE SET
total_duration_seconds = billing.ai_transcript_usage.total_duration_seconds + EXCLUDED.total_duration_seconds,
total_usd = billing.ai_transcript_usage.total_usd + EXCLUDED.total_usd,
last_request_id = CASE
WHEN EXCLUDED.last_start_time > billing.ai_transcript_usage.last_start_time
THEN EXCLUDED.last_request_id
ELSE billing.ai_transcript_usage.last_request_id
END,
last_start_time = GREATEST(billing.ai_transcript_usage.last_start_time, EXCLUDED.last_start_time);
`
await this.execute(query, params)
}
}
async getAiTranscriptStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTranscriptUsage> {
const baseSql = `
SELECT
SUM(total_duration_seconds) AS total_duration_seconds
FROM billing.ai_transcript_usage
`
let where = 'WHERE workspace = $1::uuid'
const params: any[] = [workspace]
let paramIndex = params.length + 1
if (start != null) {
const s = new Date(start)
s.setHours(0, 0, 0, 0)
where += ` AND day >= $${paramIndex++}::date`
params.push(s)
}
if (end != null) {
const e = new Date(end)
e.setHours(23, 59, 59, 999)
where += ` AND day <= $${paramIndex++}::date`
params.push(e)
}
const sql = [baseSql, where].join(' ')
const result = await this.execute(sql, params)
return {
totalDurationSeconds: Number(result[0]?.total_duration_seconds ?? 0)
}
}
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
const sql = `
SELECT *
FROM billing.ai_transcript_usage
ORDER BY day DESC
LIMIT 1`
const result = await this.execute(sql)
const last = result[0]
if (last == null) return undefined
return {
workspace: last.workspace,
day: last.day,
lastRequestId: last.last_request_id,
lastStartTime: last.last_start_time,
durationSeconds: Number(last?.total_duration_seconds ?? 0),
usd: Number(last.usd)
}
}
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
const BATCH_SIZE = 100
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
if (batch.length === 0) continue
const values: string[] = []
const params: any[] = []
let paramIndex = 1
for (const item of batch) {
const { workspace, reason, tokens, date } = item
values.push(
`($${paramIndex++}::uuid, $${paramIndex++}::date, $${paramIndex++}::string, $${paramIndex++}::int8)`
)
params.push(workspace, date, reason, tokens)
}
const sql = `
INSERT INTO billing.ai_tokens_usage (workspace, day, reason, total_tokens)
VALUES ${values.join(',')}
ON CONFLICT (workspace, day, reason)
DO UPDATE SET
total_tokens = billing.ai_tokens_usage.total_tokens + EXCLUDED.total_tokens;
`
await this.execute(sql, params)
}
}
async getAiTokensStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTokensUsage[]> {
const baseSql = `
SELECT
reason,
SUM(total_tokens) AS total_tokens
FROM billing.ai_tokens_usage
`
let where = 'WHERE workspace = $1::uuid'
const params: any[] = [workspace]
let paramIndex = 2
if (start != null) {
const s = new Date(start)
s.setHours(0, 0, 0, 0)
where += ` AND day >= $${paramIndex++}::date`
params.push(s)
}
if (end != null) {
const e = new Date(end)
e.setHours(23, 59, 59, 999)
where += ` AND day <= $${paramIndex++}::date`
params.push(e)
}
const groupBy = 'GROUP BY reason'
const orderBy = 'ORDER BY reason ASC'
const sql = [baseSql, where, groupBy, orderBy].join(' ')
const result = await this.execute(sql, params)
return result.map((row: any) => ({
reason: row.reason,
totalTokens: Number(row.total_tokens ?? 0)
}))
}
}
export default PostgresDB
function injectVars (sql: string, values: any[]): string {
return sql.replaceAll(/(\$\d+)/g, (_, idx) => {
return escape(values[parseInt(idx.substring(1)) - 1])
+40 -1
View File
@@ -14,7 +14,16 @@
//
import { MeasureContext, type WorkspaceUuid } from '@hcengineering/core'
import { BillingDB, LiveKitEgressData, LiveKitSessionData, LiveKitUsageData } from '../types'
import {
AiTokensData,
AiTokensUsage,
AiTranscriptData,
AiTranscriptUsage,
BillingDB,
LiveKitEgressData,
LiveKitSessionData,
LiveKitUsageData
} from '../types'
interface RetryOptions {
retries: number
@@ -67,4 +76,34 @@ export class RetryDB implements BillingDB {
async setLiveKitEgress (ctx: MeasureContext, data: LiveKitEgressData[]): Promise<void> {
await retry(() => this.db.setLiveKitEgress(ctx, data), this.options)
}
async pushAiTranscriptData (ctx: MeasureContext, data: AiTranscriptData[]): Promise<void> {
await retry(() => this.db.pushAiTranscriptData(ctx, data), this.options)
}
async getAiTranscriptLastData (ctx: MeasureContext): Promise<AiTranscriptData | undefined> {
return await retry(() => this.db.getAiTranscriptLastData(ctx), this.options)
}
async getAiTranscriptStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTranscriptUsage> {
return await retry(() => this.db.getAiTranscriptStats(ctx, workspace, start, end), this.options)
}
async pushAiTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
await retry(() => this.db.pushAiTokensData(ctx, data), this.options)
}
async getAiTokensStats (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
): Promise<AiTokensUsage[]> {
return await retry(() => this.db.getAiTokensStats(ctx, workspace, start, end), this.options)
}
}
+20 -1
View File
@@ -28,7 +28,10 @@ import {
handleListLiveKitEgress,
handleGetLiveKitStats,
handleGetDatalakeStats,
handleGetStats
handleGetStats,
handlePushAiTranscriptData,
handleGetAiTranscriptLastData,
handlePushAiTokensData
} from './billing'
import { BillingDB } from './types'
import { createDb } from './db/postgres'
@@ -140,6 +143,22 @@ export async function createServer (ctx: MeasureContext, config: Config): Promis
)
app.get('/api/v1/:workspace/stats', withToken, withOwner, wrapRequest(ctx, 'getStats', handleGetStats))
app.post(
'/api/v1/ai/transcript',
withToken,
withAdmin,
wrapRequest(ctx, 'pushAiTranscriptData', handlePushAiTranscriptData)
)
app.get(
'/api/v1/ai/transcript/last',
withToken,
withAdmin,
wrapRequest(ctx, 'getAiTranscriptLastData', handleGetAiTranscriptLastData)
)
app.post('/api/v1/ai/tokens', withToken, withAdmin, wrapRequest(ctx, 'pushAiTokensData', handlePushAiTokensData))
app.use((_req, res) => {
res.status(404).json({ message: 'Not Found' })
})
+47
View File
@@ -50,10 +50,57 @@ export interface LiveKitEgressData {
duration: number
}
export interface AiTranscriptUsage {
totalDurationSeconds: number
}
export interface AiTokensUsage {
reason: string
totalTokens: number
}
export interface AiUsageData {
transcript: AiTranscriptUsage
tokens: AiTokensUsage[]
}
export interface AiTranscriptData {
workspace: WorkspaceUuid
day: string
lastRequestId: string
lastStartTime: string
durationSeconds: number
usd: number
}
export interface AiTokensData {
workspace: WorkspaceUuid
reason: string
tokens: number
date: string
}
export interface BillingDB {
getLiveKitStats: (ctx: MeasureContext, workspace: WorkspaceUuid, start: Date, end: Date) => Promise<LiveKitUsageData>
listLiveKitSessions: (ctx: MeasureContext, workspace: WorkspaceUuid) => Promise<LiveKitSessionData[] | null>
listLiveKitEgress: (ctx: MeasureContext, workspace: WorkspaceUuid) => Promise<LiveKitEgressData[] | null>
setLiveKitSessions: (ctx: MeasureContext, data: LiveKitSessionData[]) => Promise<void>
setLiveKitEgress: (ctx: MeasureContext, data: LiveKitEgressData[]) => Promise<void>
pushAiTranscriptData: (ctx: MeasureContext, data: AiTranscriptData[]) => Promise<void>
getAiTranscriptLastData: (ctx: MeasureContext) => Promise<AiTranscriptData | undefined>
getAiTranscriptStats: (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
) => Promise<AiTranscriptUsage>
pushAiTokensData: (ctx: MeasureContext, data: AiTokensData[]) => Promise<void>
getAiTokensStats: (
ctx: MeasureContext,
workspace: WorkspaceUuid,
start?: Date,
end?: Date
) => Promise<AiTokensUsage[]>
}
+1
View File
@@ -56,6 +56,7 @@
"@hcengineering/analytics": "^0.7.5",
"@hcengineering/analytics-service": "^0.7.5",
"@hcengineering/api-client": "^0.7.5",
"@hcengineering/billing-client": "^0.7.0",
"@hcengineering/card": "^0.7.0",
"@hcengineering/communication-sdk-types": "^0.7.7",
"@hcengineering/communication-shared": "^0.7.7",
+31
View File
@@ -0,0 +1,31 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { AiTokensData, getClient as getBillingClient } from '@hcengineering/billing-client'
import { MeasureContext, systemAccountUuid } from '@hcengineering/core'
import { generateToken } from '@hcengineering/server-token'
import config from './config'
export async function pushTokensData (ctx: MeasureContext, data: AiTokensData[]): Promise<void> {
if (config.BillingUrl === '') return
try {
const token = generateToken(systemAccountUuid, undefined, { service: 'translate' })
const billingClient = getBillingClient(config.BillingUrl, token)
await billingClient.postAiTokensData(data)
} catch (e) {
ctx.error('Failed to push tokens data', { e })
}
}
+3 -1
View File
@@ -27,6 +27,7 @@ export interface Config {
Secret: string
ServiceId: string
HulylakeUrl: string
BillingUrl: string
}
const config: Config = (() => {
@@ -39,7 +40,8 @@ const config: Config = (() => {
ServiceId: process.env.SERVICE_ID ?? 'translate',
OpenAIKey: process.env.OPENAI_API_KEY,
OpenAIModel: (process.env.OPENAI_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? ''
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? '',
BillingUrl: process.env.BILLING_URL ?? ''
}
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
+15 -2
View File
@@ -37,6 +37,7 @@ import { MessageEventType, TranslateMessageEvent, UpdatePatchEvent } from '@hcen
import { Storage } from './storage'
import config from './config'
import { pushTokensData } from './billing'
export class Controller {
private readonly languagesByWorkspace = new Map<WorkspaceUuid, string[]>()
@@ -146,7 +147,7 @@ export class Controller {
const txes: Tx[] = []
for (const lang of translateTo) {
try {
const result = await withRetry(() => this.translate(message.content, lang))
const result = await withRetry(() => this.translate(workspace, message.content, lang))
if (result == null) continue
const translation = result?.translation ?? ''
@@ -211,7 +212,7 @@ export class Controller {
for (const lang of translateTo) {
try {
const result = await withRetry(() => this.translate(content, lang))
const result = await withRetry(() => this.translate(workspace, content, lang))
if (result == null) continue
const translation = result?.translation ?? ''
if (result?.original_language != null && result.original_language !== '') {
@@ -278,6 +279,7 @@ export class Controller {
}
private async translate (
workspace: WorkspaceUuid,
markdown: string,
lang: string
): Promise<{ original_language?: string, translation?: string } | undefined> {
@@ -312,6 +314,17 @@ Do not add any explanations, comments, or extra text outside the JSON.
]
})
if (response.usage != null) {
void pushTokensData(this.ctx, [
{
workspace,
reason: 'auto-translate',
tokens: response.usage.total_tokens,
date: new Date(response.created * 1000).toISOString()
}
])
}
const res = response.choices[0]?.message.content ?? ''
try {