Implements comprehensive LLM usage tracking with analytics endpoints. Implementation: - Added LlmUsageLog model to Prisma schema - Created llm-usage module with service, controller, and DTOs - Added tracking for token usage, costs, and durations - Implemented analytics aggregation by provider, model, and task type - Added filtering by workspace, provider, model, user, and date range Testing: - 20 unit tests with 90.8% coverage (exceeds 85% requirement) - Tests for service and controller with full error handling - Tests use Vitest following project conventions API Endpoints: - GET /api/llm-usage/analytics - Aggregated usage analytics - GET /api/llm-usage/by-workspace/:workspaceId - Workspace usage logs - GET /api/llm-usage/by-workspace/:workspaceId/provider/:provider - Provider logs - GET /api/llm-usage/by-workspace/:workspaceId/model/:model - Model logs Database: - LlmUsageLog table with indexes for efficient queries - Relations to User, Workspace, and LlmProviderInstance - Ready for migration with: pnpm prisma migrate dev Refs #309 Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { LlmUsageService } from "./llm-usage.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { TrackUsageDto, UsageAnalyticsQueryDto } from "./dto";
|
|
|
|
describe("LlmUsageService", () => {
|
|
let service: LlmUsageService;
|
|
let prisma: PrismaService;
|
|
|
|
const mockPrismaService = {
|
|
llmUsageLog: {
|
|
create: vi.fn(),
|
|
findMany: vi.fn(),
|
|
groupBy: vi.fn(),
|
|
aggregate: vi.fn(),
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
LlmUsageService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<LlmUsageService>(LlmUsageService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("should be defined", () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe("trackUsage", () => {
|
|
it("should create a new usage log entry", async () => {
|
|
const trackUsageDto: TrackUsageDto = {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
costCents: 0.15,
|
|
taskType: "chat",
|
|
durationMs: 1500,
|
|
};
|
|
|
|
const expectedResult = {
|
|
id: "usage-789",
|
|
...trackUsageDto,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.create.mockResolvedValue(expectedResult);
|
|
|
|
const result = await service.trackUsage(trackUsageDto);
|
|
|
|
expect(result).toEqual(expectedResult);
|
|
expect(mockPrismaService.llmUsageLog.create).toHaveBeenCalledWith({
|
|
data: trackUsageDto,
|
|
});
|
|
});
|
|
|
|
it("should handle optional fields correctly", async () => {
|
|
const trackUsageDto: TrackUsageDto = {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
};
|
|
|
|
const expectedResult = {
|
|
id: "usage-789",
|
|
...trackUsageDto,
|
|
costCents: null,
|
|
taskType: null,
|
|
durationMs: null,
|
|
providerInstanceId: null,
|
|
conversationId: null,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.create.mockResolvedValue(expectedResult);
|
|
|
|
const result = await service.trackUsage(trackUsageDto);
|
|
|
|
expect(result).toEqual(expectedResult);
|
|
});
|
|
|
|
it("should throw error when database operation fails", async () => {
|
|
const trackUsageDto: TrackUsageDto = {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.create.mockRejectedValue(new Error("Database error"));
|
|
|
|
await expect(service.trackUsage(trackUsageDto)).rejects.toThrow("Database error");
|
|
});
|
|
});
|
|
|
|
describe("getUsageAnalytics", () => {
|
|
it("should return aggregated usage analytics", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
};
|
|
|
|
const mockUsageLogs = [
|
|
{
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
taskType: "chat",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
costCents: 0.15,
|
|
durationMs: 1500,
|
|
},
|
|
{
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
taskType: "embed",
|
|
promptTokens: 200,
|
|
completionTokens: 0,
|
|
totalTokens: 200,
|
|
costCents: 0.1,
|
|
durationMs: 500,
|
|
},
|
|
];
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue(mockUsageLogs);
|
|
|
|
const result = await service.getUsageAnalytics(query);
|
|
|
|
expect(result.totalCalls).toBe(2);
|
|
expect(result.totalPromptTokens).toBe(300);
|
|
expect(result.totalCompletionTokens).toBe(50);
|
|
expect(result.totalTokens).toBe(350);
|
|
expect(result.totalCostCents).toBe(0.25);
|
|
expect(result.averageDurationMs).toBe(1000);
|
|
expect(result.byProvider).toHaveLength(1);
|
|
expect(result.byModel).toHaveLength(1);
|
|
expect(result.byTaskType).toHaveLength(2);
|
|
});
|
|
|
|
it("should filter by date range", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
startDate: "2024-01-01T00:00:00Z",
|
|
endDate: "2024-01-31T23:59:59Z",
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue([]);
|
|
|
|
await service.getUsageAnalytics(query);
|
|
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId: "workspace-123",
|
|
createdAt: {
|
|
gte: new Date("2024-01-01T00:00:00Z"),
|
|
lte: new Date("2024-01-31T23:59:59Z"),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should filter by provider", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
provider: "ollama",
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue([]);
|
|
|
|
await service.getUsageAnalytics(query);
|
|
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId: "workspace-123",
|
|
provider: "ollama",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should filter by model", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
model: "llama3.2",
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue([]);
|
|
|
|
await service.getUsageAnalytics(query);
|
|
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId: "workspace-123",
|
|
model: "llama3.2",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should filter by userId", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue([]);
|
|
|
|
await service.getUsageAnalytics(query);
|
|
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId: "workspace-123",
|
|
userId: "user-456",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should handle empty results", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
};
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue([]);
|
|
|
|
const result = await service.getUsageAnalytics(query);
|
|
|
|
expect(result.totalCalls).toBe(0);
|
|
expect(result.totalPromptTokens).toBe(0);
|
|
expect(result.totalCompletionTokens).toBe(0);
|
|
expect(result.totalTokens).toBe(0);
|
|
expect(result.totalCostCents).toBe(0);
|
|
expect(result.averageDurationMs).toBe(0);
|
|
expect(result.byProvider).toEqual([]);
|
|
expect(result.byModel).toEqual([]);
|
|
expect(result.byTaskType).toEqual([]);
|
|
});
|
|
|
|
it("should handle null values in aggregation", async () => {
|
|
const query: UsageAnalyticsQueryDto = {
|
|
workspaceId: "workspace-123",
|
|
};
|
|
|
|
const mockUsageLogs = [
|
|
{
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
taskType: null,
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
costCents: null,
|
|
durationMs: null,
|
|
},
|
|
];
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue(mockUsageLogs);
|
|
|
|
const result = await service.getUsageAnalytics(query);
|
|
|
|
expect(result.totalCalls).toBe(1);
|
|
expect(result.totalCostCents).toBe(0);
|
|
expect(result.averageDurationMs).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("getUsageByWorkspace", () => {
|
|
it("should return usage logs for a specific workspace", async () => {
|
|
const workspaceId = "workspace-123";
|
|
const mockLogs = [
|
|
{
|
|
id: "log-1",
|
|
workspaceId,
|
|
userId: "user-1",
|
|
provider: "ollama",
|
|
model: "llama3.2",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
createdAt: new Date(),
|
|
},
|
|
];
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue(mockLogs);
|
|
|
|
const result = await service.getUsageByWorkspace(workspaceId);
|
|
|
|
expect(result).toEqual(mockLogs);
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: { workspaceId },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getUsageByProvider", () => {
|
|
it("should return usage logs for a specific provider", async () => {
|
|
const workspaceId = "workspace-123";
|
|
const provider = "ollama";
|
|
const mockLogs = [
|
|
{
|
|
id: "log-1",
|
|
workspaceId,
|
|
userId: "user-1",
|
|
provider,
|
|
model: "llama3.2",
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
createdAt: new Date(),
|
|
},
|
|
];
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue(mockLogs);
|
|
|
|
const result = await service.getUsageByProvider(workspaceId, provider);
|
|
|
|
expect(result).toEqual(mockLogs);
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: { workspaceId, provider },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getUsageByModel", () => {
|
|
it("should return usage logs for a specific model", async () => {
|
|
const workspaceId = "workspace-123";
|
|
const model = "llama3.2";
|
|
const mockLogs = [
|
|
{
|
|
id: "log-1",
|
|
workspaceId,
|
|
userId: "user-1",
|
|
provider: "ollama",
|
|
model,
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
totalTokens: 150,
|
|
createdAt: new Date(),
|
|
},
|
|
];
|
|
|
|
mockPrismaService.llmUsageLog.findMany.mockResolvedValue(mockLogs);
|
|
|
|
const result = await service.getUsageByModel(workspaceId, model);
|
|
|
|
expect(result).toEqual(mockLogs);
|
|
expect(mockPrismaService.llmUsageLog.findMany).toHaveBeenCalledWith({
|
|
where: { workspaceId, model },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
});
|
|
});
|
|
});
|