chore: ai, public course and other ui improvements
This commit is contained in:
@@ -1,22 +1,51 @@
|
||||
import { db } from '@cio/db';
|
||||
import * as schema from '@cio/db/schema';
|
||||
import { eq, and, gte, sql } from 'drizzle-orm';
|
||||
import { getActiveOrganizationPlan } from '@cio/db/queries/organization';
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
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 '@api/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: 50_000,
|
||||
EARLY_ADOPTER: 500_000,
|
||||
ENTERPRISE: 2_000_000
|
||||
BASIC: 500_000,
|
||||
EARLY_ADOPTER: 3_000_000,
|
||||
ENTERPRISE: 15_000_000
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the monthly token allowance for an org based on its active plan.
|
||||
* Also checks if the plan payload has a custom `aiTokenAllowance` override.
|
||||
*/
|
||||
// ─── Model Cost Multipliers ───────────────────────────────────────────────────
|
||||
|
||||
// Blended multiplier vs Gemini 2.5 Flash baseline ($0.15/1M); ~80/20 input/output mix; unknown → 1×.
|
||||
const MODEL_COST_MULTIPLIER: Record<string, number> = {
|
||||
'gemini-2.5-flash': 1,
|
||||
'gpt-5.4-mini': 3,
|
||||
'claude-sonnet-4-6': 11,
|
||||
'claude-haiku-4-5-20251001': 1,
|
||||
'kimi-k2.6': 3
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -32,42 +61,16 @@ async function getPlanAllowance(orgId: string): Promise<{ planName: string; allo
|
||||
return { planName, allowance };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total tokens used by an org in the current month.
|
||||
*/
|
||||
async function getMonthlyUsage(orgId: string): Promise<number> {
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const [result] = await db
|
||||
.select({
|
||||
totalTokens: sql<number>`COALESCE(SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens}), 0)`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())));
|
||||
|
||||
return Number(result?.totalTokens ?? 0);
|
||||
export async function getMonthlyUsage(orgId: string): Promise<number> {
|
||||
return getMonthlyTokenUsage(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the credit balance for an org.
|
||||
*/
|
||||
async function getCreditBalance(orgId: string): Promise<number> {
|
||||
const [result] = await db
|
||||
.select({ balance: schema.aiCreditBalance.balance })
|
||||
.from(schema.aiCreditBalance)
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
return result?.balance ?? 0;
|
||||
export async function getCreditBalance(orgId: string): Promise<number> {
|
||||
return getOrgCreditBalance(orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full token balance for an org: used, allowance, credits, remaining.
|
||||
*/
|
||||
export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const [{ planName, allowance }, monthlyUsage, creditBalance] = await Promise.all([
|
||||
const [{ allowance }, monthlyUsage, creditBalance] = await Promise.all([
|
||||
getPlanAllowance(orgId),
|
||||
getMonthlyUsage(orgId),
|
||||
getCreditBalance(orgId)
|
||||
@@ -84,9 +87,6 @@ export async function getTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the org has sufficient tokens. Throws 402 if exhausted.
|
||||
*/
|
||||
export async function enforceTokenBalance(orgId: string): Promise<TokenBalance> {
|
||||
const balance = await getTokenBalance(orgId);
|
||||
|
||||
@@ -97,9 +97,17 @@ export async function enforceTokenBalance(orgId: string): Promise<TokenBalance>
|
||||
return balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record token usage after an LLM call.
|
||||
*/
|
||||
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,
|
||||
@@ -107,29 +115,28 @@ export async function recordTokenUsage(
|
||||
usage: TokenUsage,
|
||||
model: string
|
||||
): Promise<void> {
|
||||
await db.insert(schema.aiTokenUsage).values({
|
||||
const costUnits = computeCostUnits(usage.promptTokens, usage.completionTokens, model);
|
||||
const { allowance } = await getPlanAllowance(orgId);
|
||||
|
||||
await insertTokenUsageAndDrainCredits({
|
||||
orgId,
|
||||
userId,
|
||||
courseId,
|
||||
promptTokens: usage.promptTokens,
|
||||
completionTokens: usage.completionTokens,
|
||||
model
|
||||
costUnits,
|
||||
model,
|
||||
planAllowance: allowance,
|
||||
since: startOfCurrentMonth()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an org's plan supports a specific feature (e.g., document upload).
|
||||
* Returns the plan name so callers can decide what to gate.
|
||||
*/
|
||||
export async function getOrgPlanName(orgId: string): Promise<string> {
|
||||
const { planName } = await getPlanAllowance(orgId);
|
||||
|
||||
return planName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an org is on a paid plan (EARLY_ADOPTER or above).
|
||||
*/
|
||||
export async function isOrgOnPaidPlan(orgId: string): Promise<boolean> {
|
||||
const planName = await getOrgPlanName(orgId);
|
||||
|
||||
@@ -143,67 +150,76 @@ export interface DailyUsage {
|
||||
tokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily token usage for the current month, grouped by day.
|
||||
*/
|
||||
export async function getUsageHistory(orgId: string): Promise<DailyUsage[]> {
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
date: sql<string>`DATE(${schema.aiTokenUsage.createdAt})`,
|
||||
tokens: sql<number>`SUM(${schema.aiTokenUsage.promptTokens} + ${schema.aiTokenUsage.completionTokens})`
|
||||
})
|
||||
.from(schema.aiTokenUsage)
|
||||
.where(and(eq(schema.aiTokenUsage.orgId, orgId), gte(schema.aiTokenUsage.createdAt, startOfMonth.toISOString())))
|
||||
.groupBy(sql`DATE(${schema.aiTokenUsage.createdAt})`)
|
||||
.orderBy(sql`DATE(${schema.aiTokenUsage.createdAt})`);
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: String(row.date),
|
||||
tokens: Number(row.tokens)
|
||||
}));
|
||||
return getDailyTokenUsageHistory(orgId, startOfCurrentMonth());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full detailed usage stats for the usage endpoint.
|
||||
*/
|
||||
export async function getDetailedUsage(orgId: string) {
|
||||
const [balance, history] = await Promise.all([getTokenBalance(orgId), getUsageHistory(orgId)]);
|
||||
export interface PurchasedSummary {
|
||||
totalPurchasedTokens: number;
|
||||
totalSpentCents: number;
|
||||
currency: 'USD';
|
||||
currentBalance: number;
|
||||
lastPurchaseAt: string | null;
|
||||
}
|
||||
|
||||
return { ...balance, history };
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add credits to an org's balance.
|
||||
* Creates the balance row if it doesn't exist (upsert).
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: schema.aiCreditBalance.id, balance: schema.aiCreditBalance.balance })
|
||||
.from(schema.aiCreditBalance)
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const newBalance = existing[0].balance + amount;
|
||||
await db
|
||||
.update(schema.aiCreditBalance)
|
||||
.set({ balance: newBalance, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.aiCreditBalance.orgId, orgId));
|
||||
|
||||
return newBalance;
|
||||
}
|
||||
|
||||
await db.insert(schema.aiCreditBalance).values({ orgId, balance: amount });
|
||||
|
||||
return amount;
|
||||
return upsertCreditBalance(orgId, amount);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user