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 // 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 });
}
}
+10
View File
@@ -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;
+2 -1
View File
@@ -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;
+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.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;
} }
+16 -6
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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: {
+20 -24
View File
@@ -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}
+15
View File
@@ -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 = [];
+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 }),
]));
});
});