feat(api-ai): add usage log query endpoint
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from "@nestjs/common";
|
import { Body, Controller, Get, Headers, Post, Query, UnauthorizedException } from "@nestjs/common";
|
||||||
import { AiChatDto } from "./dto/ai-chat.dto";
|
import { AiChatDto } from "./dto/ai-chat.dto";
|
||||||
|
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
|
||||||
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
|
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
|
||||||
import { AiChatResponse, AiService, ListAiBindingsResponse } from "./ai.service";
|
import {
|
||||||
|
AiChatResponse,
|
||||||
|
AiService,
|
||||||
|
ListAiBindingsResponse,
|
||||||
|
ListAiUsageLogsResponse
|
||||||
|
} from "./ai.service";
|
||||||
|
|
||||||
@Controller("ai")
|
@Controller("ai")
|
||||||
export class AiController {
|
export class AiController {
|
||||||
@@ -14,6 +20,14 @@ export class AiController {
|
|||||||
return this.aiService.listBindings(this.resolveUserId(userIdHeader));
|
return this.aiService.listBindings(this.resolveUserId(userIdHeader));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("usage-logs")
|
||||||
|
async listUsageLogs(
|
||||||
|
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
||||||
|
@Query() query: ListAiUsageLogsQueryDto
|
||||||
|
): Promise<ListAiUsageLogsResponse> {
|
||||||
|
return this.aiService.listUsageLogs(this.resolveUserId(userIdHeader), query);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("bindings")
|
@Post("bindings")
|
||||||
async upsertBinding(
|
async upsertBinding(
|
||||||
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
AiChannel,
|
AiChannel,
|
||||||
|
AiUsageLog,
|
||||||
AiProviderBinding,
|
AiProviderBinding,
|
||||||
AiPublicPoolConfig,
|
AiPublicPoolConfig,
|
||||||
Prisma,
|
Prisma,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
||||||
import { AiChatDto } from "./dto/ai-chat.dto";
|
import { AiChatDto } from "./dto/ai-chat.dto";
|
||||||
|
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
|
||||||
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
|
import { UpsertAiProviderBindingDto } from "./dto/upsert-ai-provider-binding.dto";
|
||||||
import {
|
import {
|
||||||
AiResolvedRouteCandidate,
|
AiResolvedRouteCandidate,
|
||||||
@@ -61,6 +63,27 @@ export type ListAiBindingsResponse = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiUsageLogSummary = {
|
||||||
|
id: string;
|
||||||
|
channel: AiChannel;
|
||||||
|
providerName: string | null;
|
||||||
|
model: string | null;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
latencyMs: number | null;
|
||||||
|
success: boolean;
|
||||||
|
errorCode: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListAiUsageLogsResponse = {
|
||||||
|
items: AiUsageLogSummary[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AiChatResponse = {
|
export type AiChatResponse = {
|
||||||
channel: AiChannel;
|
channel: AiChannel;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
@@ -111,6 +134,47 @@ export class AiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listUsageLogs(
|
||||||
|
userId: string,
|
||||||
|
query: ListAiUsageLogsQueryDto
|
||||||
|
): Promise<ListAiUsageLogsResponse> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
const where: Prisma.AiUsageLogWhereInput = {
|
||||||
|
userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.channel) {
|
||||||
|
where.channel = query.channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.success !== undefined) {
|
||||||
|
where.success = query.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prismaService.aiUsageLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc"
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: pageSize
|
||||||
|
}),
|
||||||
|
this.prismaService.aiUsageLog.count({
|
||||||
|
where
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serializeUsageLog(item)),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async upsertBinding(userId: string, dto: UpsertAiProviderBindingDto): Promise<AiBindingSummary> {
|
async upsertBinding(userId: string, dto: UpsertAiProviderBindingDto): Promise<AiBindingSummary> {
|
||||||
if (dto.channel === AiChannel.PUBLIC_POOL) {
|
if (dto.channel === AiChannel.PUBLIC_POOL) {
|
||||||
throw new BadRequestException("公共 AI 通道只能由管理员配置");
|
throw new BadRequestException("公共 AI 通道只能由管理员配置");
|
||||||
@@ -421,6 +485,22 @@ export class AiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializeUsageLog(log: AiUsageLog): AiUsageLogSummary {
|
||||||
|
return {
|
||||||
|
id: log.id,
|
||||||
|
channel: log.channel,
|
||||||
|
providerName: log.providerName,
|
||||||
|
model: log.model,
|
||||||
|
promptTokens: log.promptTokens,
|
||||||
|
completionTokens: log.completionTokens,
|
||||||
|
totalTokens: log.totalTokens,
|
||||||
|
latencyMs: log.latencyMs,
|
||||||
|
success: log.success,
|
||||||
|
errorCode: log.errorCode,
|
||||||
|
createdAt: log.createdAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async buildPromptMessage(userId: string, userMessage: string): Promise<string> {
|
private async buildPromptMessage(userId: string, userMessage: string): Promise<string> {
|
||||||
const taskSummary = await this.buildTaskContextSummary(userId);
|
const taskSummary = await this.buildTaskContextSummary(userId);
|
||||||
if (!taskSummary) {
|
if (!taskSummary) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsEnum, IsInt, IsOptional, Max, Min } from "class-validator";
|
||||||
|
import { AiChannel } from "../../../generated/prisma/client";
|
||||||
|
|
||||||
|
function normalizeBoolean(value: unknown): boolean | undefined {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "true" || normalized === "1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "false" || normalized === "0") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListAiUsageLogsQueryDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AiChannel)
|
||||||
|
channel?: AiChannel;
|
||||||
|
|
||||||
|
@Transform(({ value }) => normalizeBoolean(value))
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
+168
-5
@@ -3,6 +3,7 @@ import { INestApplication, ValidationPipe } from "@nestjs/common";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import {
|
import {
|
||||||
AiChannel,
|
AiChannel,
|
||||||
|
AiUsageLog,
|
||||||
AiProviderBinding,
|
AiProviderBinding,
|
||||||
AiPublicPoolConfig,
|
AiPublicPoolConfig,
|
||||||
TaskPriority,
|
TaskPriority,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
import { PrismaService } from "../src/prisma/prisma.service";
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
|
||||||
type AiUsageLogRecord = {
|
type AiUsageLogRecord = {
|
||||||
|
id: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
channel: AiChannel;
|
channel: AiChannel;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
@@ -30,6 +32,7 @@ type AiUsageLogRecord = {
|
|||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
errorCode: string | null;
|
errorCode: string | null;
|
||||||
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AiTaskRecord = {
|
type AiTaskRecord = {
|
||||||
@@ -45,6 +48,7 @@ type AiTaskRecord = {
|
|||||||
class InMemoryAiPrismaService {
|
class InMemoryAiPrismaService {
|
||||||
private bindingIdSequence = 1;
|
private bindingIdSequence = 1;
|
||||||
private publicPoolIdSequence = 1;
|
private publicPoolIdSequence = 1;
|
||||||
|
private usageLogIdSequence = 1;
|
||||||
private bindings: AiProviderBinding[] = [];
|
private bindings: AiProviderBinding[] = [];
|
||||||
private publicPools: AiPublicPoolConfig[] = [];
|
private publicPools: AiPublicPoolConfig[] = [];
|
||||||
private usageLogs: AiUsageLogRecord[] = [];
|
private usageLogs: AiUsageLogRecord[] = [];
|
||||||
@@ -197,9 +201,47 @@ class InMemoryAiPrismaService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
readonly aiUsageLog = {
|
readonly aiUsageLog = {
|
||||||
create: async (args: { data: AiUsageLogRecord }) => {
|
create: async (args: { data: Omit<AiUsageLog, "id" | "createdAt"> }) => {
|
||||||
this.usageLogs.push(args.data);
|
const usageLog: AiUsageLogRecord = {
|
||||||
return args.data;
|
id: `usage_log_${this.usageLogIdSequence++}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...args.data
|
||||||
|
};
|
||||||
|
|
||||||
|
this.usageLogs.push(usageLog);
|
||||||
|
return usageLog;
|
||||||
|
},
|
||||||
|
|
||||||
|
findMany: async (args: {
|
||||||
|
where?: {
|
||||||
|
userId?: string;
|
||||||
|
channel?: AiChannel;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
orderBy?: {
|
||||||
|
createdAt: "asc" | "desc";
|
||||||
|
};
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
}) => {
|
||||||
|
const filteredLogs = this.filterUsageLogs(args.where);
|
||||||
|
const sortedLogs = [...filteredLogs].sort((left, right) => {
|
||||||
|
const direction = args.orderBy?.createdAt === "asc" ? 1 : -1;
|
||||||
|
return (left.createdAt.getTime() - right.createdAt.getTime()) * direction;
|
||||||
|
});
|
||||||
|
const start = args.skip ?? 0;
|
||||||
|
const end = args.take === undefined ? undefined : start + args.take;
|
||||||
|
return sortedLogs.slice(start, end);
|
||||||
|
},
|
||||||
|
|
||||||
|
count: async (args?: {
|
||||||
|
where?: {
|
||||||
|
userId?: string;
|
||||||
|
channel?: AiChannel;
|
||||||
|
success?: boolean;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
return this.filterUsageLogs(args?.where).length;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,6 +300,33 @@ class InMemoryAiPrismaService {
|
|||||||
seedTask(task: AiTaskRecord): void {
|
seedTask(task: AiTaskRecord): void {
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seedUsageLog(log: Omit<AiUsageLogRecord, "id"> & { id?: string }): void {
|
||||||
|
this.usageLogs.push({
|
||||||
|
id: log.id ?? `usage_log_${this.usageLogIdSequence++}`,
|
||||||
|
...log
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterUsageLogs(where?: {
|
||||||
|
userId?: string;
|
||||||
|
channel?: AiChannel;
|
||||||
|
success?: boolean;
|
||||||
|
}): AiUsageLogRecord[] {
|
||||||
|
return this.usageLogs.filter((log) => {
|
||||||
|
if (where?.userId !== undefined && log.userId !== where.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.channel !== undefined && log.channel !== where.channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (where?.success !== undefined && log.success !== where.success) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class StaticExecutor implements AiChannelExecutor {
|
class StaticExecutor implements AiChannelExecutor {
|
||||||
@@ -458,6 +527,7 @@ describe("AiController (integration)", () => {
|
|||||||
]);
|
]);
|
||||||
expect(prismaService.getUsageLogs()).toEqual([
|
expect(prismaService.getUsageLogs()).toEqual([
|
||||||
{
|
{
|
||||||
|
id: expect.any(String),
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
channel: AiChannel.USER_KEY,
|
channel: AiChannel.USER_KEY,
|
||||||
providerName: "openai",
|
providerName: "openai",
|
||||||
@@ -467,9 +537,11 @@ describe("AiController (integration)", () => {
|
|||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
latencyMs: expect.any(Number),
|
latencyMs: expect.any(Number),
|
||||||
success: false,
|
success: false,
|
||||||
errorCode: "UPSTREAM_UNREACHABLE"
|
errorCode: "UPSTREAM_UNREACHABLE",
|
||||||
|
createdAt: expect.any(Date)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: expect.any(String),
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
channel: AiChannel.ASTRBOT,
|
channel: AiChannel.ASTRBOT,
|
||||||
providerName: "default",
|
providerName: "default",
|
||||||
@@ -479,7 +551,8 @@ describe("AiController (integration)", () => {
|
|||||||
totalTokens: 20,
|
totalTokens: 20,
|
||||||
latencyMs: expect.any(Number),
|
latencyMs: expect.any(Number),
|
||||||
success: true,
|
success: true,
|
||||||
errorCode: null
|
errorCode: null,
|
||||||
|
createdAt: expect.any(Date)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -593,4 +666,94 @@ describe("AiController (integration)", () => {
|
|||||||
]);
|
]);
|
||||||
expect(prismaService.getUsageLogs()).toEqual([]);
|
expect(prismaService.getUsageLogs()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
it("should list usage logs with pagination and filters", async () => {
|
||||||
|
prismaService.seedUsageLog({
|
||||||
|
id: "usage_log_1",
|
||||||
|
userId: "user_1",
|
||||||
|
channel: AiChannel.ASTRBOT,
|
||||||
|
providerName: "default",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 6,
|
||||||
|
totalTokens: 16,
|
||||||
|
latencyMs: 120,
|
||||||
|
success: true,
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: new Date("2026-04-06T08:00:00.000Z")
|
||||||
|
});
|
||||||
|
prismaService.seedUsageLog({
|
||||||
|
id: "usage_log_2",
|
||||||
|
userId: "user_1",
|
||||||
|
channel: AiChannel.ASTRBOT,
|
||||||
|
providerName: "default",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
promptTokens: 14,
|
||||||
|
completionTokens: 9,
|
||||||
|
totalTokens: 23,
|
||||||
|
latencyMs: 100,
|
||||||
|
success: true,
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: new Date("2026-04-06T09:00:00.000Z")
|
||||||
|
});
|
||||||
|
prismaService.seedUsageLog({
|
||||||
|
id: "usage_log_3",
|
||||||
|
userId: "user_1",
|
||||||
|
channel: AiChannel.USER_KEY,
|
||||||
|
providerName: "openai",
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 12,
|
||||||
|
totalTokens: 32,
|
||||||
|
latencyMs: 210,
|
||||||
|
success: false,
|
||||||
|
errorCode: "UPSTREAM_UNREACHABLE",
|
||||||
|
createdAt: new Date("2026-04-06T10:00:00.000Z")
|
||||||
|
});
|
||||||
|
prismaService.seedUsageLog({
|
||||||
|
id: "usage_log_4",
|
||||||
|
userId: "user_2",
|
||||||
|
channel: AiChannel.ASTRBOT,
|
||||||
|
providerName: "default",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
promptTokens: 18,
|
||||||
|
completionTokens: 11,
|
||||||
|
totalTokens: 29,
|
||||||
|
latencyMs: 90,
|
||||||
|
success: true,
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: new Date("2026-04-06T11:00:00.000Z")
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get("/ai/usage-logs")
|
||||||
|
.set("x-user-id", "user_1")
|
||||||
|
.query({
|
||||||
|
page: 2,
|
||||||
|
pageSize: 1,
|
||||||
|
channel: AiChannel.ASTRBOT,
|
||||||
|
success: true
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "usage_log_1",
|
||||||
|
channel: AiChannel.ASTRBOT,
|
||||||
|
providerName: "default",
|
||||||
|
model: "deepseek-chat",
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 6,
|
||||||
|
totalTokens: 16,
|
||||||
|
latencyMs: 120,
|
||||||
|
success: true,
|
||||||
|
errorCode: null,
|
||||||
|
createdAt: "2026-04-06T08:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 2,
|
||||||
|
pageSize: 1,
|
||||||
|
total: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user