send, process, store, display virtual api key ai information in usage and sessions

This commit is contained in:
Owen
2026-08-12 11:20:25 -04:00
parent 355294a2d4
commit bed5817da3
20 changed files with 849 additions and 68 deletions
+10
View File
@@ -1989,6 +1989,14 @@
"aiUsageUserCost": "User Cost",
"aiUsageUserTokenUsage": "User Token Usage",
"aiUsageUnknownUser": "Unknown user",
"aiUsageVirtualApiKeysTab": "Virtual API Keys",
"aiUsageFilterVirtualApiKey": "Virtual API Key",
"aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys",
"aiUsageTopVirtualApiKeys": "Top Virtual API Keys",
"aiUsageVirtualApiKeyCost": "Virtual API Key Cost",
"aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage",
"aiUsageUnknownVirtualApiKey": "No virtual API key",
"aiUsageUnnamedVirtualApiKey": "Unnamed key",
"aiUsageLoading": "Loading...",
"aiUsageNoData": "No data",
"resourceBudgetSettings": "Budget",
@@ -3457,6 +3465,8 @@
"provider": "Provider",
"capability": "Capability",
"model": "Model",
"virtualApiKey": "Virtual API Key",
"noVirtualApiKey": "No virtual API key",
"stream": "Stream",
"streaming": "Streaming",
"nonStreaming": "Non-streaming",
+24 -6
View File
@@ -1819,18 +1819,22 @@ export const aiUsageRecords = pgTable(
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
.references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
@@ -1870,6 +1874,11 @@ export const aiUsageRecords = pgTable(
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
@@ -1927,19 +1936,23 @@ export const aiSessionLog = pgTable(
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
.references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
@@ -1979,6 +1992,11 @@ export const aiSessionLog = pgTable(
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
+24 -6
View File
@@ -1807,18 +1807,22 @@ export const aiUsageRecords = sqliteTable(
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
.references(() => aiProviders.providerId, { onDelete: "set null" }),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
@@ -1860,6 +1864,11 @@ export const aiUsageRecords = sqliteTable(
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
@@ -1917,19 +1926,23 @@ export const aiSessionLog = sqliteTable(
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
.references(() => aiProviders.providerId, { onDelete: "set null" }),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
@@ -1973,6 +1986,11 @@ export const aiSessionLog = sqliteTable(
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
+30 -6
View File
@@ -1,4 +1,14 @@
import { and, eq, gte, inArray, isNull, or, sql, SQL, type InferInsertModel } from "drizzle-orm";
import {
and,
eq,
gte,
inArray,
isNull,
or,
sql,
SQL,
type InferInsertModel
} from "drizzle-orm";
import {
AiBudget,
aiBudgetBreachEvents,
@@ -424,6 +434,7 @@ export type UsageRecordInput = {
resourceId: number | null;
siteResourceId: number | null;
userId: string | null;
virtualApiKeyId: string | null;
requestedModel: string;
usage: AiUsage;
costUsd: number | null;
@@ -460,7 +471,10 @@ async function flushUsageRecords() {
isUsageFlushInProgress = true;
const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length);
const recordsToWrite = usageRecordBuffer.splice(
0,
usageRecordBuffer.length
);
try {
// Use a transaction to ensure all inserts succeed or fail together
@@ -472,16 +486,25 @@ async function flushUsageRecords() {
await tx.insert(aiUsageRecords).values(batch);
}
});
logger.debug(`Flushed ${recordsToWrite.length} AI usage records to database`);
logger.debug(
`Flushed ${recordsToWrite.length} AI usage records to database`
);
} catch (error) {
logger.error("Error flushing AI usage records:", error);
// On transaction error, put records back at the front of the buffer
// to retry, but only if the buffer isn't too large
if (usageRecordBuffer.length < USAGE_MAX_BUFFER_SIZE - recordsToWrite.length) {
if (
usageRecordBuffer.length <
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
) {
usageRecordBuffer.unshift(...recordsToWrite);
logger.info(`Re-queued ${recordsToWrite.length} AI usage records for retry`);
logger.info(
`Re-queued ${recordsToWrite.length} AI usage records for retry`
);
} else {
logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`);
logger.error(
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
);
}
} finally {
isUsageFlushInProgress = false;
@@ -544,6 +567,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
resourceId: input.resourceId,
siteResourceId: input.siteResourceId,
userId: input.userId,
virtualApiKeyId: input.virtualApiKeyId,
sessionId: input.sessionId,
requestedModel: input.requestedModel,
promptTokens: usage.promptTokens,
+4
View File
@@ -180,6 +180,7 @@ export function logAiSession(data: {
resourceId: number | null;
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
}): void {
(async () => {
try {
@@ -237,6 +238,9 @@ export function logAiSession(data: {
resourceId: data.resourceId ?? undefined,
siteResourceId: data.siteResourceId ?? undefined,
userId: sanitizeString(data.requestUserId ?? undefined),
virtualApiKeyId: sanitizeString(
data.virtualApiKeyId ?? undefined
),
requestedModel: sanitizeString(data.requestedModel),
isStream: data.isStream,
requestBody: sanitizeString(requestBodyText.value),
+49 -19
View File
@@ -161,6 +161,15 @@ export type RequestUser = {
roleIds: number[];
};
// Identity resolved for a gateway request: the app/session or virtual-API-key
// user (if any) plus the virtual API key that authenticated the request (if
// any) - a manual virtual API key with no associated user has a
// virtualApiKeyId but no user.
export type RequestIdentity = {
user: RequestUser | null;
virtualApiKeyId: string | null;
};
// Identity headers forwarded to the upstream inference endpoint when the
// requesting user is known. Omitted entirely (not sent empty) when we
// couldn't resolve a user for the request.
@@ -223,10 +232,12 @@ async function resolveRequestUser(
req: Request,
resourceId: number | null,
orgId: string | null
): Promise<RequestUser | null> {
): Promise<RequestIdentity> {
// Public inference: identity comes from Badger via Remote-* only when the
// Traefik trust header proves the request passed verify-session (VAK).
if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) {
const virtualApiKeyId =
getRequestHeader(req, "remote-virtual-api-key-id") || null;
const userId = getRequestHeader(req, "remote-user-id");
if (userId) {
const username = getRequestHeader(req, "remote-user") || userId;
@@ -236,32 +247,41 @@ async function resolveRequestUser(
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
return {
userId,
username,
email: email || null,
name: name || null,
role:
role || orgRoles.map((r) => r.roleName).join(", ") || null,
roleIds: orgRoles.map((r) => r.roleId)
user: {
userId,
username,
email: email || null,
name: name || null,
role:
role ||
orgRoles.map((r) => r.roleName).join(", ") ||
null,
roleIds: orgRoles.map((r) => r.roleId)
},
virtualApiKeyId
};
}
// Trusted request with no associated user (manual key without userId).
// Trusted request with no associated user (manual key without
// userId) - still attribute usage to the virtual API key itself.
if (resourceId != null) {
return null;
return { user: null, virtualApiKeyId };
}
}
// Public inference must come through Badger; do not authorize via app session.
if (resourceId != null) {
return null;
return { user: null, virtualApiKeyId: null };
}
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
if (sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
return buildRequestUser(user.userId, orgId);
return {
user: await buildRequestUser(user.userId, orgId),
virtualApiKeyId: null
};
}
}
@@ -269,7 +289,7 @@ async function resolveRequestUser(
const ip = req.ip;
if (!ip) {
return null;
return { user: null, virtualApiKeyId: null };
}
const exitNodeRanges = await getExitNodeRanges();
@@ -277,15 +297,18 @@ async function resolveRequestUser(
isIpInCidr(ip, range)
);
if (!inExitNodeRange) {
return null;
return { user: null, virtualApiKeyId: null };
}
const client = await findClientByIp(ip);
if (!client || !client.userId) {
return null;
return { user: null, virtualApiKeyId: null };
}
return buildRequestUser(client.userId, orgId);
return {
user: await buildRequestUser(client.userId, orgId),
virtualApiKeyId: null
};
}
function getRequestHeader(req: Request, name: string): string | undefined {
@@ -608,6 +631,7 @@ export function recordAiGatewayCompletion(args: {
resourceId: number | null;
siteResourceId: number | null;
requestUserId: string | null;
virtualApiKeyId: string | null;
budgets: AiBudget[];
}): void {
const {
@@ -623,6 +647,7 @@ export function recordAiGatewayCompletion(args: {
resourceId,
siteResourceId,
requestUserId,
virtualApiKeyId,
budgets
} = args;
@@ -668,6 +693,7 @@ export function recordAiGatewayCompletion(args: {
resourceId,
siteResourceId,
userId: requestUserId,
virtualApiKeyId,
requestedModel: model ?? "unknown",
usage,
costUsd: cost?.totalCost ?? null,
@@ -699,7 +725,8 @@ export function recordAiGatewayCompletion(args: {
orgId,
resourceId,
siteResourceId,
requestUserId
requestUserId,
virtualApiKeyId
});
}
@@ -770,7 +797,7 @@ export async function handleAiGatewayProxy(
const requestedModel = def.extractModel(req);
const [requestUser, selection] = await Promise.all([
const [identity, selection] = await Promise.all([
resolveRequestUser(req, resourceId, orgId),
selectProvider(
capableAttachments,
@@ -778,6 +805,7 @@ export async function handleAiGatewayProxy(
requestedModel
)
]);
const requestUser = identity.user;
if (requestUser) {
logger.debug(
@@ -836,7 +864,8 @@ export async function handleAiGatewayProxy(
resourceId,
siteResourceId,
requestedModel,
budgets: appliedBudgets
budgets: appliedBudgets,
virtualApiKeyId: identity.virtualApiKeyId
}
);
}
@@ -987,6 +1016,7 @@ export async function handleAiGatewayProxy(
resourceId,
siteResourceId,
requestUserId: requestUser?.userId ?? null,
virtualApiKeyId: identity.virtualApiKeyId,
budgets: appliedBudgets
});
}
@@ -179,6 +179,7 @@ export async function proxyAiGatewayToSiteTarget(
siteResourceId: number | null;
requestedModel: string | undefined;
budgets: AiBudget[];
virtualApiKeyId: string | null;
}
): Promise<void> {
const providerTargets = await getProviderTargets(provider.providerId);
@@ -319,6 +320,7 @@ export async function proxyAiGatewayToSiteTarget(
resourceId: ctx.resourceId,
siteResourceId: ctx.siteResourceId,
requestUserId: requestUser?.userId ?? null,
virtualApiKeyId: ctx.virtualApiKeyId,
budgets: ctx.budgets
});
}
@@ -58,7 +58,8 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.transform(Number)
.pipe(z.int().positive())
.optional(),
userId: z.string().optional()
userId: z.string().optional(),
virtualApiKeyId: z.string().optional()
});
export const aiUsageAnalyticsParams = z.object({
@@ -114,6 +115,9 @@ export function buildAiUsageWhere(
)
: undefined,
data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined,
data.virtualApiKeyId
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
: undefined,
roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined
);
}
+1
View File
@@ -8,3 +8,4 @@ export * from "./queryAiUsageOverview";
export * from "./queryAiUsageProviders";
export * from "./queryAiUsageResources";
export * from "./queryAiUsageUsersRoles";
export * from "./queryAiUsageVirtualApiKeys";
+79 -4
View File
@@ -6,6 +6,7 @@ import {
resources,
siteResources,
users,
virtualApiKeys,
db,
primaryDb
} from "@server/db";
@@ -67,6 +68,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.pipe(z.int().positive())
.optional(),
actor: z.string().optional(),
virtualApiKeyId: z.string().optional(),
model: z.string().optional(),
isStream: z
.union([z.boolean(), z.string()])
@@ -117,7 +119,9 @@ function getWhere(data: Q) {
data.providerId
? eq(aiSessionLog.providerId, data.providerId)
: undefined,
data.capability ? eq(aiSessionLog.capability, data.capability) : undefined,
data.capability
? eq(aiSessionLog.capability, data.capability)
: undefined,
data.resourceId
? or(
eq(aiSessionLog.resourceId, data.resourceId),
@@ -125,9 +129,10 @@ function getWhere(data: Q) {
)
: undefined,
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
data.model
? eq(aiSessionLog.requestedModel, data.model)
data.virtualApiKeyId
? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId)
: undefined,
data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined,
data.isStream !== undefined
? eq(aiSessionLog.isStream, data.isStream)
: undefined
@@ -145,6 +150,7 @@ export function queryAiSession(data: Q) {
resourceId: aiSessionLog.resourceId,
siteResourceId: aiSessionLog.siteResourceId,
userId: aiSessionLog.userId,
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
requestedModel: aiSessionLog.requestedModel,
isStream: aiSessionLog.isStream,
requestBody: aiSessionLog.requestBody,
@@ -182,6 +188,14 @@ async function enrichWithDetails(
)
];
const virtualApiKeyIds = [
...new Set(
logs
.map((log) => log.virtualApiKeyId)
.filter((id): id is string => id !== null && id !== undefined)
)
];
const providerMap = new Map<
number,
{ name: string | null; type: string | null }
@@ -254,6 +268,28 @@ async function enrichWithDetails(
}
}
const virtualApiKeyMap = new Map<
string,
{ name: string | null; lastChars: string }
>();
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
for (const k of virtualApiKeyDetails) {
virtualApiKeyMap.set(k.virtualApiKeyId, {
name: k.name,
lastChars: k.lastChars
});
}
}
const usageMap = new Map<
string,
{
@@ -328,6 +364,12 @@ async function enrichWithDetails(
resourceName,
resourceNiceId,
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null,
virtualApiKeyName: log.virtualApiKeyId
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.name ?? null)
: null,
virtualApiKeyLastChars: log.virtualApiKeyId
? (virtualApiKeyMap.get(log.virtualApiKeyId)?.lastChars ?? null)
: null,
usage: usageMap.get(log.sessionId) ?? null
};
});
@@ -385,7 +427,8 @@ async function queryUniqueFilterAttributes(
uniqueUsers,
uniqueResources,
uniqueSiteResources,
uniqueModels
uniqueModels,
uniqueVirtualApiKeys
] = await Promise.all([
logsDb
.selectDistinct({ id: aiSessionLog.providerId })
@@ -411,6 +454,11 @@ async function queryUniqueFilterAttributes(
.selectDistinct({ model: aiSessionLog.requestedModel })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
logsDb
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
.from(aiSessionLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT + 1)
]);
@@ -498,10 +546,37 @@ async function queryUniqueFilterAttributes(
];
}
const virtualApiKeyIds = uniqueVirtualApiKeys
.map((row) => row.id)
.filter((id): id is string => id !== null);
let virtualApiKeyList: Array<{
id: string;
name: string | null;
lastChars: string | null;
}> = [];
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await primaryDb
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
id: k.virtualApiKeyId,
name: k.name,
lastChars: k.lastChars
}));
}
return {
providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames),
users: userList,
virtualApiKeys: virtualApiKeyList,
models: models.sort()
};
}
@@ -6,7 +6,8 @@ import {
siteResources,
users,
roles,
userOrgRoles
userOrgRoles,
virtualApiKeys
} from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
@@ -43,8 +44,9 @@ const queryAiUsageFilterOptionsParams = z.object({
orgId: z.string()
});
const queryAiUsageFilterOptionsCombined =
queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams);
const queryAiUsageFilterOptionsCombined = queryAiUsageFilterOptionsQuery.merge(
queryAiUsageFilterOptionsParams
);
type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
@@ -73,7 +75,8 @@ async function query(data: Q) {
uniqueModels,
uniqueResources,
uniqueSiteResources,
uniqueUsers
uniqueUsers,
uniqueVirtualApiKeys
] = await Promise.all([
db
.selectDistinct({ id: aiUsageRecords.providerId })
@@ -105,6 +108,13 @@ async function query(data: Q) {
.selectDistinct({ userId: aiUsageRecords.userId })
.from(aiUsageRecords)
.where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
.limit(DISTINCT_LIMIT + 1),
db
.selectDistinct({ id: aiUsageRecords.virtualApiKeyId })
.from(aiUsageRecords)
.where(
and(baseConditions, not(isNull(aiUsageRecords.virtualApiKeyId)))
)
.limit(DISTINCT_LIMIT + 1)
]);
@@ -120,11 +130,17 @@ async function query(data: Q) {
let providers: Array<{ id: number; name: string | null }> = [];
if (providerIds.length > 0) {
const providerDetails = await db
.select({ providerId: aiProviders.providerId, name: aiProviders.name })
.select({
providerId: aiProviders.providerId,
name: aiProviders.name
})
.from(aiProviders)
.where(inArray(aiProviders.providerId, providerIds));
providers = providerDetails.map((p) => ({ id: p.providerId, name: p.name }));
providers = providerDetails.map((p) => ({
id: p.providerId,
name: p.name
}));
}
const resourceIds = uniqueResources
@@ -155,7 +171,10 @@ async function query(data: Q) {
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = resourcesWithNames.concat(
siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name }))
siteResourceDetails.map((r) => ({
id: r.siteResourceId,
name: r.name
}))
);
}
@@ -190,11 +209,37 @@ async function query(data: Q) {
roleList = [...roleMap.entries()].map(([id, name]) => ({ id, name }));
}
const virtualApiKeyIds = uniqueVirtualApiKeys
.map((row) => row.id)
.filter((id): id is string => id !== null);
let virtualApiKeyList: Array<{
id: string;
name: string | null;
lastChars: string;
}> = [];
if (virtualApiKeyIds.length > 0) {
const virtualApiKeyDetails = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
virtualApiKeyList = virtualApiKeyDetails.map((k) => ({
id: k.virtualApiKeyId,
name: k.name,
lastChars: k.lastChars
}));
}
return {
providers: sortNamedFilterOptions(providers),
resources: sortNamedFilterOptions(resourcesWithNames),
roles: sortNamedFilterOptions(roleList),
users: userList,
virtualApiKeys: virtualApiKeyList,
models
};
}
@@ -227,7 +272,9 @@ registry.registerPath({
}
});
export type QueryAiUsageFilterOptionsResponse = Awaited<ReturnType<typeof query>>;
export type QueryAiUsageFilterOptionsResponse = Awaited<
ReturnType<typeof query>
>;
export async function queryAiUsageFilterOptions(
req: Request,
@@ -238,7 +285,10 @@ export async function queryAiUsageFilterOptions(
const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error))
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
@@ -247,7 +297,10 @@ export async function queryAiUsageFilterOptions(
);
if (!parsedParams.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error))
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
@@ -0,0 +1,222 @@
import { db, aiUsageRecords, virtualApiKeys } from "@server/db";
import { registry } from "@server/openApi";
import { NextFunction } from "express";
import { Request, Response } from "express";
import { count, desc, inArray, sql } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import {
aiUsageAnalyticsFiltersQuery,
aiUsageAnalyticsParams,
buildAiUsageWhere,
resolveRoleUserIds,
dayBucketExpr,
pickTopNKeys,
bucketTopNPerDay,
DISTINCT_LIMIT,
type AiUsageAnalyticsQuery
} from "./aiUsageAnalyticsShared";
type Q = AiUsageAnalyticsQuery;
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
async function query(data: Q) {
const roleUserIds = await resolveRoleUserIds(data.orgId, data.roleId);
const baseConditions = buildAiUsageWhere(data, roleUserIds);
const dayExpr = dayBucketExpr();
const virtualApiKeyByDay = await db
.select({
day: dayExpr.as("day"),
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
cost: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`,
tokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(dayExpr, aiUsageRecords.virtualApiKeyId)
.orderBy(dayExpr);
const virtualApiKeyTotalsRaw = await db
.select({
virtualApiKeyId: aiUsageRecords.virtualApiKeyId,
requests: count(),
totalTokens: sql<number>`COALESCE(SUM(${aiUsageRecords.totalTokens}), 0)`,
costUsd: sql<number>`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`
})
.from(aiUsageRecords)
.where(baseConditions)
.groupBy(aiUsageRecords.virtualApiKeyId)
.orderBy(desc(sql`COALESCE(SUM(${aiUsageRecords.costUsd}), 0)`))
.limit(DISTINCT_LIMIT + 1);
if (virtualApiKeyTotalsRaw.length > DISTINCT_LIMIT) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Too many distinct virtual API keys. Please narrow your query."
);
}
const virtualApiKeyIds = virtualApiKeyTotalsRaw
.map((r) => r.virtualApiKeyId)
.filter((id): id is string => id !== null);
const detailsMap = new Map<
string,
{ name: string | null; lastChars: string; kind: "user" | "manual" }
>();
if (virtualApiKeyIds.length > 0) {
const details = await db
.select({
virtualApiKeyId: virtualApiKeys.virtualApiKeyId,
name: virtualApiKeys.name,
lastChars: virtualApiKeys.lastChars,
kind: virtualApiKeys.kind
})
.from(virtualApiKeys)
.where(inArray(virtualApiKeys.virtualApiKeyId, virtualApiKeyIds));
for (const k of details) {
detailsMap.set(k.virtualApiKeyId, {
name: k.name,
lastChars: k.lastChars,
kind: k.kind
});
}
}
const topVirtualApiKeys = virtualApiKeyTotalsRaw.map((r) => {
const details = r.virtualApiKeyId
? detailsMap.get(r.virtualApiKeyId)
: undefined;
return {
virtualApiKeyId: r.virtualApiKeyId,
name: details?.name ?? null,
lastChars: details?.lastChars ?? null,
kind: details?.kind ?? null,
requests: r.requests,
totalTokens: r.totalTokens,
costUsd: r.costUsd
};
});
const virtualApiKeyCostTotals = new Map<string, number>();
const virtualApiKeyTokenTotals = new Map<string, number>();
for (const row of virtualApiKeyByDay) {
const key = row.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY;
virtualApiKeyCostTotals.set(
key,
(virtualApiKeyCostTotals.get(key) ?? 0) + row.cost
);
virtualApiKeyTokenTotals.set(
key,
(virtualApiKeyTokenTotals.get(key) ?? 0) + row.tokens
);
}
const topVirtualApiKeysByCost = pickTopNKeys(virtualApiKeyCostTotals);
const topVirtualApiKeysByTokens = pickTopNKeys(virtualApiKeyTokenTotals);
const virtualApiKeyCostPerDay = bucketTopNPerDay(
virtualApiKeyByDay.map((r) => ({
day: r.day,
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
value: r.cost
})),
topVirtualApiKeysByCost
);
const virtualApiKeyTokensPerDay = bucketTopNPerDay(
virtualApiKeyByDay.map((r) => ({
day: r.day,
key: r.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
value: r.tokens
})),
topVirtualApiKeysByTokens
);
return {
topVirtualApiKeys,
virtualApiKeyCostPerDay,
virtualApiKeyTokensPerDay
};
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/logs/ai/usage/virtual-api-keys",
description:
"Query the AI usage analytics virtual API key breakdown for an organization",
tags: [OpenAPITags.Logs],
request: {
query: aiUsageAnalyticsFiltersQuery,
params: aiUsageAnalyticsParams
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export type QueryAiUsageVirtualApiKeysResponse = Awaited<
ReturnType<typeof query>
>;
export async function queryAiUsageVirtualApiKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = aiUsageAnalyticsFiltersQuery.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const parsedParams = aiUsageAnalyticsParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const data = await query({ ...parsedQuery.data, ...parsedParams.data });
return response<QueryAiUsageVirtualApiKeysResponse>(res, {
data,
success: true,
error: false,
message:
"AI usage virtual API key breakdown retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+8
View File
@@ -110,6 +110,9 @@ export type QueryAiSessionLogResponse = {
resourceType: "public" | "site" | null;
userId: string | null;
userEmail: string | null;
virtualApiKeyId: string | null;
virtualApiKeyName: string | null;
virtualApiKeyLastChars: string | null;
requestedModel: string | null;
isStream: boolean;
requestBody: string | null;
@@ -148,6 +151,11 @@ export type QueryAiSessionLogResponse = {
id: string;
email: string | null;
}[];
virtualApiKeys: {
id: string;
name: string | null;
lastChars: string | null;
}[];
models: string[];
};
};
+17 -4
View File
@@ -96,6 +96,10 @@ export type VerifyUserResponse = {
userData?: BasicUserData;
pangolinVersion?: string;
dontStripSession?: boolean;
// Set independently of userData so a manual virtual API key with no
// associated user still gets attributed to the key that authenticated
// the request (see the mode === "inference" branch below).
virtualApiKeyId?: string;
};
export async function verifyResourceSession(
@@ -401,7 +405,12 @@ export async function verifyResourceSession(
parsedBody.data
);
return allowed(res, vakUserData, dontStripSession);
return allowed(
res,
vakUserData,
dontStripSession,
key.virtualApiKeyId
);
}
}
@@ -987,16 +996,20 @@ async function notAllowed(
function allowed(
res: Response,
userData?: BasicUserData,
dontStripSession?: boolean
dontStripSession?: boolean,
virtualApiKeyId?: string
) {
const baseData =
userData !== undefined && userData !== null
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
: { valid: true, pangolinVersion: APP_VERSION };
const withVirtualApiKey = virtualApiKeyId
? { ...baseData, virtualApiKeyId }
: baseData;
const data = {
data: dontStripSession
? { ...baseData, dontStripSession: true }
: baseData,
? { ...withVirtualApiKey, dontStripSession: true }
: withVirtualApiKey,
success: true,
error: false,
message: "Access allowed",
+7
View File
@@ -1537,6 +1537,13 @@ authenticated.get(
logs.queryAiUsageUsersRoles
);
authenticated.get(
"/org/:orgId/logs/ai/usage/virtual-api-keys",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageVirtualApiKeys
);
authenticated.get(
"/org/:orgId/blueprints",
verifyOrgAccess,
+7
View File
@@ -1580,6 +1580,13 @@ authenticated.get(
logs.queryAiUsageUsersRoles
);
authenticated.get(
"/org/:orgId/logs/ai/usage/virtual-api-keys",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.viewLogs),
logs.queryAiUsageVirtualApiKeys
);
authenticated.get(
"/org/:orgId/logs/analytics",
verifyApiKeyOrgAccess,
+100 -3
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
@@ -50,6 +51,7 @@ export default function AiSessionLogsPage() {
capability?: string;
resourceId?: string;
actor?: string;
virtualApiKeyId?: string;
model?: string;
isStream?: string;
}>({
@@ -57,6 +59,7 @@ export default function AiSessionLogsPage() {
capability: searchParams.get("capability") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
actor: searchParams.get("actor") || undefined,
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
model: searchParams.get("model") || undefined,
isStream: searchParams.get("isStream") || undefined
});
@@ -135,6 +138,7 @@ export default function AiSessionLogsPage() {
providers: [],
resources: [],
users: [],
virtualApiKeys: [],
models: []
};
@@ -358,7 +362,9 @@ export default function AiSessionLogsPage() {
},
cell: ({ row }) => {
if (!row.original.resourceNiceId) {
return <span className="text-xs text-muted-foreground">-</span>;
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<Link
@@ -453,13 +459,67 @@ export default function AiSessionLogsPage() {
</span>
);
}
},
{
accessorKey: "virtualApiKeyId",
header: ({ column }) => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.virtualApiKeys.map(
(key) => ({
value: key.id,
label:
key.name ??
(key.lastChars
? formatVirtualApiKeyPreview(
key.id,
key.lastChars
)
: key.id)
})
)}
selectedValue={filters.virtualApiKeyId}
onValueChange={(value) =>
handleFilterChange("virtualApiKeyId", value)
}
label={t("virtualApiKey")}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (!row.original.virtualApiKeyId) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<div className="flex flex-col min-w-0">
<span className="truncate">
{row.original.virtualApiKeyName ??
t("aiUsageUnnamedVirtualApiKey")}
</span>
{row.original.virtualApiKeyLastChars && (
<span className="text-xs text-muted-foreground truncate">
{formatVirtualApiKeyPreview(
row.original.virtualApiKeyId,
row.original.virtualApiKeyLastChars
)}
</span>
)}
</div>
);
}
}
];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-xs">
<div>
<strong>{t("aiSessionId")}</strong>
<p className="text-muted-foreground mt-1 break-all">
@@ -490,6 +550,30 @@ export default function AiSessionLogsPage() {
: "N/A"}
</p>
</div>
<div>
<strong>{t("virtualApiKey")}</strong>
<p className="text-muted-foreground mt-1 break-all">
{row.virtualApiKeyId ? (
<>
{row.virtualApiKeyName ??
t("aiUsageUnnamedVirtualApiKey")}
{row.virtualApiKeyLastChars && (
<>
{" "}
(
{formatVirtualApiKeyPreview(
row.virtualApiKeyId,
row.virtualApiKeyLastChars
)}
)
</>
)}
</>
) : (
t("noVirtualApiKey")
)}
</p>
</div>
</div>
{row.usage && (
<div>
@@ -599,15 +683,25 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
];
const actors = ["alice@example.com", "bob@example.com", null];
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
const virtualApiKeysSample = [
{ id: "vak00001", name: "CI pipeline", lastChars: "ab12" },
{ id: "vak00002", name: null, lastChars: "cd34" },
null
];
const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
return Array.from({ length: 10 }, (_, i) => {
const provider = providers[Math.floor(Math.random() * providers.length)];
const provider =
providers[Math.floor(Math.random() * providers.length)];
const resource =
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
const actor = actors[Math.floor(Math.random() * actors.length)];
const virtualApiKey =
virtualApiKeysSample[
Math.floor(Math.random() * virtualApiKeysSample.length)
];
return {
id: i,
@@ -625,6 +719,9 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
resourceType: "public",
userId: actor ? `user-${i}` : null,
userEmail: actor,
virtualApiKeyId: virtualApiKey?.id ?? null,
virtualApiKeyName: virtualApiKey?.name ?? null,
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
requestedModel: models[Math.floor(Math.random() * models.length)],
isStream: Math.random() > 0.5,
requestBody: null,
+29 -3
View File
@@ -29,6 +29,8 @@ import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
import { RolesTab } from "./ai-usage-analytics/RolesTab";
import { UsersTab } from "./ai-usage-analytics/UsersTab";
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
export type AiUsageAnalyticsDataProps = {
orgId: string;
@@ -140,13 +142,20 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value: u.id,
label: u.email ?? u.id
}));
const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map(
(k) => ({
value: k.id,
label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars)
})
);
const tabs: TabItem[] = [
{ title: t("aiUsageTabOverview"), href: "#" },
{ title: t("aiUsageTabProviders"), href: "#" },
{ title: t("aiUsageTabResources"), href: "#" },
{ title: t("aiUsageRolesTab"), href: "#" },
{ title: t("aiUsageUsersTab"), href: "#" }
{ title: t("aiUsageUsersTab"), href: "#" },
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
];
return (
@@ -180,7 +189,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value={filters.providerId?.toString()}
options={providerOptions}
placeholder={t("aiUsageFilterAllProviders")}
onValueChange={(v) => setFilter("providerId", v)}
onValueChange={(v) =>
setFilter("providerId", v)
}
/>
<FilterSelect
id="model"
@@ -196,7 +207,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
value={filters.resourceId?.toString()}
options={resourceOptions}
placeholder={t("aiUsageFilterAllResources")}
onValueChange={(v) => setFilter("resourceId", v)}
onValueChange={(v) =>
setFilter("resourceId", v)
}
/>
<FilterSelect
id="roleId"
@@ -214,6 +227,18 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
placeholder={t("aiUsageFilterAllUsers")}
onValueChange={(v) => setFilter("userId", v)}
/>
<FilterSelect
id="virtualApiKeyId"
label={t("aiUsageFilterVirtualApiKey")}
value={filters.virtualApiKeyId}
options={virtualApiKeyOptions}
placeholder={t(
"aiUsageFilterAllVirtualApiKeys"
)}
onValueChange={(v) =>
setFilter("virtualApiKeyId", v)
}
/>
{!isEmptySearchParams && (
<Button
@@ -259,6 +284,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
<ResourcesTab orgId={props.orgId} filters={filters} />
<RolesTab orgId={props.orgId} filters={filters} />
<UsersTab orgId={props.orgId} filters={filters} />
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
</HorizontalTabs>
</div>
);
@@ -0,0 +1,108 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import { ToggleableTrendChart } from "./ToggleableTrendChart";
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
import { buildSeriesFromData, formatCost } from "./shared";
type VirtualApiKeysTabProps = {
orgId: string;
filters: AiUsageAnalyticsFilters;
};
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
export function VirtualApiKeysTab(props: VirtualApiKeysTabProps) {
const t = useTranslations();
const { data, isLoading } = useQuery(
aiUsageAnalyticsQueries.virtualApiKeys({
orgId: props.orgId,
filters: props.filters
})
);
const labelByKey = new Map<string, string>();
for (const k of data?.topVirtualApiKeys ?? []) {
if (k.virtualApiKeyId) {
labelByKey.set(k.virtualApiKeyId, k.name ?? k.virtualApiKeyId);
}
}
const virtualApiKeyLabelFor = (key: string) =>
key === UNKNOWN_VIRTUAL_API_KEY_KEY
? t("aiUsageUnknownVirtualApiKey")
: (labelByKey.get(key) ?? key);
const virtualApiKeyCostSeries = buildSeriesFromData(
data?.virtualApiKeyCostPerDay ?? [],
virtualApiKeyLabelFor,
t("aiUsageOther")
);
const virtualApiKeyTokensSeries = buildSeriesFromData(
data?.virtualApiKeyTokensPerDay ?? [],
virtualApiKeyLabelFor,
t("aiUsageOther")
);
const topVirtualApiKeys: TopEntity[] = (data?.topVirtualApiKeys ?? []).map(
(k) => ({
key: k.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
label: k.virtualApiKeyId
? (k.name ?? t("aiUsageUnnamedVirtualApiKey"))
: t("aiUsageUnknownVirtualApiKey"),
sublabel:
k.virtualApiKeyId && k.lastChars
? formatVirtualApiKeyPreview(k.virtualApiKeyId, k.lastChars)
: undefined,
requests: k.requests,
totalTokens: k.totalTokens,
costUsd: k.costUsd
})
);
return (
<div className="flex flex-col gap-5">
<Card>
<CardHeader>
<h3 className="font-semibold">
{t("aiUsageTopVirtualApiKeys")}
</h3>
</CardHeader>
<CardContent>
<TopEntitiesList
entities={topVirtualApiKeys}
isLoading={isLoading}
nameColumnLabel={t("aiUsageFilterVirtualApiKey")}
/>
</CardContent>
</Card>
<div className="grid lg:grid-cols-2 gap-5">
<Card>
<CardContent className="p-6">
<ToggleableTrendChart
title={t("aiUsageVirtualApiKeyCost")}
data={data?.virtualApiKeyCostPerDay ?? []}
series={virtualApiKeyCostSeries}
isLoading={isLoading}
valueFormatter={(v) => formatCost(v)}
/>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<ToggleableTrendChart
title={t("aiUsageVirtualApiKeyTokenUsage")}
data={data?.virtualApiKeyTokensPerDay ?? []}
series={virtualApiKeyTokensSeries}
isLoading={isLoading}
/>
</CardContent>
</Card>
</div>
</div>
);
}
+60 -6
View File
@@ -12,7 +12,8 @@ import type {
QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse
QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse
} from "@server/routers/auditLogs";
import type {
QueryAccessAuditLogResponse,
@@ -954,7 +955,8 @@ export const aiUsageAnalyticsFiltersSchema = z.object({
model: z.string().optional().catch(undefined),
resourceId: z.coerce.number().optional().catch(undefined),
roleId: z.coerce.number().optional().catch(undefined),
userId: z.string().optional().catch(undefined)
userId: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined)
});
export type AiUsageAnalyticsFilters = z.output<
@@ -1086,6 +1088,7 @@ export const aiSessionLogsFiltersSchema = z.object({
capability: z.string().optional().catch(undefined),
resourceId: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
virtualApiKeyId: z.string().optional().catch(undefined),
model: z.string().optional().catch(undefined),
isStream: z.string().optional().catch(undefined)
});
@@ -1284,7 +1287,12 @@ export const aiUsageAnalyticsQueries = {
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
}) =>
queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const,
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"FILTERS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageFilterOptionsResponse>
@@ -1304,7 +1312,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const,
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"OVERVIEW",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageOverviewResponse>
@@ -1330,7 +1343,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const,
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"PROVIDERS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageProvidersResponse>
@@ -1356,7 +1374,12 @@ export const aiUsageAnalyticsQueries = {
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const,
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"RESOURCES",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageResourcesResponse>
@@ -1403,6 +1426,37 @@ export const aiUsageAnalyticsQueries = {
}
return false;
}
}),
virtualApiKeys: ({
orgId,
filters
}: {
orgId: string;
filters: AiUsageAnalyticsFilters;
}) =>
queryOptions({
queryKey: [
"AI_USAGE_ANALYTICS",
orgId,
"VIRTUAL_API_KEYS",
filters
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<QueryAiUsageVirtualApiKeysResponse>
>(`/org/${orgId}/logs/ai/usage/virtual-api-keys`, {
params: filters,
signal
});
return res.data.data;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
}
})
};