Files
classroomio/packages/core/src/services/agent/usage.ts
T
Rotimi Best d5b07d356e feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
2026-06-07 18:43:05 +01:00

228 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { TokenBalance, TokenUsage } from '@cio/ai-assistant';
import {
aggregateTokenUsageByUser,
countRequests,
getDailyTokenUsageHistory,
getMonthlyTokenUsage,
getOrgCreditBalance,
insertTokenUsageAndDrainCredits,
summarizePurchases,
upsertCreditBalance
} from '@cio/db/queries/agent';
import type { UsageLeaderboardRow } from '@cio/db/queries/agent';
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
import { AppError } from '@cio/utils/errors';
function startOfCurrentMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
// ─── Plan-Based Token Allowances ─────────────────────────────────────────────
const PLAN_TOKEN_ALLOWANCES: Record<string, number> = {
BASIC: 500_000,
EARLY_ADOPTER: 3_000_000,
ENTERPRISE: 15_000_000
};
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
// Blended multiplier vs Gemini 3.1 Flash Lite baseline ($0.50/1M blended at 80/20 input/output mix); unknown → 1×.
// gemini-2.5-flash kept for backward compat with historical usage records.
const MODEL_COST_MULTIPLIER: Record<string, number> = {
'gemini-3.1-flash-lite': 1,
'gemini-2.5-flash': 1.5,
'gpt-5.4-mini': 4,
'claude-sonnet-4-6': 11,
'claude-haiku-4-5-20251001': 1.5,
'kimi-k2.6': 4
};
function computeCostUnits(promptTokens: number, completionTokens: number, model: string): number {
const multiplier = MODEL_COST_MULTIPLIER[model] ?? 1;
return Math.round((promptTokens + completionTokens) * multiplier);
}
async function getPlanAllowance(orgId: string): Promise<{ planName: string; allowance: number }> {
const activePlan = await getActiveOrganizationPlan(orgId);
if (!activePlan || !activePlan.planName) {
return { planName: 'BASIC', allowance: PLAN_TOKEN_ALLOWANCES.BASIC };
}
const planName = activePlan.planName;
const payload = activePlan.payload as Record<string, unknown> | null;
const customAllowance = payload?.aiTokenAllowance as number | undefined;
const allowance = customAllowance ?? PLAN_TOKEN_ALLOWANCES[planName] ?? 0;
return { planName, allowance };
}
export async function getMonthlyUsage(orgId: string): Promise<number> {
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
}
export async function getCreditBalance(orgId: string): Promise<number> {
return getOrgCreditBalance(orgId);
}
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
getPlanAllowance(orgId),
getMonthlyUsage(orgId),
getCreditBalance(orgId)
]);
const remainingAllowance = Math.max(0, allowance - monthlyUsage);
const remaining = remainingAllowance + creditBalance;
return {
used: monthlyUsage,
allowance,
creditBalance,
remaining
};
}
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
const balance = await getTokenBalance(orgId);
if (balance.remaining <= 0) {
throw new AppError('Token limit reached', 'TOKEN_LIMIT_REACHED', 402);
}
return balance;
}
export function computePurchasedTokenOverflow(params: {
allowance: number;
monthlyUsageBefore: number;
requestTokens: number;
}): number {
const allowanceRemainingBefore = Math.max(0, params.allowance - params.monthlyUsageBefore);
return Math.max(0, params.requestTokens - allowanceRemainingBefore);
}
/** Record token usage after an LLM call; drains purchased credits when usage exceeds plan allowance. */
export async function recordTokenUsage(
orgId: string,
userId: string,
courseId: string,
usage: TokenUsage,
model: string
): Promise<void> {
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
const { allowance } = await getPlanAllowance(orgId);
await insertTokenUsageAndDrainCredits({
orgId,
userId,
courseId,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
costUnits,
model,
planAllowance: allowance,
since: startOfCurrentMonth()
});
}
export async function getOrgPlanName(orgId: string): Promise<string> {
const { planName } = await getPlanAllowance(orgId);
return planName;
}
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
const planName = await getOrgPlanName(orgId);
return planName !== 'BASIC';
}
// ─── Usage History ───────────────────────────────────────────────────────────
export interface DailyUsage {
date: string;
tokens: number;
}
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
}
export interface PurchasedSummary {
totalPurchasedTokens: number;
totalSpentCents: number;
currency: 'USD';
currentBalance: number;
lastPurchaseAt: string | null;
}
export async function getPurchasedSummary(orgId: string): Promise<PurchasedSummary> {
const [summary, currentBalance] = await Promise.all([summarizePurchases(orgId), getCreditBalance(orgId)]);
return {
totalPurchasedTokens: summary.totalPurchasedTokens,
totalSpentCents: summary.totalSpentCents,
currency: 'USD',
currentBalance,
lastPurchaseAt: summary.lastPurchaseAt
};
}
export interface LeaderboardEntry {
userId: string;
fullname: string | null;
email: string | null;
avatarUrl: string | null;
tokens: number;
requests: number;
favoriteModel: string | null;
percentage: number;
}
export async function getTeamLeaderboard(orgId: string): Promise<{ entries: LeaderboardEntry[]; totalTokens: number }> {
const rows: UsageLeaderboardRow[] = await aggregateTokenUsageByUser(orgId, startOfCurrentMonth());
const totalTokens = rows.reduce((sum: number, row: UsageLeaderboardRow) => sum + row.tokens, 0);
const entries: LeaderboardEntry[] = rows.map((row: UsageLeaderboardRow) => ({
userId: row.userId,
fullname: row.fullname,
email: row.email,
avatarUrl: row.avatarUrl,
tokens: row.tokens,
requests: row.requests,
favoriteModel: row.favoriteModel,
percentage: totalTokens > 0 ? row.tokens / totalTokens : 0
}));
return { entries, totalTokens };
}
export async function getDetailedUsage(orgId: string) {
const start = startOfCurrentMonth();
const [balance, history, requestsThisMonth] = await Promise.all([
getTokenBalance(orgId),
getDailyTokenUsageHistory(orgId, start),
countRequests(orgId, start)
]);
return { ...balance, history, requestsThisMonth };
}
// ─── Credit Purchases ────────────────────────────────────────────────────────
export async function addCredits(orgId: string, amount: number): Promise<number> {
if (amount <= 0) {
throw new AppError('Credit amount must be positive', 'INVALID_CREDIT_AMOUNT', 400);
}
return upsertCreditBalance(orgId, amount);
}