fix: update the tracking usage filter for the usage page

This commit is contained in:
2026-07-11 18:36:55 +07:00
parent 40606ce39f
commit e1232ed1bd
11 changed files with 422 additions and 241 deletions
+165 -194
View File
@@ -1,194 +1,165 @@
// Ensure proxyFetch is loaded to patch globalThis.fetch
import "open-sse/index.js";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { getUsageForProvider } from "open-sse/services/usage.js";
import { getExecutor } from "open-sse/executors/index.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
// Detect auth-expired messages returned by usage providers instead of throwing
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
function isAuthExpiredMessage(usage) {
if (!usage?.message) return false;
const msg = usage.message.toLowerCase();
return AUTH_EXPIRED_PATTERNS.some((p) => msg.includes(p));
}
/**
* Refresh credentials using executor and update database
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
* @returns Promise<{ connection, refreshed: boolean }>
*/
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
const executor = getExecutor(connection.provider);
// Build credentials object from connection
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
idToken: connection.idToken,
expiresAt: connection.expiresAt || connection.tokenExpiresAt,
lastRefreshAt: connection.lastRefreshAt,
connectionId: connection.id,
providerSpecificData: connection.providerSpecificData,
// For GitHub
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
};
// Check if refresh is needed (skip when force=true)
const needsRefresh = force || executor.needsRefresh(credentials);
if (!needsRefresh) {
return { connection, refreshed: false };
}
// Use executor's refreshCredentials method (with optional proxy)
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
if (!refreshResult) {
// Refresh failed but we still have an accessToken — try with existing token
if (connection.accessToken) {
return { connection, refreshed: false };
}
throw new Error("Failed to refresh credentials. Please re-authorize the connection.");
}
// Build update object
const now = new Date().toISOString();
const updateData = {
updatedAt: now,
};
// Update accessToken if present
if (refreshResult.accessToken) {
updateData.accessToken = refreshResult.accessToken;
}
// Update refreshToken if present
if (refreshResult.refreshToken) {
updateData.refreshToken = refreshResult.refreshToken;
}
if (refreshResult.idToken) {
updateData.idToken = refreshResult.idToken;
}
if (refreshResult.lastRefreshAt) {
updateData.lastRefreshAt = refreshResult.lastRefreshAt;
}
// Update token expiry
if (refreshResult.expiresIn) {
updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
updateData.expiresIn = refreshResult.expiresIn;
} else if (refreshResult.expiresAt) {
updateData.expiresAt = refreshResult.expiresAt;
}
// Handle provider-specific data (copilotToken for GitHub, etc.)
const providerSpecificUpdates = {
...(refreshResult.providerSpecificData || {}),
...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}),
...(refreshResult.copilotTokenExpiresAt ? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt } : {}),
};
if (Object.keys(providerSpecificUpdates).length > 0) {
updateData.providerSpecificData = {
...(connection.providerSpecificData || {}),
...providerSpecificUpdates,
};
}
// Update database
await updateProviderConnection(connection.id, updateData);
// Return updated connection
const updatedConnection = {
...connection,
...updateData,
providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData,
};
return {
connection: updatedConnection,
refreshed: true,
};
}
/**
* GET /api/usage/[connectionId] - Get usage data for a specific connection
*/
export async function GET(request, { params }) {
let connection;
try {
const { connectionId } = await params;
// Get connection from database
connection = await getProviderConnectionById(connectionId);
if (!connection) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
// Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...)
// Kiro's headless api-key flow persists authType "api_key" (underscore) while
// generic apikey providers persist "apikey" — accept both spellings here.
const isOAuth = connection.authType === "oauth";
const isApikeyAuth =
connection.authType === "apikey" || connection.authType === "api_key";
const isApikeyEligible =
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
if (!isOAuth && !isApikeyEligible) {
return Response.json({ message: "Usage not available for this connection" });
}
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
// Refresh credentials only for OAuth connections (apikey has no token refresh)
if (isOAuth) {
try {
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
connection = result.connection;
} catch (refreshError) {
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 });
}
}
// Ensure proxyFetch is loaded to patch globalThis.fetch
import "open-sse/index.js";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { USAGE_APIKEY_PROVIDERS } from "@/shared/constants/providers";
import { getExecutor } from "open-sse/executors/index.js";
import { getUsageForProvider } from "open-sse/services/usage.js";
const AUTH_EXPIRED_PATTERNS = [
"expired",
"authentication",
"unauthorized",
"401",
"re-authorize",
];
function isAuthExpiredMessage(usage) {
if (!usage?.message) return false;
const message = usage.message.toLowerCase();
return AUTH_EXPIRED_PATTERNS.some((pattern) => message.includes(pattern));
}
/**
* Refresh connection credentials when required and persist the result.
* @param {object} connection Provider connection.
* @param {boolean} force Refresh even if the executor considers the token valid.
* @param {object|null} proxyOptions Connection proxy configuration.
* @returns {Promise<{ connection: object, refreshed: boolean }>}
*/
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
const executor = getExecutor(connection.provider);
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
idToken: connection.idToken,
expiresAt: connection.expiresAt || connection.tokenExpiresAt,
lastRefreshAt: connection.lastRefreshAt,
connectionId: connection.id,
providerSpecificData: connection.providerSpecificData,
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
};
if (!force && !executor.needsRefresh(credentials)) {
return { connection, refreshed: false };
}
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
if (!refreshResult) {
if (connection.accessToken) 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;
if (refreshResult.idToken) updateData.idToken = refreshResult.idToken;
if (refreshResult.lastRefreshAt) updateData.lastRefreshAt = refreshResult.lastRefreshAt;
if (refreshResult.expiresIn) {
updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
updateData.expiresIn = refreshResult.expiresIn;
} else if (refreshResult.expiresAt) {
updateData.expiresAt = refreshResult.expiresAt;
}
const providerSpecificUpdates = {
...(refreshResult.providerSpecificData || {}),
...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}),
...(refreshResult.copilotTokenExpiresAt
? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt }
: {}),
};
if (Object.keys(providerSpecificUpdates).length > 0) {
updateData.providerSpecificData = {
...(connection.providerSpecificData || {}),
...providerSpecificUpdates,
};
}
await updateProviderConnection(connection.id, updateData);
return {
connection: {
...connection,
...updateData,
providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData,
},
refreshed: true,
};
}
/**
* GET /api/usage/[connectionId] - Get quota data for one provider connection.
*/
export async function GET(_request, { params }) {
let connection;
try {
const { connectionId } = await params;
const user = await requireUsageDashboardUser();
connection = await getProviderConnectionById(
connectionId,
user.role === "admin" ? null : user.id,
);
if (!connection) {
return Response.json({ error: "Connection not found" }, { status: 404 });
}
const isOAuth = connection.authType === "oauth";
const isApikeyAuth =
connection.authType === "apikey" || connection.authType === "api_key";
const isApikeyEligible =
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
if (!isOAuth && !isApikeyEligible) {
return Response.json({ message: "Usage not available for this connection" });
}
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
if (isOAuth) {
try {
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
connection = result.connection;
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
return Response.json(
{ error: `Credential refresh failed: ${refreshError.message}` },
{ status: 401 },
);
}
}
let usage = await getUsageForProvider(connection, proxyOptions);
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 });
}
}
+10
View File
@@ -191,6 +191,16 @@ function importLegacyUsage(adapter, data) {
for (const [dateKey, day] of Object.entries(data.dailySummary || {})) {
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") {
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;
+2 -1
View File
@@ -5,8 +5,9 @@ import m001 from "./001-initial.js";
import m002 from "./002-users-table.js";
import m003 from "./003-api-key-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() {
return MIGRATIONS.length ? MIGRATIONS[MIGRATIONS.length - 1].version : 0;
+3 -3
View File
@@ -155,7 +155,7 @@ export async function getRequestDetails(filter = {}, user = null) {
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.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 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 conds = ["provider IS NOT NULL"];
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);
return rows.map((r) => r.provider);
}
@@ -193,7 +193,7 @@ export async function getRequestDetailById(id, user = null) {
const scope = await getUsageAccessScope(user);
const conds = ["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);
return row ? parseJson(row.data, null) : null;
}
+16 -6
View File
@@ -10,11 +10,11 @@ import { getAdapter } from "../driver.js";
*/
export async function getUsageAccessScope(user) {
if (user?.role === "admin") {
return { isAdmin: true, connectionIds: [], apiKeys: [] };
return { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
}
if (!user?.id) {
return { isAdmin: false, connectionIds: [], apiKeys: [] };
return { isAdmin: false, userId: null, connectionIds: [], apiKeys: [] };
}
const db = await getAdapter();
@@ -27,7 +27,7 @@ export async function getUsageAccessScope(user) {
[user.id],
).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 {unknown[]} params Bound parameters corresponding to conditions.
* @param {{ isAdmin: boolean, connectionIds: string[], apiKeys: string[] }} scope
* @param {{ connectionColumn?: string, apiKeyColumn?: string | null }} options
* @param {{ isAdmin: boolean, userId?: string | null, connectionIds: string[], apiKeys: string[] }} scope
* @param {{ connectionColumn?: string, apiKeyColumn?: string | null, userColumn?: string | null }} options
*/
export function appendUsageAccessClause(
conditions,
params,
scope,
{ connectionColumn = "connectionId", apiKeyColumn = "apiKey" } = {},
{ connectionColumn = "connectionId", apiKeyColumn = "apiKey", userColumn = "userId" } = {},
) {
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 = [];
if (scope?.connectionIds?.length) {
ownershipConditions.push(`${connectionColumn} IN (${scope.connectionIds.map(() => "?").join(", ")})`);
+100 -12
View File
@@ -78,6 +78,7 @@ function aggregateEntryToDay(day, entry) {
day.byModel ||= {};
day.byAccount ||= {};
day.byApiKey ||= {};
day.byUser ||= {};
day.byEndpoint ||= {};
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"}`;
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 epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } });
@@ -123,10 +127,10 @@ async function ensureRingInitialized() {
recentRing.initialized = true;
try {
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) => ({
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, {}),
}));
} catch {}
@@ -218,7 +222,7 @@ export async function getActiveRequests(user = null) {
await ensureRingInitialized();
const seen = new Set();
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))
.map((e) => {
const t = e.tokens || {};
@@ -250,6 +254,20 @@ export async function saveRequestUsage(entry) {
if (!entry.timestamp) entry.timestamp = new Date().toISOString();
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 promptTokens = tokens.prompt_tokens || tokens.input_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(connectionId, '') = COALESCE(?, '')
AND COALESCE(apiKey, '') = COALESCE(?, '')
AND COALESCE(userId, '') = COALESCE(?, '')
AND promptTokens = ?
AND completionTokens = ?
ORDER BY id DESC LIMIT 1`,
[
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,
]
);
@@ -284,10 +303,10 @@ export async function saveRequestUsage(entry) {
}
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.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",
stringifyJson(tokens), stringifyJson({}),
]
@@ -297,7 +316,7 @@ export async function saveRequestUsage(entry) {
const row = db.get(`SELECT data FROM usageDaily WHERE dateKey = ?`, [dateKey]);
const day = row ? parseJson(row.data, {}) : {
requests: 0, promptTokens: 0, completionTokens: 0, cost: 0,
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byUser: {}, byEndpoint: {},
};
aggregateEntryToDay(day, entry);
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: {},
byAccount: {},
byApiKey: {},
byUser: {},
byEndpoint: {},
last10Minutes: Array.from({ length: 10 }, () => ({ requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 })),
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) {
if (period === "today") {
const startOfDay = new Date();
@@ -484,11 +563,15 @@ async function getScopedUsageStats(period, user, scope) {
stats.pending.byModel[model] = (stats.pending.byModel[model] || 0) + count;
}
}
return stats;
return applyUsageViewPermissions(stats, user);
}
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);
const db = await getAdapter();
@@ -541,7 +624,7 @@ export async function getUsageStats(period = "all", user = null) {
const stats = {
totalRequests: 0,
totalPromptTokens: 0, totalCompletionTokens: 0, totalCachedTokens: 0, totalCost: 0,
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byUser: {}, byEndpoint: {},
last10Minutes: [],
pending: pendingRequests,
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);
return stats;
if (scope.isAdmin) {
addUserBreakdown(stats, loadUserBreakdownRows(db, period), providerNodeNameMap);
}
return applyUsageViewPermissions(stats, user || { role: "admin" });
}
function buildChartDataFromRows(rows, period) {
@@ -830,7 +916,9 @@ function buildChartDataFromRows(rows, period) {
export async function getChartData(period = "7d", user = null) {
const db = await getAdapter();
const scope = await getUsageAccessScope(user);
const scope = user
? await getUsageAccessScope(user)
: { isAdmin: true, userId: null, connectionIds: [], apiKeys: [] };
if (!scope.isAdmin) {
const conds = [];
const params = [];
+3 -1
View File
@@ -3,7 +3,7 @@
// pre-change safety backup in migrate.js: when the stored version is lower,
// 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.
export const SCHEMA_VERSION = 4;
export const SCHEMA_VERSION = 5;
export const PRAGMA_SQL = `
PRAGMA journal_mode = WAL;
@@ -135,6 +135,7 @@ export const TABLES = {
model: "TEXT",
connectionId: "TEXT",
apiKey: "TEXT",
userId: "TEXT",
endpoint: "TEXT",
promptTokens: "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_model ON usageHistory(model)",
"CREATE INDEX IF NOT EXISTS idx_uh_conn ON usageHistory(connectionId)",
"CREATE INDEX IF NOT EXISTS idx_uh_user ON usageHistory(userId)",
],
},
usageDaily: {
+20 -24
View File
@@ -117,6 +117,7 @@ function getGroupKey(item, keyField) {
switch (keyField) {
case "rawModel": return item.rawModel || "Unknown Model";
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 "endpoint": return item.endpoint || "Unknown Endpoint";
default: return item[keyField] || "Unknown";
@@ -161,10 +162,10 @@ const MODEL_COLUMNS = [
{ field: "lastUsed", label: "Last Used", align: "right" },
];
const ACCOUNT_COLUMNS = [
const USER_COLUMNS = [
{ field: "username", label: "User" },
{ field: "rawModel", label: "Model" },
{ field: "provider", label: "Provider" },
{ field: "accountName", label: "Account" },
{ field: "requests", label: "Requests", align: "right" },
{ field: "lastUsed", label: "Last Used", align: "right" },
];
@@ -187,7 +188,7 @@ const ENDPOINT_COLUMNS = [
const TABLE_OPTIONS = [
{ 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: "endpoint", label: "Usage by Endpoint" },
];
@@ -218,6 +219,11 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
const hasLoadedStats = useRef(false);
const period = periodProp ?? periodLocal;
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
// 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
const activeTableConfig = useMemo(() => {
if (!stats) return null;
switch (tableView) {
switch (activeTableView) {
case "model": {
const pendingMap = stats.pending?.byModel || {};
return {
@@ -344,22 +350,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
),
};
}
case "account": {
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;
}
});
}
case "user": {
return {
columns: ACCOUNT_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byAccount, pendingMap, sortBy, sortOrder), "accountName"),
storageKey: "usage-stats:expanded-accounts",
emptyMessage: "No account-specific usage recorded yet.",
columns: USER_COLUMNS,
groupedData: groupDataByKey(sortData(stats.byUser, {}, sortBy, sortOrder), "username"),
storageKey: "usage-stats:expanded-users",
emptyMessage: "No user-specific usage recorded yet.",
renderSummaryCells: (group) => (
<>
<td className="px-6 py-3 text-text-muted"></td>
@@ -370,7 +366,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
),
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"><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>
@@ -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>;
@@ -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-2 sm:flex-row sm:items-center sm:justify-between">
<select
value={tableView}
value={activeTableView}
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"
style={{ colorScheme: 'auto' }}
>
{TABLE_OPTIONS.map((opt) => (
{tableOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
@@ -516,7 +512,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
title=""
columns={activeTableConfig.columns}
groupedData={activeTableConfig.groupedData}
tableType={tableView}
tableType={activeTableView}
sortBy={sortBy}
sortOrder={sortOrder}
onToggleSort={toggleSort}
+15
View File
@@ -30,6 +30,21 @@ describe("usage access scope SQL predicates", () => {
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", () => {
const conditions = [];
const params = [];
+64
View File
@@ -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 }),
]));
});
});