mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: update the tracking usage filter for the usage page
This commit is contained in:
@@ -1,194 +1,165 @@
|
|||||||
// Ensure proxyFetch is loaded to patch globalThis.fetch
|
// Ensure proxyFetch is loaded to patch globalThis.fetch
|
||||||
import "open-sse/index.js";
|
import "open-sse/index.js";
|
||||||
|
|
||||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
import { getExecutor } from "open-sse/executors/index.js";
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
||||||
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
|
import { getExecutor } from "open-sse/executors/index.js";
|
||||||
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||||
|
|
||||||
// Detect auth-expired messages returned by usage providers instead of throwing
|
const AUTH_EXPIRED_PATTERNS = [
|
||||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
"expired",
|
||||||
function isAuthExpiredMessage(usage) {
|
"authentication",
|
||||||
if (!usage?.message) return false;
|
"unauthorized",
|
||||||
const msg = usage.message.toLowerCase();
|
"401",
|
||||||
return AUTH_EXPIRED_PATTERNS.some((p) => msg.includes(p));
|
"re-authorize",
|
||||||
}
|
];
|
||||||
|
|
||||||
/**
|
function isAuthExpiredMessage(usage) {
|
||||||
* Refresh credentials using executor and update database
|
if (!usage?.message) return false;
|
||||||
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
|
const message = usage.message.toLowerCase();
|
||||||
* @returns Promise<{ connection, refreshed: boolean }>
|
return AUTH_EXPIRED_PATTERNS.some((pattern) => message.includes(pattern));
|
||||||
*/
|
}
|
||||||
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
|
|
||||||
const executor = getExecutor(connection.provider);
|
/**
|
||||||
|
* Refresh connection credentials when required and persist the result.
|
||||||
// Build credentials object from connection
|
* @param {object} connection Provider connection.
|
||||||
const credentials = {
|
* @param {boolean} force Refresh even if the executor considers the token valid.
|
||||||
accessToken: connection.accessToken,
|
* @param {object|null} proxyOptions Connection proxy configuration.
|
||||||
refreshToken: connection.refreshToken,
|
* @returns {Promise<{ connection: object, refreshed: boolean }>}
|
||||||
idToken: connection.idToken,
|
*/
|
||||||
expiresAt: connection.expiresAt || connection.tokenExpiresAt,
|
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
|
||||||
lastRefreshAt: connection.lastRefreshAt,
|
const executor = getExecutor(connection.provider);
|
||||||
connectionId: connection.id,
|
const credentials = {
|
||||||
providerSpecificData: connection.providerSpecificData,
|
accessToken: connection.accessToken,
|
||||||
// For GitHub
|
refreshToken: connection.refreshToken,
|
||||||
copilotToken: connection.providerSpecificData?.copilotToken,
|
idToken: connection.idToken,
|
||||||
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
|
expiresAt: connection.expiresAt || connection.tokenExpiresAt,
|
||||||
};
|
lastRefreshAt: connection.lastRefreshAt,
|
||||||
|
connectionId: connection.id,
|
||||||
// Check if refresh is needed (skip when force=true)
|
providerSpecificData: connection.providerSpecificData,
|
||||||
const needsRefresh = force || executor.needsRefresh(credentials);
|
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||||
|
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
|
||||||
if (!needsRefresh) {
|
};
|
||||||
return { connection, refreshed: false };
|
|
||||||
}
|
if (!force && !executor.needsRefresh(credentials)) {
|
||||||
|
return { connection, refreshed: false };
|
||||||
// Use executor's refreshCredentials method (with optional proxy)
|
}
|
||||||
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
|
|
||||||
|
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
|
||||||
if (!refreshResult) {
|
if (!refreshResult) {
|
||||||
// Refresh failed but we still have an accessToken — try with existing token
|
if (connection.accessToken) return { connection, refreshed: false };
|
||||||
if (connection.accessToken) {
|
throw new Error("Failed to refresh credentials. Please re-authorize the connection.");
|
||||||
return { connection, refreshed: false };
|
}
|
||||||
}
|
|
||||||
throw new Error("Failed to refresh credentials. Please re-authorize the connection.");
|
const updateData = { updatedAt: new Date().toISOString() };
|
||||||
}
|
if (refreshResult.accessToken) updateData.accessToken = refreshResult.accessToken;
|
||||||
|
if (refreshResult.refreshToken) updateData.refreshToken = refreshResult.refreshToken;
|
||||||
// Build update object
|
if (refreshResult.idToken) updateData.idToken = refreshResult.idToken;
|
||||||
const now = new Date().toISOString();
|
if (refreshResult.lastRefreshAt) updateData.lastRefreshAt = refreshResult.lastRefreshAt;
|
||||||
const updateData = {
|
|
||||||
updatedAt: now,
|
if (refreshResult.expiresIn) {
|
||||||
};
|
updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
|
||||||
|
updateData.expiresIn = refreshResult.expiresIn;
|
||||||
// Update accessToken if present
|
} else if (refreshResult.expiresAt) {
|
||||||
if (refreshResult.accessToken) {
|
updateData.expiresAt = refreshResult.expiresAt;
|
||||||
updateData.accessToken = refreshResult.accessToken;
|
}
|
||||||
}
|
|
||||||
|
const providerSpecificUpdates = {
|
||||||
// Update refreshToken if present
|
...(refreshResult.providerSpecificData || {}),
|
||||||
if (refreshResult.refreshToken) {
|
...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}),
|
||||||
updateData.refreshToken = refreshResult.refreshToken;
|
...(refreshResult.copilotTokenExpiresAt
|
||||||
}
|
? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt }
|
||||||
|
: {}),
|
||||||
if (refreshResult.idToken) {
|
};
|
||||||
updateData.idToken = refreshResult.idToken;
|
if (Object.keys(providerSpecificUpdates).length > 0) {
|
||||||
}
|
updateData.providerSpecificData = {
|
||||||
|
...(connection.providerSpecificData || {}),
|
||||||
if (refreshResult.lastRefreshAt) {
|
...providerSpecificUpdates,
|
||||||
updateData.lastRefreshAt = refreshResult.lastRefreshAt;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update token expiry
|
await updateProviderConnection(connection.id, updateData);
|
||||||
if (refreshResult.expiresIn) {
|
|
||||||
updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
|
return {
|
||||||
updateData.expiresIn = refreshResult.expiresIn;
|
connection: {
|
||||||
} else if (refreshResult.expiresAt) {
|
...connection,
|
||||||
updateData.expiresAt = refreshResult.expiresAt;
|
...updateData,
|
||||||
}
|
providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData,
|
||||||
|
},
|
||||||
// Handle provider-specific data (copilotToken for GitHub, etc.)
|
refreshed: true,
|
||||||
const providerSpecificUpdates = {
|
};
|
||||||
...(refreshResult.providerSpecificData || {}),
|
}
|
||||||
...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}),
|
|
||||||
...(refreshResult.copilotTokenExpiresAt ? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt } : {}),
|
/**
|
||||||
};
|
* GET /api/usage/[connectionId] - Get quota data for one provider connection.
|
||||||
if (Object.keys(providerSpecificUpdates).length > 0) {
|
*/
|
||||||
updateData.providerSpecificData = {
|
export async function GET(_request, { params }) {
|
||||||
...(connection.providerSpecificData || {}),
|
let connection;
|
||||||
...providerSpecificUpdates,
|
try {
|
||||||
};
|
const { connectionId } = await params;
|
||||||
}
|
const user = await requireUsageDashboardUser();
|
||||||
|
|
||||||
// Update database
|
connection = await getProviderConnectionById(
|
||||||
await updateProviderConnection(connection.id, updateData);
|
connectionId,
|
||||||
|
user.role === "admin" ? null : user.id,
|
||||||
// Return updated connection
|
);
|
||||||
const updatedConnection = {
|
if (!connection) {
|
||||||
...connection,
|
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||||
...updateData,
|
}
|
||||||
providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData,
|
|
||||||
};
|
const isOAuth = connection.authType === "oauth";
|
||||||
|
const isApikeyAuth =
|
||||||
return {
|
connection.authType === "apikey" || connection.authType === "api_key";
|
||||||
connection: updatedConnection,
|
const isApikeyEligible =
|
||||||
refreshed: true,
|
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||||
};
|
if (!isOAuth && !isApikeyEligible) {
|
||||||
}
|
return Response.json({ message: "Usage not available for this connection" });
|
||||||
|
}
|
||||||
/**
|
|
||||||
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
||||||
*/
|
const proxyOptions = {
|
||||||
export async function GET(request, { params }) {
|
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
||||||
let connection;
|
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
||||||
try {
|
connectionNoProxy: proxyConfig.connectionNoProxy || "",
|
||||||
const { connectionId } = await params;
|
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
|
||||||
|
strictProxy: false,
|
||||||
|
};
|
||||||
// Get connection from database
|
|
||||||
connection = await getProviderConnectionById(connectionId);
|
if (isOAuth) {
|
||||||
if (!connection) {
|
try {
|
||||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||||
}
|
connection = result.connection;
|
||||||
|
} catch (refreshError) {
|
||||||
// Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...)
|
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||||
// Kiro's headless api-key flow persists authType "api_key" (underscore) while
|
return Response.json(
|
||||||
// generic apikey providers persist "apikey" — accept both spellings here.
|
{ error: `Credential refresh failed: ${refreshError.message}` },
|
||||||
const isOAuth = connection.authType === "oauth";
|
{ status: 401 },
|
||||||
const isApikeyAuth =
|
);
|
||||||
connection.authType === "apikey" || connection.authType === "api_key";
|
}
|
||||||
const isApikeyEligible =
|
}
|
||||||
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
|
||||||
|
let usage = await getUsageForProvider(connection, proxyOptions);
|
||||||
if (!isOAuth && !isApikeyEligible) {
|
|
||||||
return Response.json({ message: "Usage not available for this connection" });
|
if (isOAuth && isAuthExpiredMessage(usage) && connection.refreshToken) {
|
||||||
}
|
try {
|
||||||
|
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||||
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
|
connection = retryResult.connection;
|
||||||
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
usage = await getUsageForProvider(connection, proxyOptions);
|
||||||
const proxyOptions = {
|
} catch (retryError) {
|
||||||
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
||||||
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
}
|
||||||
connectionNoProxy: proxyConfig.connectionNoProxy || "",
|
}
|
||||||
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
|
|
||||||
strictProxy: false,
|
return Response.json(usage);
|
||||||
};
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") {
|
||||||
// Refresh credentials only for OAuth connections (apikey has no token refresh)
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
if (isOAuth) {
|
}
|
||||||
try {
|
|
||||||
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
const provider = connection?.provider ?? "unknown";
|
||||||
connection = result.connection;
|
console.warn(`[Usage] ${provider}: ${error.message}`);
|
||||||
} catch (refreshError) {
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
console.error("[Usage API] Credential refresh failed:", refreshError);
|
}
|
||||||
return Response.json({
|
}
|
||||||
error: `Credential refresh failed: ${refreshError.message}`
|
|
||||||
}, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const user = await requireUsageDashboardUser();
|
|
||||||
|
|
||||||
// Fetch usage from provider API
|
|
||||||
connection = await getProviderConnectionById(connectionId, user.role === "admin" ? null : user.id);
|
|
||||||
|
|
||||||
// If provider returned an auth-expired message instead of throwing,
|
|
||||||
// force-refresh token and retry once (OAuth only)
|
|
||||||
if (isOAuth && isAuthExpiredMessage(usage) && connection.refreshToken) {
|
|
||||||
try {
|
|
||||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
|
||||||
connection = retryResult.connection;
|
|
||||||
usage = await getUsageForProvider(connection, proxyOptions);
|
|
||||||
} catch (retryError) {
|
|
||||||
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json(usage);
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
const provider = connection?.provider ?? "unknown";
|
|
||||||
console.warn(`[Usage] ${provider}: ${error.message}`);
|
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ function importLegacyUsage(adapter, data) {
|
|||||||
for (const [dateKey, day] of Object.entries(data.dailySummary || {})) {
|
for (const [dateKey, day] of Object.entries(data.dailySummary || {})) {
|
||||||
adapter.run(`INSERT OR REPLACE INTO usageDaily(dateKey, data) VALUES(?, ?)`, [dateKey, stringifyJson(day)]);
|
adapter.run(`INSERT OR REPLACE INTO usageDaily(dateKey, data) VALUES(?, ?)`, [dateKey, stringifyJson(day)]);
|
||||||
}
|
}
|
||||||
|
// Versioned migrations run before legacy import on a fresh database, so
|
||||||
|
// apply the same dashboard-user attribution here for imported history.
|
||||||
|
adapter.run(
|
||||||
|
`UPDATE usageHistory
|
||||||
|
SET userId = COALESCE(
|
||||||
|
(SELECT ownerId FROM apiKeys WHERE apiKeys.key = usageHistory.apiKey AND ownerId IS NOT NULL),
|
||||||
|
(SELECT ownerId FROM providerConnections WHERE providerConnections.id = usageHistory.connectionId AND ownerId IS NOT NULL)
|
||||||
|
)
|
||||||
|
WHERE userId IS NULL OR userId = ''`,
|
||||||
|
);
|
||||||
if (typeof data.totalRequestsLifetime === "number") {
|
if (typeof data.totalRequestsLifetime === "number") {
|
||||||
setMetaSync(adapter, "totalRequestsLifetime", data.totalRequestsLifetime);
|
setMetaSync(adapter, "totalRequestsLifetime", data.totalRequestsLifetime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Attribute historic usage to a dashboard user. API-key ownership is the
|
||||||
|
// request actor and intentionally takes precedence over connection ownership.
|
||||||
|
const usageUserAttributionMigration = {
|
||||||
|
version: 5,
|
||||||
|
name: "usage-user-attribution",
|
||||||
|
up(db) {
|
||||||
|
const columns = db.all("PRAGMA table_info(usageHistory)");
|
||||||
|
if (!columns.some((column) => column.name === "userId")) {
|
||||||
|
db.exec("ALTER TABLE usageHistory ADD COLUMN userId TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_uh_user ON usageHistory(userId)");
|
||||||
|
db.run(
|
||||||
|
`UPDATE usageHistory
|
||||||
|
SET userId = COALESCE(
|
||||||
|
(SELECT ownerId FROM apiKeys WHERE apiKeys.key = usageHistory.apiKey AND ownerId IS NOT NULL),
|
||||||
|
(SELECT ownerId FROM providerConnections WHERE providerConnections.id = usageHistory.connectionId AND ownerId IS NOT NULL)
|
||||||
|
)
|
||||||
|
WHERE userId IS NULL OR userId = ''`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usageUserAttributionMigration;
|
||||||
@@ -5,8 +5,9 @@ import m001 from "./001-initial.js";
|
|||||||
import m002 from "./002-users-table.js";
|
import m002 from "./002-users-table.js";
|
||||||
import m003 from "./003-api-key-owners.js";
|
import m003 from "./003-api-key-owners.js";
|
||||||
import m004 from "./004-provider-connection-owners.js";
|
import m004 from "./004-provider-connection-owners.js";
|
||||||
|
import m005 from "./005-usage-user-attribution.js";
|
||||||
|
|
||||||
export const MIGRATIONS = [m001, m002, m003, m004].sort((a, b) => a.version - b.version);
|
export const MIGRATIONS = [m001, m002, m003, m004, m005].sort((a, b) => a.version - b.version);
|
||||||
|
|
||||||
export function latestVersion() {
|
export function latestVersion() {
|
||||||
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export async function getRequestDetails(filter = {}, user = null) {
|
|||||||
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
if (filter.status) { conds.push("status = ?"); params.push(filter.status); }
|
||||||
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
if (filter.startDate) { conds.push("timestamp >= ?"); params.push(new Date(filter.startDate).toISOString()); }
|
||||||
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
|
if (filter.endDate) { conds.push("timestamp <= ?"); params.push(new Date(filter.endDate).toISOString()); }
|
||||||
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null });
|
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null, userColumn: null });
|
||||||
|
|
||||||
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||||
const cntRow = db.get(`SELECT COUNT(*) as c FROM requestDetails ${where}`, params);
|
const cntRow = db.get(`SELECT COUNT(*) as c FROM requestDetails ${where}`, params);
|
||||||
@@ -183,7 +183,7 @@ export async function getDistinctProviders(user = null) {
|
|||||||
const scope = await getUsageAccessScope(user);
|
const scope = await getUsageAccessScope(user);
|
||||||
const conds = ["provider IS NOT NULL"];
|
const conds = ["provider IS NOT NULL"];
|
||||||
const params = [];
|
const params = [];
|
||||||
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null });
|
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null, userColumn: null });
|
||||||
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE ${conds.join(" AND ")} ORDER BY provider ASC`, params);
|
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE ${conds.join(" AND ")} ORDER BY provider ASC`, params);
|
||||||
return rows.map((r) => r.provider);
|
return rows.map((r) => r.provider);
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ export async function getRequestDetailById(id, user = null) {
|
|||||||
const scope = await getUsageAccessScope(user);
|
const scope = await getUsageAccessScope(user);
|
||||||
const conds = ["id = ?"];
|
const conds = ["id = ?"];
|
||||||
const params = [id];
|
const params = [id];
|
||||||
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null });
|
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null, userColumn: null });
|
||||||
const row = db.get(`SELECT data FROM requestDetails WHERE ${conds.join(" AND ")}`, params);
|
const row = db.get(`SELECT data FROM requestDetails WHERE ${conds.join(" AND ")}`, params);
|
||||||
return row ? parseJson(row.data, null) : null;
|
return row ? parseJson(row.data, null) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import { getAdapter } from "../driver.js";
|
|||||||
*/
|
*/
|
||||||
export async function getUsageAccessScope(user) {
|
export async function getUsageAccessScope(user) {
|
||||||
if (user?.role === "admin") {
|
if (user?.role === "admin") {
|
||||||
return { isAdmin: true, connectionIds: [], apiKeys: [] };
|
return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return { isAdmin: false, connectionIds: [], apiKeys: [] };
|
return { isAdmin: false, userId: null, connectionIds: [], apiKeys: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
@@ -27,7 +27,7 @@ export async function getUsageAccessScope(user) {
|
|||||||
[user.id],
|
[user.id],
|
||||||
).map((row) => row.key);
|
).map((row) => row.key);
|
||||||
|
|
||||||
return { isAdmin: false, connectionIds, apiKeys };
|
return { isAdmin: false, userId: user.id, connectionIds, apiKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,17 +35,27 @@ export async function getUsageAccessScope(user) {
|
|||||||
*
|
*
|
||||||
* @param {string[]} conditions SQL conditions to append to.
|
* @param {string[]} conditions SQL conditions to append to.
|
||||||
* @param {unknown[]} params Bound parameters corresponding to conditions.
|
* @param {unknown[]} params Bound parameters corresponding to conditions.
|
||||||
* @param {{ isAdmin: boolean, connectionIds: string[], apiKeys: string[] }} scope
|
* @param {{ isAdmin: boolean, userId?: string | null, connectionIds: string[], apiKeys: string[] }} scope
|
||||||
* @param {{ connectionColumn?: string, apiKeyColumn?: string | null }} options
|
* @param {{ connectionColumn?: string, apiKeyColumn?: string | null, userColumn?: string | null }} options
|
||||||
*/
|
*/
|
||||||
export function appendUsageAccessClause(
|
export function appendUsageAccessClause(
|
||||||
conditions,
|
conditions,
|
||||||
params,
|
params,
|
||||||
scope,
|
scope,
|
||||||
{ connectionColumn = "connectionId", apiKeyColumn = "apiKey" } = {},
|
{ connectionColumn = "connectionId", apiKeyColumn = "apiKey", userColumn = "userId" } = {},
|
||||||
) {
|
) {
|
||||||
if (scope?.isAdmin) return;
|
if (scope?.isAdmin) return;
|
||||||
|
|
||||||
|
// usageHistory records persist the resolved actor. This avoids an API key
|
||||||
|
// owned by one user and a provider connection owned by another appearing in
|
||||||
|
// both users' dashboards. Repositories without a userId column explicitly
|
||||||
|
// pass userColumn: null and retain their resource-level access predicate.
|
||||||
|
if (userColumn && scope?.userId) {
|
||||||
|
conditions.push(`${userColumn} = ?`);
|
||||||
|
params.push(scope.userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ownershipConditions = [];
|
const ownershipConditions = [];
|
||||||
if (scope?.connectionIds?.length) {
|
if (scope?.connectionIds?.length) {
|
||||||
ownershipConditions.push(`${connectionColumn} IN (${scope.connectionIds.map(() => "?").join(", ")})`);
|
ownershipConditions.push(`${connectionColumn} IN (${scope.connectionIds.map(() => "?").join(", ")})`);
|
||||||
|
|||||||
+100
-12
@@ -78,6 +78,7 @@ function aggregateEntryToDay(day, entry) {
|
|||||||
day.byModel ||= {};
|
day.byModel ||= {};
|
||||||
day.byAccount ||= {};
|
day.byAccount ||= {};
|
||||||
day.byApiKey ||= {};
|
day.byApiKey ||= {};
|
||||||
|
day.byUser ||= {};
|
||||||
day.byEndpoint ||= {};
|
day.byEndpoint ||= {};
|
||||||
|
|
||||||
if (entry.provider) addToCounter(day.byProvider, entry.provider, vals);
|
if (entry.provider) addToCounter(day.byProvider, entry.provider, vals);
|
||||||
@@ -93,6 +94,9 @@ function aggregateEntryToDay(day, entry) {
|
|||||||
const akModelKey = `${apiKeyVal}|${entry.model}|${entry.provider || "unknown"}`;
|
const akModelKey = `${apiKeyVal}|${entry.model}|${entry.provider || "unknown"}`;
|
||||||
addToCounter(day.byApiKey, akModelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider, apiKey: entry.apiKey || null } });
|
addToCounter(day.byApiKey, akModelKey, { ...vals, meta: { rawModel: entry.model, provider: entry.provider, apiKey: entry.apiKey || null } });
|
||||||
|
|
||||||
|
const userKey = `${entry.userId || "unattributed"}|${entry.model}|${entry.provider || "unknown"}`;
|
||||||
|
addToCounter(day.byUser, userKey, { ...vals, meta: { userId: entry.userId || null, rawModel: entry.model, provider: entry.provider } });
|
||||||
|
|
||||||
const endpoint = entry.endpoint || "Unknown";
|
const endpoint = entry.endpoint || "Unknown";
|
||||||
const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
|
const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
|
||||||
addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } });
|
addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } });
|
||||||
@@ -123,10 +127,10 @@ async function ensureRingInitialized() {
|
|||||||
recentRing.initialized = true;
|
recentRing.initialized = true;
|
||||||
try {
|
try {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, cost, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, [RING_CAP]);
|
const rows = db.all(`SELECT timestamp, provider, model, connectionId, apiKey, userId, endpoint, cost, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, [RING_CAP]);
|
||||||
recentRing.items = rows.reverse().map((r) => ({
|
recentRing.items = rows.reverse().map((r) => ({
|
||||||
timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId,
|
timestamp: r.timestamp, provider: r.provider, model: r.model, connectionId: r.connectionId,
|
||||||
apiKey: r.apiKey, endpoint: r.endpoint, cost: r.cost, status: r.status,
|
apiKey: r.apiKey, userId: r.userId, endpoint: r.endpoint, cost: r.cost, status: r.status,
|
||||||
tokens: parseJson(r.tokens, {}),
|
tokens: parseJson(r.tokens, {}),
|
||||||
}));
|
}));
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -218,7 +222,7 @@ export async function getActiveRequests(user = null) {
|
|||||||
await ensureRingInitialized();
|
await ensureRingInitialized();
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const recentRequests = [...recentRing.items]
|
const recentRequests = [...recentRing.items]
|
||||||
.filter((entry) => scope.isAdmin || allowedConnectionIds.has(entry.connectionId) || scope.apiKeys.includes(entry.apiKey))
|
.filter((entry) => scope.isAdmin || entry.userId === scope.userId)
|
||||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const t = e.tokens || {};
|
const t = e.tokens || {};
|
||||||
@@ -250,6 +254,20 @@ export async function saveRequestUsage(entry) {
|
|||||||
if (!entry.timestamp) entry.timestamp = new Date().toISOString();
|
if (!entry.timestamp) entry.timestamp = new Date().toISOString();
|
||||||
entry.cost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
entry.cost = await calculateCost(entry.provider, entry.model, entry.tokens);
|
||||||
|
|
||||||
|
// The API key identifies the caller and therefore takes precedence over
|
||||||
|
// the provider connection owner. This gives every stored request one
|
||||||
|
// dashboard actor and prevents cross-user double counting.
|
||||||
|
if (!entry.userId) {
|
||||||
|
const keyOwner = entry.apiKey
|
||||||
|
? db.get(`SELECT ownerId FROM apiKeys WHERE key = ? AND ownerId IS NOT NULL`, [entry.apiKey])?.ownerId
|
||||||
|
: null;
|
||||||
|
entry.userId = keyOwner
|
||||||
|
|| (entry.connectionId
|
||||||
|
? db.get(`SELECT ownerId FROM providerConnections WHERE id = ? AND ownerId IS NOT NULL`, [entry.connectionId])?.ownerId
|
||||||
|
: null)
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = entry.tokens || {};
|
const tokens = entry.tokens || {};
|
||||||
const promptTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
|
const promptTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
|
||||||
const completionTokens = tokens.completion_tokens || tokens.output_tokens || 0;
|
const completionTokens = tokens.completion_tokens || tokens.output_tokens || 0;
|
||||||
@@ -266,12 +284,13 @@ export async function saveRequestUsage(entry) {
|
|||||||
AND COALESCE(model, '') = COALESCE(?, '')
|
AND COALESCE(model, '') = COALESCE(?, '')
|
||||||
AND COALESCE(connectionId, '') = COALESCE(?, '')
|
AND COALESCE(connectionId, '') = COALESCE(?, '')
|
||||||
AND COALESCE(apiKey, '') = COALESCE(?, '')
|
AND COALESCE(apiKey, '') = COALESCE(?, '')
|
||||||
|
AND COALESCE(userId, '') = COALESCE(?, '')
|
||||||
AND promptTokens = ?
|
AND promptTokens = ?
|
||||||
AND completionTokens = ?
|
AND completionTokens = ?
|
||||||
ORDER BY id DESC LIMIT 1`,
|
ORDER BY id DESC LIMIT 1`,
|
||||||
[
|
[
|
||||||
entry.timestamp, entry.provider || null, entry.model || null,
|
entry.timestamp, entry.provider || null, entry.model || null,
|
||||||
entry.connectionId || null, entry.apiKey || null,
|
entry.connectionId || null, entry.apiKey || null, entry.userId || null,
|
||||||
promptTokens, completionTokens,
|
promptTokens, completionTokens,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -284,10 +303,10 @@ export async function saveRequestUsage(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, userId, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
entry.timestamp, entry.provider || null, entry.model || null,
|
entry.timestamp, entry.provider || null, entry.model || null,
|
||||||
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
|
entry.connectionId || null, entry.apiKey || null, entry.userId || null, entry.endpoint || null,
|
||||||
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
|
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
|
||||||
stringifyJson(tokens), stringifyJson({}),
|
stringifyJson(tokens), stringifyJson({}),
|
||||||
]
|
]
|
||||||
@@ -297,7 +316,7 @@ export async function saveRequestUsage(entry) {
|
|||||||
const row = db.get(`SELECT data FROM usageDaily WHERE dateKey = ?`, [dateKey]);
|
const row = db.get(`SELECT data FROM usageDaily WHERE dateKey = ?`, [dateKey]);
|
||||||
const day = row ? parseJson(row.data, {}) : {
|
const day = row ? parseJson(row.data, {}) : {
|
||||||
requests: 0, promptTokens: 0, completionTokens: 0, cost: 0,
|
requests: 0, promptTokens: 0, completionTokens: 0, cost: 0,
|
||||||
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
|
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byUser: {}, byEndpoint: {},
|
||||||
};
|
};
|
||||||
aggregateEntryToDay(day, entry);
|
aggregateEntryToDay(day, entry);
|
||||||
db.run(`INSERT INTO usageDaily(dateKey, data) VALUES(?, ?) ON CONFLICT(dateKey) DO UPDATE SET data = excluded.data`, [dateKey, stringifyJson(day)]);
|
db.run(`INSERT INTO usageDaily(dateKey, data) VALUES(?, ?) ON CONFLICT(dateKey) DO UPDATE SET data = excluded.data`, [dateKey, stringifyJson(day)]);
|
||||||
@@ -359,6 +378,7 @@ function createEmptyUsageStats() {
|
|||||||
byModel: {},
|
byModel: {},
|
||||||
byAccount: {},
|
byAccount: {},
|
||||||
byApiKey: {},
|
byApiKey: {},
|
||||||
|
byUser: {},
|
||||||
byEndpoint: {},
|
byEndpoint: {},
|
||||||
last10Minutes: Array.from({ length: 10 }, () => ({ requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 })),
|
last10Minutes: Array.from({ length: 10 }, () => ({ requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 })),
|
||||||
pending: { byModel: {}, byAccount: {} },
|
pending: { byModel: {}, byAccount: {} },
|
||||||
@@ -368,6 +388,65 @@ function createEmptyUsageStats() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USER_TABLE_VIEWS = ["model", "endpoint"];
|
||||||
|
const ADMIN_TABLE_VIEWS = ["model", "user", "apiKey", "endpoint"];
|
||||||
|
|
||||||
|
function applyUsageViewPermissions(stats, user) {
|
||||||
|
if (user?.role === "admin") {
|
||||||
|
return { ...stats, availableTableViews: ADMIN_TABLE_VIEWS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not rely on UI hiding: user-specific responses deliberately omit
|
||||||
|
// account, API-key, and cross-dashboard-user breakdowns.
|
||||||
|
const { byAccount, byApiKey, byUser, pending, ...safeStats } = stats;
|
||||||
|
return {
|
||||||
|
...safeStats,
|
||||||
|
pending: { ...pending, byAccount: {} },
|
||||||
|
availableTableViews: USER_TABLE_VIEWS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUserBreakdown(stats, rows, providerNodeNameMap) {
|
||||||
|
for (const row of rows) {
|
||||||
|
const tokens = parseJson(row.tokens, {}) || {};
|
||||||
|
const promptTokens = row.promptTokens || tokens.prompt_tokens || tokens.input_tokens || 0;
|
||||||
|
const completionTokens = row.completionTokens || tokens.completion_tokens || tokens.output_tokens || 0;
|
||||||
|
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
|
||||||
|
const provider = row.provider || "unknown";
|
||||||
|
const username = row.username || "Unattributed";
|
||||||
|
const userKey = `${row.userId || "unattributed"}|${row.model}|${provider}`;
|
||||||
|
addToCounter(stats.byUser, userKey, {
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
cachedTokens,
|
||||||
|
cost: row.cost || 0,
|
||||||
|
meta: {
|
||||||
|
userId: row.userId || null,
|
||||||
|
username,
|
||||||
|
rawModel: row.model,
|
||||||
|
provider: providerNodeNameMap[provider] || provider,
|
||||||
|
lastUsed: row.timestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (new Date(row.timestamp) > new Date(stats.byUser[userKey].lastUsed)) {
|
||||||
|
stats.byUser[userKey].lastUsed = row.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUserBreakdownRows(db, period) {
|
||||||
|
const cutoff = getUsagePeriodCutoff(period);
|
||||||
|
const where = cutoff ? "WHERE h.timestamp >= ?" : "";
|
||||||
|
return db.all(
|
||||||
|
`SELECT h.timestamp, h.provider, h.model, h.userId, h.promptTokens, h.completionTokens, h.cost, h.tokens, u.username
|
||||||
|
FROM usageHistory h
|
||||||
|
LEFT JOIN users u ON u.id = h.userId
|
||||||
|
${where}
|
||||||
|
ORDER BY h.id DESC`,
|
||||||
|
cutoff ? [cutoff] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getUsagePeriodCutoff(period) {
|
function getUsagePeriodCutoff(period) {
|
||||||
if (period === "today") {
|
if (period === "today") {
|
||||||
const startOfDay = new Date();
|
const startOfDay = new Date();
|
||||||
@@ -484,11 +563,15 @@ async function getScopedUsageStats(period, user, scope) {
|
|||||||
stats.pending.byModel[model] = (stats.pending.byModel[model] || 0) + count;
|
stats.pending.byModel[model] = (stats.pending.byModel[model] || 0) + count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stats;
|
return applyUsageViewPermissions(stats, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsageStats(period = "all", user = null) {
|
export async function getUsageStats(period = "all", user = null) {
|
||||||
const scope = await getUsageAccessScope(user);
|
// Internal callers without a dashboard principal retain the historical
|
||||||
|
// system-wide behavior. Public API routes always supply an authenticated user.
|
||||||
|
const scope = user
|
||||||
|
? await getUsageAccessScope(user)
|
||||||
|
: { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
|
||||||
if (!scope.isAdmin) return getScopedUsageStats(period, user, scope);
|
if (!scope.isAdmin) return getScopedUsageStats(period, user, scope);
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
|
||||||
@@ -541,7 +624,7 @@ export async function getUsageStats(period = "all", user = null) {
|
|||||||
const stats = {
|
const stats = {
|
||||||
totalRequests: 0,
|
totalRequests: 0,
|
||||||
totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0,
|
totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0,
|
||||||
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
|
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byUser: {}, byEndpoint: {},
|
||||||
last10Minutes: [],
|
last10Minutes: [],
|
||||||
pending: pendingRequests,
|
pending: pendingRequests,
|
||||||
activeRequests: [],
|
activeRequests: [],
|
||||||
@@ -801,7 +884,10 @@ export async function getUsageStats(period = "all", user = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stats.totalRequests = Object.values(stats.byProvider).reduce((sum, p) => sum + (p.requests || 0), 0);
|
stats.totalRequests = Object.values(stats.byProvider).reduce((sum, p) => sum + (p.requests || 0), 0);
|
||||||
return stats;
|
if (scope.isAdmin) {
|
||||||
|
addUserBreakdown(stats, loadUserBreakdownRows(db, period), providerNodeNameMap);
|
||||||
|
}
|
||||||
|
return applyUsageViewPermissions(stats, user || { role: "admin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChartDataFromRows(rows, period) {
|
function buildChartDataFromRows(rows, period) {
|
||||||
@@ -830,7 +916,9 @@ function buildChartDataFromRows(rows, period) {
|
|||||||
|
|
||||||
export async function getChartData(period = "7d", user = null) {
|
export async function getChartData(period = "7d", user = null) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const scope = await getUsageAccessScope(user);
|
const scope = user
|
||||||
|
? await getUsageAccessScope(user)
|
||||||
|
: { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
|
||||||
if (!scope.isAdmin) {
|
if (!scope.isAdmin) {
|
||||||
const conds = [];
|
const conds = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// pre-change safety backup in migrate.js: when the stored version is lower,
|
// pre-change safety backup in migrate.js: when the stored version is lower,
|
||||||
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
// one lightweight DB backup is taken before applying schema changes. Forgetting
|
||||||
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
// to bump only skips that backup — it does NOT break the additive auto-sync.
|
||||||
export const SCHEMA_VERSION = 4;
|
export const SCHEMA_VERSION = 5;
|
||||||
|
|
||||||
export const PRAGMA_SQL = `
|
export const PRAGMA_SQL = `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
@@ -135,6 +135,7 @@ export const TABLES = {
|
|||||||
model: "TEXT",
|
model: "TEXT",
|
||||||
connectionId: "TEXT",
|
connectionId: "TEXT",
|
||||||
apiKey: "TEXT",
|
apiKey: "TEXT",
|
||||||
|
userId: "TEXT",
|
||||||
endpoint: "TEXT",
|
endpoint: "TEXT",
|
||||||
promptTokens: "INTEGER DEFAULT 0",
|
promptTokens: "INTEGER DEFAULT 0",
|
||||||
completionTokens: "INTEGER DEFAULT 0",
|
completionTokens: "INTEGER DEFAULT 0",
|
||||||
@@ -148,6 +149,7 @@ export const TABLES = {
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_provider ON usageHistory(provider)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_model ON usageHistory(model)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_uh_user ON usageHistory(userId)",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
usageDaily: {
|
usageDaily: {
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function getGroupKey(item, keyField) {
|
|||||||
switch (keyField) {
|
switch (keyField) {
|
||||||
case "rawModel": return item.rawModel || "Unknown Model";
|
case "rawModel": return item.rawModel || "Unknown Model";
|
||||||
case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account";
|
case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account";
|
||||||
|
case "username": return item.username || "Unattributed";
|
||||||
case "keyName": return item.keyName || "Unknown Key";
|
case "keyName": return item.keyName || "Unknown Key";
|
||||||
case "endpoint": return item.endpoint || "Unknown Endpoint";
|
case "endpoint": return item.endpoint || "Unknown Endpoint";
|
||||||
default: return item[keyField] || "Unknown";
|
default: return item[keyField] || "Unknown";
|
||||||
@@ -161,10 +162,10 @@ const MODEL_COLUMNS = [
|
|||||||
{ field: "lastUsed", label: "Last Used", align: "right" },
|
{ field: "lastUsed", label: "Last Used", align: "right" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ACCOUNT_COLUMNS = [
|
const USER_COLUMNS = [
|
||||||
|
{ field: "username", label: "User" },
|
||||||
{ field: "rawModel", label: "Model" },
|
{ field: "rawModel", label: "Model" },
|
||||||
{ field: "provider", label: "Provider" },
|
{ field: "provider", label: "Provider" },
|
||||||
{ field: "accountName", label: "Account" },
|
|
||||||
{ field: "requests", label: "Requests", align: "right" },
|
{ field: "requests", label: "Requests", align: "right" },
|
||||||
{ field: "lastUsed", label: "Last Used", align: "right" },
|
{ field: "lastUsed", label: "Last Used", align: "right" },
|
||||||
];
|
];
|
||||||
@@ -187,7 +188,7 @@ const ENDPOINT_COLUMNS = [
|
|||||||
|
|
||||||
const TABLE_OPTIONS = [
|
const TABLE_OPTIONS = [
|
||||||
{ value: "model", label: "Usage by Model" },
|
{ value: "model", label: "Usage by Model" },
|
||||||
{ value: "account", label: "Usage by Account" },
|
{ value: "user", label: "Usage by User" },
|
||||||
{ value: "apiKey", label: "Usage by API Key" },
|
{ value: "apiKey", label: "Usage by API Key" },
|
||||||
{ value: "endpoint", label: "Usage by Endpoint" },
|
{ value: "endpoint", label: "Usage by Endpoint" },
|
||||||
];
|
];
|
||||||
@@ -218,6 +219,11 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
const hasLoadedStats = useRef(false);
|
const hasLoadedStats = useRef(false);
|
||||||
const period = periodProp ?? periodLocal;
|
const period = periodProp ?? periodLocal;
|
||||||
const setPeriod = setPeriodProp ?? setPeriodLocal;
|
const setPeriod = setPeriodProp ?? setPeriodLocal;
|
||||||
|
const tableOptions = useMemo(() => {
|
||||||
|
const allowedViews = new Set(stats?.availableTableViews || ["model", "endpoint"]);
|
||||||
|
return TABLE_OPTIONS.filter((option) => allowedViews.has(option.value));
|
||||||
|
}, [stats?.availableTableViews]);
|
||||||
|
const activeTableView = tableOptions.some((option) => option.value === tableView) ? tableView : "model";
|
||||||
|
|
||||||
// Fetch connected providers once, deduplicate by provider type
|
// Fetch connected providers once, deduplicate by provider type
|
||||||
// Always include noAuth free providers (e.g. opencode) regardless of connections
|
// Always include noAuth free providers (e.g. opencode) regardless of connections
|
||||||
@@ -319,7 +325,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
// Compute active table data
|
// Compute active table data
|
||||||
const activeTableConfig = useMemo(() => {
|
const activeTableConfig = useMemo(() => {
|
||||||
if (!stats) return null;
|
if (!stats) return null;
|
||||||
switch (tableView) {
|
switch (activeTableView) {
|
||||||
case "model": {
|
case "model": {
|
||||||
const pendingMap = stats.pending?.byModel || {};
|
const pendingMap = stats.pending?.byModel || {};
|
||||||
return {
|
return {
|
||||||
@@ -344,22 +350,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "account": {
|
case "user": {
|
||||||
const pendingMap = {};
|
|
||||||
if (stats?.pending?.byAccount) {
|
|
||||||
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => {
|
|
||||||
const connPending = stats.pending.byAccount[data.connectionId];
|
|
||||||
if (connPending) {
|
|
||||||
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
|
|
||||||
pendingMap[accountKey] = connPending[modelKey] || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
columns: ACCOUNT_COLUMNS,
|
columns: USER_COLUMNS,
|
||||||
groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"),
|
groupedData: groupDataByKey(sortData(stats.byUser, {}, sortBy, sortOrder), "username"),
|
||||||
storageKey: "usage-stats:expanded-accounts",
|
storageKey: "usage-stats:expanded-users",
|
||||||
emptyMessage: "No account-specific usage recorded yet.",
|
emptyMessage: "No user-specific usage recorded yet.",
|
||||||
renderSummaryCells: (group) => (
|
renderSummaryCells: (group) => (
|
||||||
<>
|
<>
|
||||||
<td className="px-6 py-3 text-text-muted">—</td>
|
<td className="px-6 py-3 text-text-muted">—</td>
|
||||||
@@ -370,7 +366,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
),
|
),
|
||||||
renderDetailCells: (item) => (
|
renderDetailCells: (item) => (
|
||||||
<>
|
<>
|
||||||
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.accountName || `Account ${item.connectionId?.slice(0, 8)}...`}</td>
|
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.username || "Unattributed"}</td>
|
||||||
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.rawModel}</td>
|
<td className={`px-6 py-3 font-medium transition-colors ${item.pending > 0 ? "text-primary" : ""}`}>{item.rawModel}</td>
|
||||||
<td className="px-6 py-3"><Badge variant={item.pending > 0 ? "primary" : "neutral"} size="sm">{item.provider}</Badge></td>
|
<td className="px-6 py-3"><Badge variant={item.pending > 0 ? "primary" : "neutral"} size="sm">{item.provider}</Badge></td>
|
||||||
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
|
<td className="px-6 py-3 text-right">{fmt(item.requests)}</td>
|
||||||
@@ -431,7 +427,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [stats, tableView, sortBy, sortOrder]);
|
}, [stats, activeTableView, sortBy, sortOrder]);
|
||||||
|
|
||||||
if (!stats && !loading) return <div className="text-text-muted">Failed to load usage statistics.</div>;
|
if (!stats && !loading) return <div className="text-text-muted">Failed to load usage statistics.</div>;
|
||||||
|
|
||||||
@@ -487,12 +483,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<select
|
<select
|
||||||
value={tableView}
|
value={activeTableView}
|
||||||
onChange={(e) => setTableView(e.target.value)}
|
onChange={(e) => setTableView(e.target.value)}
|
||||||
className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto"
|
className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto"
|
||||||
style={{ colorScheme: 'auto' }}
|
style={{ colorScheme: 'auto' }}
|
||||||
>
|
>
|
||||||
{TABLE_OPTIONS.map((opt) => (
|
{tableOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -516,7 +512,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
|||||||
title=""
|
title=""
|
||||||
columns={activeTableConfig.columns}
|
columns={activeTableConfig.columns}
|
||||||
groupedData={activeTableConfig.groupedData}
|
groupedData={activeTableConfig.groupedData}
|
||||||
tableType={tableView}
|
tableType={activeTableView}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onToggleSort={toggleSort}
|
onToggleSort={toggleSort}
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ describe("usage access scope SQL predicates", () => {
|
|||||||
expect(params).toEqual(["connection-a", "connection-b", "key-a"]);
|
expect(params).toEqual(["connection-a", "connection-b", "key-a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses persisted actor attribution when a user id is available", () => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
appendUsageAccessClause(conditions, params, {
|
||||||
|
isAdmin: false,
|
||||||
|
userId: "user-a",
|
||||||
|
connectionIds: ["connection-a"],
|
||||||
|
apiKeys: ["key-a"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conditions).toEqual(["userId = ?"]);
|
||||||
|
expect(params).toEqual(["user-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("denies users with no attributable resources", () => {
|
it("denies users with no attributable resources", () => {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const originalDataDir = process.env.DATA_DIR;
|
||||||
|
let tempDir;
|
||||||
|
let db;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-usage-roles-"));
|
||||||
|
process.env.DATA_DIR = tempDir;
|
||||||
|
vi.resetModules();
|
||||||
|
db = await import("@/lib/db/index.js");
|
||||||
|
await db.initDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||||
|
else process.env.DATA_DIR = originalDataDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("usage role breakdowns", () => {
|
||||||
|
it("attributes requests to the API key owner and exposes breakdowns by role", async () => {
|
||||||
|
const keyOwner = await db.createUser({ username: "usage-key-owner", password: "password", role: "user" });
|
||||||
|
const connectionOwner = await db.createUser({ username: "usage-connection-owner", password: "password", role: "user" });
|
||||||
|
const admin = await db.createUser({ username: "usage-admin", password: "password", role: "admin" });
|
||||||
|
const apiKey = await db.createApiKey("usage-owned-key", "usage-machine", keyOwner.id);
|
||||||
|
const connection = await db.createProviderConnection({
|
||||||
|
provider: "usage-test",
|
||||||
|
authType: "apikey",
|
||||||
|
name: "usage connection",
|
||||||
|
apiKey: "upstream-key",
|
||||||
|
ownerId: connectionOwner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.saveRequestUsage({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
provider: "usage-test",
|
||||||
|
model: "test-model",
|
||||||
|
connectionId: connection.id,
|
||||||
|
apiKey: apiKey.key,
|
||||||
|
endpoint: "/v1/chat/completions",
|
||||||
|
tokens: { prompt_tokens: 10, completion_tokens: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyOwnerStats = await db.getUsageStats("24h", keyOwner);
|
||||||
|
expect(keyOwnerStats.totalRequests).toBe(1);
|
||||||
|
expect(keyOwnerStats.availableTableViews).toEqual(["model", "endpoint"]);
|
||||||
|
expect(keyOwnerStats).not.toHaveProperty("byUser");
|
||||||
|
expect(keyOwnerStats).not.toHaveProperty("byApiKey");
|
||||||
|
expect(keyOwnerStats).not.toHaveProperty("byAccount");
|
||||||
|
|
||||||
|
const connectionOwnerStats = await db.getUsageStats("24h", connectionOwner);
|
||||||
|
expect(connectionOwnerStats.totalRequests).toBe(0);
|
||||||
|
|
||||||
|
const adminStats = await db.getUsageStats("24h", admin);
|
||||||
|
expect(adminStats.availableTableViews).toEqual(["model", "user", "apiKey", "endpoint"]);
|
||||||
|
expect(Object.values(adminStats.byUser)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ username: keyOwner.username, requests: 1 }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user