mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-14 00:09:55 +02:00
Merge branch 'aig' of https://github.com/fosrl/pangolin into aig
This commit is contained in:
@@ -1989,6 +1989,14 @@
|
|||||||
"aiUsageUserCost": "User Cost",
|
"aiUsageUserCost": "User Cost",
|
||||||
"aiUsageUserTokenUsage": "User Token Usage",
|
"aiUsageUserTokenUsage": "User Token Usage",
|
||||||
"aiUsageUnknownUser": "Unknown user",
|
"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...",
|
"aiUsageLoading": "Loading...",
|
||||||
"aiUsageNoData": "No data",
|
"aiUsageNoData": "No data",
|
||||||
"resourceBudgetSettings": "Budget",
|
"resourceBudgetSettings": "Budget",
|
||||||
@@ -3457,6 +3465,8 @@
|
|||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"capability": "Capability",
|
"capability": "Capability",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
|
"virtualApiKey": "Virtual API Key",
|
||||||
|
"noVirtualApiKey": "No virtual API key",
|
||||||
"stream": "Stream",
|
"stream": "Stream",
|
||||||
"streaming": "Streaming",
|
"streaming": "Streaming",
|
||||||
"nonStreaming": "Non-streaming",
|
"nonStreaming": "Non-streaming",
|
||||||
|
|||||||
@@ -1819,18 +1819,22 @@ export const aiUsageRecords = pgTable(
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "set null" }),
|
||||||
resourceId: integer("resourceId").references(
|
resourceId: integer("resourceId").references(
|
||||||
() => resources.resourceId,
|
() => resources.resourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
siteResourceId: integer("siteResourceId").references(
|
siteResourceId: integer("siteResourceId").references(
|
||||||
() => siteResources.siteResourceId,
|
() => siteResources.siteResourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
userId: varchar("userId").references(() => users.userId, {
|
userId: varchar("userId").references(() => users.userId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
|
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||||
|
() => virtualApiKeys.virtualApiKeyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
// Links this usage record back to the aiSessionLog row for the same
|
// Links this usage record back to the aiSessionLog row for the same
|
||||||
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
||||||
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
||||||
@@ -1870,6 +1874,11 @@ export const aiUsageRecords = pgTable(
|
|||||||
t.userId,
|
t.userId,
|
||||||
t.createdAt
|
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)
|
index("idx_ai_usage_records_session").on(t.sessionId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1927,19 +1936,23 @@ export const aiSessionLog = pgTable(
|
|||||||
}),
|
}),
|
||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "set null" }),
|
||||||
capability: varchar("capability").notNull(),
|
capability: varchar("capability").notNull(),
|
||||||
resourceId: integer("resourceId").references(
|
resourceId: integer("resourceId").references(
|
||||||
() => resources.resourceId,
|
() => resources.resourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
siteResourceId: integer("siteResourceId").references(
|
siteResourceId: integer("siteResourceId").references(
|
||||||
() => siteResources.siteResourceId,
|
() => siteResources.siteResourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
userId: varchar("userId").references(() => users.userId, {
|
userId: varchar("userId").references(() => users.userId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
|
virtualApiKeyId: varchar("virtualApiKeyId").references(
|
||||||
|
() => virtualApiKeys.virtualApiKeyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
requestedModel: varchar("requestedModel"),
|
requestedModel: varchar("requestedModel"),
|
||||||
isStream: boolean("isStream").notNull().default(false),
|
isStream: boolean("isStream").notNull().default(false),
|
||||||
requestBody: text("requestBody"),
|
requestBody: text("requestBody"),
|
||||||
@@ -1979,6 +1992,11 @@ export const aiSessionLog = pgTable(
|
|||||||
t.userId,
|
t.userId,
|
||||||
t.createdAt
|
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)
|
index("idx_ai_session_log_session").on(t.sessionId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1807,18 +1807,22 @@ export const aiUsageRecords = sqliteTable(
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "set null" }),
|
||||||
resourceId: integer("resourceId").references(
|
resourceId: integer("resourceId").references(
|
||||||
() => resources.resourceId,
|
() => resources.resourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
siteResourceId: integer("siteResourceId").references(
|
siteResourceId: integer("siteResourceId").references(
|
||||||
() => siteResources.siteResourceId,
|
() => siteResources.siteResourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
userId: text("userId").references(() => users.userId, {
|
userId: text("userId").references(() => users.userId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
|
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||||
|
() => virtualApiKeys.virtualApiKeyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
// Links this usage record back to the aiSessionLog row for the same
|
// Links this usage record back to the aiSessionLog row for the same
|
||||||
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
// request (aiSessionLog.sessionId), so token/cost usage can be shown
|
||||||
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
// alongside the session transcript. Not a DB-level FK - aiSessionLog
|
||||||
@@ -1860,6 +1864,11 @@ export const aiUsageRecords = sqliteTable(
|
|||||||
t.userId,
|
t.userId,
|
||||||
t.createdAt
|
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)
|
index("idx_ai_usage_records_session").on(t.sessionId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1917,19 +1926,23 @@ export const aiSessionLog = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
providerId: integer("providerId")
|
providerId: integer("providerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
.references(() => aiProviders.providerId, { onDelete: "set null" }),
|
||||||
capability: text("capability").notNull(),
|
capability: text("capability").notNull(),
|
||||||
resourceId: integer("resourceId").references(
|
resourceId: integer("resourceId").references(
|
||||||
() => resources.resourceId,
|
() => resources.resourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
siteResourceId: integer("siteResourceId").references(
|
siteResourceId: integer("siteResourceId").references(
|
||||||
() => siteResources.siteResourceId,
|
() => siteResources.siteResourceId,
|
||||||
{ onDelete: "cascade" }
|
{ onDelete: "set null" }
|
||||||
),
|
),
|
||||||
userId: text("userId").references(() => users.userId, {
|
userId: text("userId").references(() => users.userId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
|
virtualApiKeyId: text("virtualApiKeyId").references(
|
||||||
|
() => virtualApiKeys.virtualApiKeyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
requestedModel: text("requestedModel"),
|
requestedModel: text("requestedModel"),
|
||||||
isStream: integer("isStream", { mode: "boolean" })
|
isStream: integer("isStream", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -1973,6 +1986,11 @@ export const aiSessionLog = sqliteTable(
|
|||||||
t.userId,
|
t.userId,
|
||||||
t.createdAt
|
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)
|
index("idx_ai_session_log_session").on(t.sessionId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
AiBudget,
|
AiBudget,
|
||||||
aiBudgetBreachEvents,
|
aiBudgetBreachEvents,
|
||||||
@@ -424,6 +434,7 @@ export type UsageRecordInput = {
|
|||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
requestedModel: string;
|
requestedModel: string;
|
||||||
usage: AiUsage;
|
usage: AiUsage;
|
||||||
costUsd: number | null;
|
costUsd: number | null;
|
||||||
@@ -460,7 +471,10 @@ async function flushUsageRecords() {
|
|||||||
|
|
||||||
isUsageFlushInProgress = true;
|
isUsageFlushInProgress = true;
|
||||||
|
|
||||||
const recordsToWrite = usageRecordBuffer.splice(0, usageRecordBuffer.length);
|
const recordsToWrite = usageRecordBuffer.splice(
|
||||||
|
0,
|
||||||
|
usageRecordBuffer.length
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use a transaction to ensure all inserts succeed or fail together
|
// Use a transaction to ensure all inserts succeed or fail together
|
||||||
@@ -472,16 +486,25 @@ async function flushUsageRecords() {
|
|||||||
await tx.insert(aiUsageRecords).values(batch);
|
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) {
|
} catch (error) {
|
||||||
logger.error("Error flushing AI usage records:", error);
|
logger.error("Error flushing AI usage records:", error);
|
||||||
// On transaction error, put records back at the front of the buffer
|
// On transaction error, put records back at the front of the buffer
|
||||||
// to retry, but only if the buffer isn't too large
|
// 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);
|
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 {
|
} else {
|
||||||
logger.error(`Buffer full, dropped ${recordsToWrite.length} AI usage records`);
|
logger.error(
|
||||||
|
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isUsageFlushInProgress = false;
|
isUsageFlushInProgress = false;
|
||||||
@@ -544,6 +567,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
|||||||
resourceId: input.resourceId,
|
resourceId: input.resourceId,
|
||||||
siteResourceId: input.siteResourceId,
|
siteResourceId: input.siteResourceId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
|
virtualApiKeyId: input.virtualApiKeyId,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
requestedModel: input.requestedModel,
|
requestedModel: input.requestedModel,
|
||||||
promptTokens: usage.promptTokens,
|
promptTokens: usage.promptTokens,
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export function logAiSession(data: {
|
|||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
requestUserId: string | null;
|
requestUserId: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -237,6 +238,9 @@ export function logAiSession(data: {
|
|||||||
resourceId: data.resourceId ?? undefined,
|
resourceId: data.resourceId ?? undefined,
|
||||||
siteResourceId: data.siteResourceId ?? undefined,
|
siteResourceId: data.siteResourceId ?? undefined,
|
||||||
userId: sanitizeString(data.requestUserId ?? undefined),
|
userId: sanitizeString(data.requestUserId ?? undefined),
|
||||||
|
virtualApiKeyId: sanitizeString(
|
||||||
|
data.virtualApiKeyId ?? undefined
|
||||||
|
),
|
||||||
requestedModel: sanitizeString(data.requestedModel),
|
requestedModel: sanitizeString(data.requestedModel),
|
||||||
isStream: data.isStream,
|
isStream: data.isStream,
|
||||||
requestBody: sanitizeString(requestBodyText.value),
|
requestBody: sanitizeString(requestBodyText.value),
|
||||||
|
|||||||
@@ -161,6 +161,15 @@ export type RequestUser = {
|
|||||||
roleIds: number[];
|
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
|
// Identity headers forwarded to the upstream inference endpoint when the
|
||||||
// requesting user is known. Omitted entirely (not sent empty) when we
|
// requesting user is known. Omitted entirely (not sent empty) when we
|
||||||
// couldn't resolve a user for the request.
|
// couldn't resolve a user for the request.
|
||||||
@@ -223,10 +232,12 @@ async function resolveRequestUser(
|
|||||||
req: Request,
|
req: Request,
|
||||||
resourceId: number | null,
|
resourceId: number | null,
|
||||||
orgId: string | null
|
orgId: string | null
|
||||||
): Promise<RequestUser | null> {
|
): Promise<RequestIdentity> {
|
||||||
// Public inference: identity comes from Badger via Remote-* only when the
|
// Public inference: identity comes from Badger via Remote-* only when the
|
||||||
// Traefik trust header proves the request passed verify-session (VAK).
|
// Traefik trust header proves the request passed verify-session (VAK).
|
||||||
if (isAiGatewayTrustHeaderValid(req.headers as Record<string, string>)) {
|
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");
|
const userId = getRequestHeader(req, "remote-user-id");
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const username = getRequestHeader(req, "remote-user") || userId;
|
const username = getRequestHeader(req, "remote-user") || userId;
|
||||||
@@ -236,32 +247,41 @@ async function resolveRequestUser(
|
|||||||
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
|
const orgRoles = orgId ? await getUserOrgRoles(userId, orgId) : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
user: {
|
||||||
username,
|
userId,
|
||||||
email: email || null,
|
username,
|
||||||
name: name || null,
|
email: email || null,
|
||||||
role:
|
name: name || null,
|
||||||
role || orgRoles.map((r) => r.roleName).join(", ") || null,
|
role:
|
||||||
roleIds: orgRoles.map((r) => r.roleId)
|
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) {
|
if (resourceId != null) {
|
||||||
return null;
|
return { user: null, virtualApiKeyId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public inference must come through Badger; do not authorize via app session.
|
// Public inference must come through Badger; do not authorize via app session.
|
||||||
if (resourceId != null) {
|
if (resourceId != null) {
|
||||||
return null;
|
return { user: null, virtualApiKeyId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
const sessionToken = req.cookies?.[SESSION_COOKIE_NAME];
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
const { session, user } = await validateSessionToken(sessionToken);
|
const { session, user } = await validateSessionToken(sessionToken);
|
||||||
if (session && user) {
|
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;
|
const ip = req.ip;
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
return null;
|
return { user: null, virtualApiKeyId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitNodeRanges = await getExitNodeRanges();
|
const exitNodeRanges = await getExitNodeRanges();
|
||||||
@@ -277,15 +297,18 @@ async function resolveRequestUser(
|
|||||||
isIpInCidr(ip, range)
|
isIpInCidr(ip, range)
|
||||||
);
|
);
|
||||||
if (!inExitNodeRange) {
|
if (!inExitNodeRange) {
|
||||||
return null;
|
return { user: null, virtualApiKeyId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await findClientByIp(ip);
|
const client = await findClientByIp(ip);
|
||||||
if (!client || !client.userId) {
|
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 {
|
function getRequestHeader(req: Request, name: string): string | undefined {
|
||||||
@@ -600,6 +623,7 @@ export function recordAiGatewayCompletion(args: {
|
|||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
requestUserId: string | null;
|
requestUserId: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
budgets: AiBudget[];
|
budgets: AiBudget[];
|
||||||
}): void {
|
}): void {
|
||||||
const {
|
const {
|
||||||
@@ -615,6 +639,7 @@ export function recordAiGatewayCompletion(args: {
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
requestUserId,
|
requestUserId,
|
||||||
|
virtualApiKeyId,
|
||||||
budgets
|
budgets
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
@@ -660,6 +685,7 @@ export function recordAiGatewayCompletion(args: {
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
userId: requestUserId,
|
userId: requestUserId,
|
||||||
|
virtualApiKeyId,
|
||||||
requestedModel: model ?? "unknown",
|
requestedModel: model ?? "unknown",
|
||||||
usage,
|
usage,
|
||||||
costUsd: cost?.totalCost ?? null,
|
costUsd: cost?.totalCost ?? null,
|
||||||
@@ -691,7 +717,8 @@ export function recordAiGatewayCompletion(args: {
|
|||||||
orgId,
|
orgId,
|
||||||
resourceId,
|
resourceId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
requestUserId
|
requestUserId,
|
||||||
|
virtualApiKeyId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +797,7 @@ export async function handleAiGatewayProxy(
|
|||||||
|
|
||||||
const requestedModel = def.extractModel(req);
|
const requestedModel = def.extractModel(req);
|
||||||
|
|
||||||
const [requestUser, selection] = await Promise.all([
|
const [identity, selection] = await Promise.all([
|
||||||
resolveRequestUser(req, resourceId, orgId),
|
resolveRequestUser(req, resourceId, orgId),
|
||||||
selectProvider(
|
selectProvider(
|
||||||
capableAttachments,
|
capableAttachments,
|
||||||
@@ -778,6 +805,7 @@ export async function handleAiGatewayProxy(
|
|||||||
requestedModel
|
requestedModel
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
|
const requestUser = identity.user;
|
||||||
|
|
||||||
if (requestUser) {
|
if (requestUser) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -836,7 +864,8 @@ export async function handleAiGatewayProxy(
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
budgets: appliedBudgets
|
budgets: appliedBudgets,
|
||||||
|
virtualApiKeyId: identity.virtualApiKeyId
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -987,6 +1016,7 @@ export async function handleAiGatewayProxy(
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
requestUserId: requestUser?.userId ?? null,
|
requestUserId: requestUser?.userId ?? null,
|
||||||
|
virtualApiKeyId: identity.virtualApiKeyId,
|
||||||
budgets: appliedBudgets
|
budgets: appliedBudgets
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export async function proxyAiGatewayToSiteTarget(
|
|||||||
siteResourceId: number | null;
|
siteResourceId: number | null;
|
||||||
requestedModel: string | undefined;
|
requestedModel: string | undefined;
|
||||||
budgets: AiBudget[];
|
budgets: AiBudget[];
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const providerTargets = await getProviderTargets(provider.providerId);
|
const providerTargets = await getProviderTargets(provider.providerId);
|
||||||
@@ -319,6 +320,7 @@ export async function proxyAiGatewayToSiteTarget(
|
|||||||
resourceId: ctx.resourceId,
|
resourceId: ctx.resourceId,
|
||||||
siteResourceId: ctx.siteResourceId,
|
siteResourceId: ctx.siteResourceId,
|
||||||
requestUserId: requestUser?.userId ?? null,
|
requestUserId: requestUser?.userId ?? null,
|
||||||
|
virtualApiKeyId: ctx.virtualApiKeyId,
|
||||||
budgets: ctx.budgets
|
budgets: ctx.budgets
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
|
|||||||
.transform(Number)
|
.transform(Number)
|
||||||
.pipe(z.int().positive())
|
.pipe(z.int().positive())
|
||||||
.optional(),
|
.optional(),
|
||||||
userId: z.string().optional()
|
userId: z.string().optional(),
|
||||||
|
virtualApiKeyId: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiUsageAnalyticsParams = z.object({
|
export const aiUsageAnalyticsParams = z.object({
|
||||||
@@ -114,6 +115,9 @@ export function buildAiUsageWhere(
|
|||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined,
|
data.userId ? eq(aiUsageRecords.userId, data.userId) : undefined,
|
||||||
|
data.virtualApiKeyId
|
||||||
|
? eq(aiUsageRecords.virtualApiKeyId, data.virtualApiKeyId)
|
||||||
|
: undefined,
|
||||||
roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined
|
roleUserIds ? inArray(aiUsageRecords.userId, roleUserIds) : undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from "./queryAiUsageOverview";
|
|||||||
export * from "./queryAiUsageProviders";
|
export * from "./queryAiUsageProviders";
|
||||||
export * from "./queryAiUsageResources";
|
export * from "./queryAiUsageResources";
|
||||||
export * from "./queryAiUsageUsersRoles";
|
export * from "./queryAiUsageUsersRoles";
|
||||||
|
export * from "./queryAiUsageVirtualApiKeys";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
users,
|
users,
|
||||||
|
virtualApiKeys,
|
||||||
db,
|
db,
|
||||||
primaryDb
|
primaryDb
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
@@ -67,6 +68,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
|
|||||||
.pipe(z.int().positive())
|
.pipe(z.int().positive())
|
||||||
.optional(),
|
.optional(),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
|
virtualApiKeyId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
isStream: z
|
isStream: z
|
||||||
.union([z.boolean(), z.string()])
|
.union([z.boolean(), z.string()])
|
||||||
@@ -117,7 +119,9 @@ function getWhere(data: Q) {
|
|||||||
data.providerId
|
data.providerId
|
||||||
? eq(aiSessionLog.providerId, data.providerId)
|
? eq(aiSessionLog.providerId, data.providerId)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.capability ? eq(aiSessionLog.capability, data.capability) : undefined,
|
data.capability
|
||||||
|
? eq(aiSessionLog.capability, data.capability)
|
||||||
|
: undefined,
|
||||||
data.resourceId
|
data.resourceId
|
||||||
? or(
|
? or(
|
||||||
eq(aiSessionLog.resourceId, data.resourceId),
|
eq(aiSessionLog.resourceId, data.resourceId),
|
||||||
@@ -125,9 +129,10 @@ function getWhere(data: Q) {
|
|||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
data.actor ? eq(aiSessionLog.userId, data.actor) : undefined,
|
||||||
data.model
|
data.virtualApiKeyId
|
||||||
? eq(aiSessionLog.requestedModel, data.model)
|
? eq(aiSessionLog.virtualApiKeyId, data.virtualApiKeyId)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
data.model ? eq(aiSessionLog.requestedModel, data.model) : undefined,
|
||||||
data.isStream !== undefined
|
data.isStream !== undefined
|
||||||
? eq(aiSessionLog.isStream, data.isStream)
|
? eq(aiSessionLog.isStream, data.isStream)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -145,6 +150,7 @@ export function queryAiSession(data: Q) {
|
|||||||
resourceId: aiSessionLog.resourceId,
|
resourceId: aiSessionLog.resourceId,
|
||||||
siteResourceId: aiSessionLog.siteResourceId,
|
siteResourceId: aiSessionLog.siteResourceId,
|
||||||
userId: aiSessionLog.userId,
|
userId: aiSessionLog.userId,
|
||||||
|
virtualApiKeyId: aiSessionLog.virtualApiKeyId,
|
||||||
requestedModel: aiSessionLog.requestedModel,
|
requestedModel: aiSessionLog.requestedModel,
|
||||||
isStream: aiSessionLog.isStream,
|
isStream: aiSessionLog.isStream,
|
||||||
requestBody: aiSessionLog.requestBody,
|
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<
|
const providerMap = new Map<
|
||||||
number,
|
number,
|
||||||
{ name: string | null; type: string | null }
|
{ 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<
|
const usageMap = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -328,6 +364,12 @@ async function enrichWithDetails(
|
|||||||
resourceName,
|
resourceName,
|
||||||
resourceNiceId,
|
resourceNiceId,
|
||||||
userEmail: log.userId ? (userMap.get(log.userId) ?? null) : null,
|
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
|
usage: usageMap.get(log.sessionId) ?? null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -385,7 +427,8 @@ async function queryUniqueFilterAttributes(
|
|||||||
uniqueUsers,
|
uniqueUsers,
|
||||||
uniqueResources,
|
uniqueResources,
|
||||||
uniqueSiteResources,
|
uniqueSiteResources,
|
||||||
uniqueModels
|
uniqueModels,
|
||||||
|
uniqueVirtualApiKeys
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
logsDb
|
logsDb
|
||||||
.selectDistinct({ id: aiSessionLog.providerId })
|
.selectDistinct({ id: aiSessionLog.providerId })
|
||||||
@@ -411,6 +454,11 @@ async function queryUniqueFilterAttributes(
|
|||||||
.selectDistinct({ model: aiSessionLog.requestedModel })
|
.selectDistinct({ model: aiSessionLog.requestedModel })
|
||||||
.from(aiSessionLog)
|
.from(aiSessionLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
logsDb
|
||||||
|
.selectDistinct({ id: aiSessionLog.virtualApiKeyId })
|
||||||
|
.from(aiSessionLog)
|
||||||
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1)
|
.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 {
|
return {
|
||||||
providers: sortNamedFilterOptions(providers),
|
providers: sortNamedFilterOptions(providers),
|
||||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
users: userList,
|
users: userList,
|
||||||
|
virtualApiKeys: virtualApiKeyList,
|
||||||
models: models.sort()
|
models: models.sort()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
siteResources,
|
siteResources,
|
||||||
users,
|
users,
|
||||||
roles,
|
roles,
|
||||||
userOrgRoles
|
userOrgRoles,
|
||||||
|
virtualApiKeys
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
@@ -43,8 +44,9 @@ const queryAiUsageFilterOptionsParams = z.object({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryAiUsageFilterOptionsCombined =
|
const queryAiUsageFilterOptionsCombined = queryAiUsageFilterOptionsQuery.merge(
|
||||||
queryAiUsageFilterOptionsQuery.merge(queryAiUsageFilterOptionsParams);
|
queryAiUsageFilterOptionsParams
|
||||||
|
);
|
||||||
type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>;
|
type Q = z.infer<typeof queryAiUsageFilterOptionsCombined>;
|
||||||
|
|
||||||
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
@@ -73,7 +75,8 @@ async function query(data: Q) {
|
|||||||
uniqueModels,
|
uniqueModels,
|
||||||
uniqueResources,
|
uniqueResources,
|
||||||
uniqueSiteResources,
|
uniqueSiteResources,
|
||||||
uniqueUsers
|
uniqueUsers,
|
||||||
|
uniqueVirtualApiKeys
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db
|
db
|
||||||
.selectDistinct({ id: aiUsageRecords.providerId })
|
.selectDistinct({ id: aiUsageRecords.providerId })
|
||||||
@@ -105,6 +108,13 @@ async function query(data: Q) {
|
|||||||
.selectDistinct({ userId: aiUsageRecords.userId })
|
.selectDistinct({ userId: aiUsageRecords.userId })
|
||||||
.from(aiUsageRecords)
|
.from(aiUsageRecords)
|
||||||
.where(and(baseConditions, not(isNull(aiUsageRecords.userId))))
|
.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)
|
.limit(DISTINCT_LIMIT + 1)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -120,11 +130,17 @@ async function query(data: Q) {
|
|||||||
let providers: Array<{ id: number; name: string | null }> = [];
|
let providers: Array<{ id: number; name: string | null }> = [];
|
||||||
if (providerIds.length > 0) {
|
if (providerIds.length > 0) {
|
||||||
const providerDetails = await db
|
const providerDetails = await db
|
||||||
.select({ providerId: aiProviders.providerId, name: aiProviders.name })
|
.select({
|
||||||
|
providerId: aiProviders.providerId,
|
||||||
|
name: aiProviders.name
|
||||||
|
})
|
||||||
.from(aiProviders)
|
.from(aiProviders)
|
||||||
.where(inArray(aiProviders.providerId, providerIds));
|
.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
|
const resourceIds = uniqueResources
|
||||||
@@ -155,7 +171,10 @@ async function query(data: Q) {
|
|||||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
resourcesWithNames = resourcesWithNames.concat(
|
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 }));
|
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 {
|
return {
|
||||||
providers: sortNamedFilterOptions(providers),
|
providers: sortNamedFilterOptions(providers),
|
||||||
resources: sortNamedFilterOptions(resourcesWithNames),
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
roles: sortNamedFilterOptions(roleList),
|
roles: sortNamedFilterOptions(roleList),
|
||||||
users: userList,
|
users: userList,
|
||||||
|
virtualApiKeys: virtualApiKeyList,
|
||||||
models
|
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(
|
export async function queryAiUsageFilterOptions(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -238,7 +285,10 @@ export async function queryAiUsageFilterOptions(
|
|||||||
const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query);
|
const parsedQuery = queryAiUsageFilterOptionsQuery.safeParse(req.query);
|
||||||
if (!parsedQuery.success) {
|
if (!parsedQuery.success) {
|
||||||
return next(
|
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) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,9 @@ export type QueryAiSessionLogResponse = {
|
|||||||
resourceType: "public" | "site" | null;
|
resourceType: "public" | "site" | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
|
virtualApiKeyName: string | null;
|
||||||
|
virtualApiKeyLastChars: string | null;
|
||||||
requestedModel: string | null;
|
requestedModel: string | null;
|
||||||
isStream: boolean;
|
isStream: boolean;
|
||||||
requestBody: string | null;
|
requestBody: string | null;
|
||||||
@@ -148,6 +151,11 @@ export type QueryAiSessionLogResponse = {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
virtualApiKeys: {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
lastChars: string | null;
|
||||||
|
}[];
|
||||||
models: string[];
|
models: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export type VerifyUserResponse = {
|
|||||||
pangolinVersion?: string;
|
pangolinVersion?: string;
|
||||||
dontStripSession?: boolean;
|
dontStripSession?: boolean;
|
||||||
clientError?: ClientErrorResponse;
|
clientError?: ClientErrorResponse;
|
||||||
|
// 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
function notAllowedWithClientError(
|
function notAllowedWithClientError(
|
||||||
@@ -419,7 +423,12 @@ export async function verifyResourceSession(
|
|||||||
parsedBody.data
|
parsedBody.data
|
||||||
);
|
);
|
||||||
|
|
||||||
return allowed(res, vakUserData, dontStripSession);
|
return allowed(
|
||||||
|
res,
|
||||||
|
vakUserData,
|
||||||
|
dontStripSession,
|
||||||
|
key.virtualApiKeyId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,16 +1023,20 @@ async function notAllowed(
|
|||||||
function allowed(
|
function allowed(
|
||||||
res: Response,
|
res: Response,
|
||||||
userData?: BasicUserData,
|
userData?: BasicUserData,
|
||||||
dontStripSession?: boolean
|
dontStripSession?: boolean,
|
||||||
|
virtualApiKeyId?: string
|
||||||
) {
|
) {
|
||||||
const baseData =
|
const baseData =
|
||||||
userData !== undefined && userData !== null
|
userData !== undefined && userData !== null
|
||||||
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
|
? { valid: true, ...userData, pangolinVersion: APP_VERSION }
|
||||||
: { valid: true, pangolinVersion: APP_VERSION };
|
: { valid: true, pangolinVersion: APP_VERSION };
|
||||||
|
const withVirtualApiKey = virtualApiKeyId
|
||||||
|
? { ...baseData, virtualApiKeyId }
|
||||||
|
: baseData;
|
||||||
const data = {
|
const data = {
|
||||||
data: dontStripSession
|
data: dontStripSession
|
||||||
? { ...baseData, dontStripSession: true }
|
? { ...withVirtualApiKey, dontStripSession: true }
|
||||||
: baseData,
|
: withVirtualApiKey,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Access allowed",
|
message: "Access allowed",
|
||||||
|
|||||||
@@ -1537,6 +1537,13 @@ authenticated.get(
|
|||||||
logs.queryAiUsageUsersRoles
|
logs.queryAiUsageUsersRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai/usage/virtual-api-keys",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||||
|
logs.queryAiUsageVirtualApiKeys
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/blueprints",
|
"/org/:orgId/blueprints",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
|||||||
@@ -1580,6 +1580,13 @@ authenticated.get(
|
|||||||
logs.queryAiUsageUsersRoles
|
logs.queryAiUsageUsersRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/logs/ai/usage/virtual-api-keys",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.viewLogs),
|
||||||
|
logs.queryAiUsageVirtualApiKeys
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/analytics",
|
"/org/:orgId/logs/analytics",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
|
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -50,6 +51,7 @@ export default function AiSessionLogsPage() {
|
|||||||
capability?: string;
|
capability?: string;
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
actor?: string;
|
actor?: string;
|
||||||
|
virtualApiKeyId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
isStream?: string;
|
isStream?: string;
|
||||||
}>({
|
}>({
|
||||||
@@ -57,6 +59,7 @@ export default function AiSessionLogsPage() {
|
|||||||
capability: searchParams.get("capability") || undefined,
|
capability: searchParams.get("capability") || undefined,
|
||||||
resourceId: searchParams.get("resourceId") || undefined,
|
resourceId: searchParams.get("resourceId") || undefined,
|
||||||
actor: searchParams.get("actor") || undefined,
|
actor: searchParams.get("actor") || undefined,
|
||||||
|
virtualApiKeyId: searchParams.get("virtualApiKeyId") || undefined,
|
||||||
model: searchParams.get("model") || undefined,
|
model: searchParams.get("model") || undefined,
|
||||||
isStream: searchParams.get("isStream") || undefined
|
isStream: searchParams.get("isStream") || undefined
|
||||||
});
|
});
|
||||||
@@ -135,6 +138,7 @@ export default function AiSessionLogsPage() {
|
|||||||
providers: [],
|
providers: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
users: [],
|
users: [],
|
||||||
|
virtualApiKeys: [],
|
||||||
models: []
|
models: []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,7 +362,9 @@ export default function AiSessionLogsPage() {
|
|||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.resourceNiceId) {
|
if (!row.original.resourceNiceId) {
|
||||||
return <span className="text-xs text-muted-foreground">-</span>;
|
return (
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -453,13 +459,67 @@ export default function AiSessionLogsPage() {
|
|||||||
</span>
|
</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) => {
|
const renderExpandedRow = (row: any) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<strong>{t("aiSessionId")}</strong>
|
<strong>{t("aiSessionId")}</strong>
|
||||||
<p className="text-muted-foreground mt-1 break-all">
|
<p className="text-muted-foreground mt-1 break-all">
|
||||||
@@ -490,6 +550,30 @@ export default function AiSessionLogsPage() {
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{row.usage && (
|
{row.usage && (
|
||||||
<div>
|
<div>
|
||||||
@@ -599,15 +683,25 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
|||||||
];
|
];
|
||||||
const actors = ["alice@example.com", "bob@example.com", null];
|
const actors = ["alice@example.com", "bob@example.com", null];
|
||||||
const models = ["gpt-4o", "claude-sonnet-5", "gemini-2.5-pro"];
|
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 now = Date.now();
|
||||||
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
return Array.from({ length: 10 }, (_, i) => {
|
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 =
|
const resource =
|
||||||
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
resourcesSample[Math.floor(Math.random() * resourcesSample.length)];
|
||||||
const actor = actors[Math.floor(Math.random() * actors.length)];
|
const actor = actors[Math.floor(Math.random() * actors.length)];
|
||||||
|
const virtualApiKey =
|
||||||
|
virtualApiKeysSample[
|
||||||
|
Math.floor(Math.random() * virtualApiKeysSample.length)
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: i,
|
id: i,
|
||||||
@@ -625,6 +719,9 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
|
|||||||
resourceType: "public",
|
resourceType: "public",
|
||||||
userId: actor ? `user-${i}` : null,
|
userId: actor ? `user-${i}` : null,
|
||||||
userEmail: actor,
|
userEmail: actor,
|
||||||
|
virtualApiKeyId: virtualApiKey?.id ?? null,
|
||||||
|
virtualApiKeyName: virtualApiKey?.name ?? null,
|
||||||
|
virtualApiKeyLastChars: virtualApiKey?.lastChars ?? null,
|
||||||
requestedModel: models[Math.floor(Math.random() * models.length)],
|
requestedModel: models[Math.floor(Math.random() * models.length)],
|
||||||
isStream: Math.random() > 0.5,
|
isStream: Math.random() > 0.5,
|
||||||
requestBody: null,
|
requestBody: null,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
|
|||||||
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
|
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
|
||||||
import { RolesTab } from "./ai-usage-analytics/RolesTab";
|
import { RolesTab } from "./ai-usage-analytics/RolesTab";
|
||||||
import { UsersTab } from "./ai-usage-analytics/UsersTab";
|
import { UsersTab } from "./ai-usage-analytics/UsersTab";
|
||||||
|
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
|
||||||
|
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||||
|
|
||||||
export type AiUsageAnalyticsDataProps = {
|
export type AiUsageAnalyticsDataProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -140,13 +142,20 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
|||||||
value: u.id,
|
value: u.id,
|
||||||
label: u.email ?? 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[] = [
|
const tabs: TabItem[] = [
|
||||||
{ title: t("aiUsageTabOverview"), href: "#" },
|
{ title: t("aiUsageTabOverview"), href: "#" },
|
||||||
{ title: t("aiUsageTabProviders"), href: "#" },
|
{ title: t("aiUsageTabProviders"), href: "#" },
|
||||||
{ title: t("aiUsageTabResources"), href: "#" },
|
{ title: t("aiUsageTabResources"), href: "#" },
|
||||||
{ title: t("aiUsageRolesTab"), href: "#" },
|
{ title: t("aiUsageRolesTab"), href: "#" },
|
||||||
{ title: t("aiUsageUsersTab"), href: "#" }
|
{ title: t("aiUsageUsersTab"), href: "#" },
|
||||||
|
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +189,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
|||||||
value={filters.providerId?.toString()}
|
value={filters.providerId?.toString()}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
placeholder={t("aiUsageFilterAllProviders")}
|
placeholder={t("aiUsageFilterAllProviders")}
|
||||||
onValueChange={(v) => setFilter("providerId", v)}
|
onValueChange={(v) =>
|
||||||
|
setFilter("providerId", v)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
id="model"
|
id="model"
|
||||||
@@ -196,7 +207,9 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
|||||||
value={filters.resourceId?.toString()}
|
value={filters.resourceId?.toString()}
|
||||||
options={resourceOptions}
|
options={resourceOptions}
|
||||||
placeholder={t("aiUsageFilterAllResources")}
|
placeholder={t("aiUsageFilterAllResources")}
|
||||||
onValueChange={(v) => setFilter("resourceId", v)}
|
onValueChange={(v) =>
|
||||||
|
setFilter("resourceId", v)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
id="roleId"
|
id="roleId"
|
||||||
@@ -214,6 +227,18 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
|||||||
placeholder={t("aiUsageFilterAllUsers")}
|
placeholder={t("aiUsageFilterAllUsers")}
|
||||||
onValueChange={(v) => setFilter("userId", v)}
|
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 && (
|
{!isEmptySearchParams && (
|
||||||
<Button
|
<Button
|
||||||
@@ -259,6 +284,7 @@ export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
|||||||
<ResourcesTab orgId={props.orgId} filters={filters} />
|
<ResourcesTab orgId={props.orgId} filters={filters} />
|
||||||
<RolesTab orgId={props.orgId} filters={filters} />
|
<RolesTab orgId={props.orgId} filters={filters} />
|
||||||
<UsersTab orgId={props.orgId} filters={filters} />
|
<UsersTab orgId={props.orgId} filters={filters} />
|
||||||
|
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
|
||||||
</HorizontalTabs>
|
</HorizontalTabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export function LayoutHeader({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const logoWidth = isUnlocked()
|
const logoWidth = isUnlocked()
|
||||||
? env.branding.logo?.navbar?.width || 98
|
? env.branding.logo?.navbar?.width || 128
|
||||||
: 98;
|
: 128;
|
||||||
const logoHeight = isUnlocked()
|
const logoHeight = isUnlocked()
|
||||||
? env.branding.logo?.navbar?.height || 32
|
? env.branding.logo?.navbar?.height || 32
|
||||||
: 32;
|
: 32;
|
||||||
|
|||||||
@@ -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
@@ -12,7 +12,8 @@ import type {
|
|||||||
QueryAiUsageOverviewResponse,
|
QueryAiUsageOverviewResponse,
|
||||||
QueryAiUsageProvidersResponse,
|
QueryAiUsageProvidersResponse,
|
||||||
QueryAiUsageResourcesResponse,
|
QueryAiUsageResourcesResponse,
|
||||||
QueryAiUsageUsersRolesResponse
|
QueryAiUsageUsersRolesResponse,
|
||||||
|
QueryAiUsageVirtualApiKeysResponse
|
||||||
} from "@server/routers/auditLogs";
|
} from "@server/routers/auditLogs";
|
||||||
import type {
|
import type {
|
||||||
QueryAccessAuditLogResponse,
|
QueryAccessAuditLogResponse,
|
||||||
@@ -954,7 +955,8 @@ export const aiUsageAnalyticsFiltersSchema = z.object({
|
|||||||
model: z.string().optional().catch(undefined),
|
model: z.string().optional().catch(undefined),
|
||||||
resourceId: z.coerce.number().optional().catch(undefined),
|
resourceId: z.coerce.number().optional().catch(undefined),
|
||||||
roleId: 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<
|
export type AiUsageAnalyticsFilters = z.output<
|
||||||
@@ -1086,6 +1088,7 @@ export const aiSessionLogsFiltersSchema = z.object({
|
|||||||
capability: z.string().optional().catch(undefined),
|
capability: z.string().optional().catch(undefined),
|
||||||
resourceId: z.string().optional().catch(undefined),
|
resourceId: z.string().optional().catch(undefined),
|
||||||
actor: z.string().optional().catch(undefined),
|
actor: z.string().optional().catch(undefined),
|
||||||
|
virtualApiKeyId: z.string().optional().catch(undefined),
|
||||||
model: z.string().optional().catch(undefined),
|
model: z.string().optional().catch(undefined),
|
||||||
isStream: z.string().optional().catch(undefined)
|
isStream: z.string().optional().catch(undefined)
|
||||||
});
|
});
|
||||||
@@ -1284,7 +1287,12 @@ export const aiUsageAnalyticsQueries = {
|
|||||||
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
|
filters: Pick<AiUsageAnalyticsFilters, "timeStart" | "timeEnd">;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "FILTERS", filters] as const,
|
queryKey: [
|
||||||
|
"AI_USAGE_ANALYTICS",
|
||||||
|
orgId,
|
||||||
|
"FILTERS",
|
||||||
|
filters
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryAiUsageFilterOptionsResponse>
|
AxiosResponse<QueryAiUsageFilterOptionsResponse>
|
||||||
@@ -1304,7 +1312,12 @@ export const aiUsageAnalyticsQueries = {
|
|||||||
filters: AiUsageAnalyticsFilters;
|
filters: AiUsageAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "OVERVIEW", filters] as const,
|
queryKey: [
|
||||||
|
"AI_USAGE_ANALYTICS",
|
||||||
|
orgId,
|
||||||
|
"OVERVIEW",
|
||||||
|
filters
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryAiUsageOverviewResponse>
|
AxiosResponse<QueryAiUsageOverviewResponse>
|
||||||
@@ -1330,7 +1343,12 @@ export const aiUsageAnalyticsQueries = {
|
|||||||
filters: AiUsageAnalyticsFilters;
|
filters: AiUsageAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "PROVIDERS", filters] as const,
|
queryKey: [
|
||||||
|
"AI_USAGE_ANALYTICS",
|
||||||
|
orgId,
|
||||||
|
"PROVIDERS",
|
||||||
|
filters
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryAiUsageProvidersResponse>
|
AxiosResponse<QueryAiUsageProvidersResponse>
|
||||||
@@ -1356,7 +1374,12 @@ export const aiUsageAnalyticsQueries = {
|
|||||||
filters: AiUsageAnalyticsFilters;
|
filters: AiUsageAnalyticsFilters;
|
||||||
}) =>
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["AI_USAGE_ANALYTICS", orgId, "RESOURCES", filters] as const,
|
queryKey: [
|
||||||
|
"AI_USAGE_ANALYTICS",
|
||||||
|
orgId,
|
||||||
|
"RESOURCES",
|
||||||
|
filters
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<QueryAiUsageResourcesResponse>
|
AxiosResponse<QueryAiUsageResourcesResponse>
|
||||||
@@ -1403,6 +1426,37 @@ export const aiUsageAnalyticsQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user