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 { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.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")
|
||||
export class AiController {
|
||||
@@ -14,6 +20,14 @@ export class AiController {
|
||||
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")
|
||||
async upsertBinding(
|
||||
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
AiChannel,
|
||||
AiUsageLog,
|
||||
AiProviderBinding,
|
||||
AiPublicPoolConfig,
|
||||
Prisma,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
||||
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 {
|
||||
AiResolvedRouteCandidate,
|
||||
@@ -61,6 +63,27 @@ export type ListAiBindingsResponse = {
|
||||
} | 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 = {
|
||||
channel: AiChannel;
|
||||
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> {
|
||||
if (dto.channel === AiChannel.PUBLIC_POOL) {
|
||||
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> {
|
||||
const taskSummary = await this.buildTaskContextSummary(userId);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user