diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 95df759b..ed032dde 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -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 }); + } +} diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js index bae24205..170b05b7 100644 --- a/src/lib/db/migrate.js +++ b/src/lib/db/migrate.js @@ -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); } diff --git a/src/lib/db/migrations/005-usage-user-attribution.js b/src/lib/db/migrations/005-usage-user-attribution.js new file mode 100644 index 00000000..2ad75898 --- /dev/null +++ b/src/lib/db/migrations/005-usage-user-attribution.js @@ -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; \ No newline at end of file diff --git a/src/lib/db/migrations/index.js b/src/lib/db/migrations/index.js index 53a3c52c..d9bbdaa2 100644 --- a/src/lib/db/migrations/index.js +++ b/src/lib/db/migrations/index.js @@ -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; diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index e2ae14a0..8a5cef5f 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -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; } diff --git a/src/lib/db/repos/usageAccessScope.js b/src/lib/db/repos/usageAccessScope.js index df53c275..c077aac0 100644 --- a/src/lib/db/repos/usageAccessScope.js +++ b/src/lib/db/repos/usageAccessScope.js @@ -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(", ")})`); diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 2a233906..51980e80 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -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 = []; diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js index 785043db..a11279bb 100644 --- a/src/lib/db/schema.js +++ b/src/lib/db/schema.js @@ -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: { diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index ecab40b1..cfc0e07d 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -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) => ( <>