mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: update the permission for showing the usage of the user
This commit is contained in:
@@ -5,6 +5,7 @@ import { getProviderConnectionById } from "@/lib/localDb";
|
|||||||
import { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "open-sse/services/usage.js";
|
import { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "open-sse/services/usage.js";
|
||||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||||
import { refreshAndUpdateCredentials } from "../route.js";
|
import { refreshAndUpdateCredentials } from "../route.js";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
||||||
|
|
||||||
@@ -47,8 +48,8 @@ function getResponseForConsumeResult(result, redeemRequestId) {
|
|||||||
}, { status: result.status >= 400 && result.status < 500 ? result.status : 502 });
|
}, { status: result.status >= 400 && result.status < 500 ? result.status : 502 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCodexConnection(connectionId) {
|
async function getCodexConnection(connectionId, user) {
|
||||||
const connection = await getProviderConnectionById(connectionId);
|
const connection = await getProviderConnectionById(connectionId, user.role === "admin" ? null : user.id);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return { response: Response.json({ error: "Connection not found" }, { status: 404 }) };
|
return { response: Response.json({ error: "Connection not found" }, { status: 404 }) };
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,8 @@ export async function GET(_request, { params }) {
|
|||||||
let connection;
|
let connection;
|
||||||
try {
|
try {
|
||||||
const { connectionId } = await params;
|
const { connectionId } = await params;
|
||||||
const resolved = await getCodexConnection(connectionId);
|
const user = await requireUsageDashboardUser();
|
||||||
|
const resolved = await getCodexConnection(connectionId, user);
|
||||||
if (resolved.response) return resolved.response;
|
if (resolved.response) return resolved.response;
|
||||||
({ connection } = resolved);
|
({ connection } = resolved);
|
||||||
const { isOAuth, proxyOptions } = resolved;
|
const { isOAuth, proxyOptions } = resolved;
|
||||||
@@ -112,6 +114,7 @@ export async function GET(_request, { params }) {
|
|||||||
|
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const provider = connection?.provider ?? "unknown";
|
const provider = connection?.provider ?? "unknown";
|
||||||
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
|
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
@@ -122,7 +125,8 @@ export async function POST(request, { params }) {
|
|||||||
let connection;
|
let connection;
|
||||||
try {
|
try {
|
||||||
const { connectionId } = await params;
|
const { connectionId } = await params;
|
||||||
const resolved = await getCodexConnection(connectionId);
|
const user = await requireUsageDashboardUser();
|
||||||
|
const resolved = await getCodexConnection(connectionId, user);
|
||||||
if (resolved.response) return resolved.response;
|
if (resolved.response) return resolved.response;
|
||||||
({ connection } = resolved);
|
({ connection } = resolved);
|
||||||
const { isOAuth, proxyOptions } = resolved;
|
const { isOAuth, proxyOptions } = resolved;
|
||||||
@@ -149,6 +153,7 @@ export async function POST(request, { params }) {
|
|||||||
|
|
||||||
return getResponseForConsumeResult(consumeResult, redeemRequestId);
|
return getResponseForConsumeResult(consumeResult, redeemRequestId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const provider = connection?.provider ?? "unknown";
|
const provider = connection?.provider ?? "unknown";
|
||||||
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
|
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getUsageForProvider } from "open-sse/services/usage.js";
|
|||||||
import { getExecutor } from "open-sse/executors/index.js";
|
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 { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
// Detect auth-expired messages returned by usage providers instead of throwing
|
// Detect auth-expired messages returned by usage providers instead of throwing
|
||||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
||||||
@@ -166,9 +167,10 @@ export async function GET(request, { params }) {
|
|||||||
}, { status: 401 });
|
}, { status: 401 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
|
|
||||||
// Fetch usage from provider API
|
// Fetch usage from provider API
|
||||||
let usage = await getUsageForProvider(connection, proxyOptions);
|
connection = await getProviderConnectionById(connectionId, user.role === "admin" ? null : user.id);
|
||||||
|
|
||||||
// If provider returned an auth-expired message instead of throwing,
|
// If provider returned an auth-expired message instead of throwing,
|
||||||
// force-refresh token and retry once (OAuth only)
|
// force-refresh token and retry once (OAuth only)
|
||||||
@@ -184,6 +186,7 @@ export async function GET(request, { params }) {
|
|||||||
|
|
||||||
return Response.json(usage);
|
return Response.json(usage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
const provider = connection?.provider ?? "unknown";
|
const provider = connection?.provider ?? "unknown";
|
||||||
console.warn(`[Usage] ${provider}: ${error.message}`);
|
console.warn(`[Usage] ${provider}: ${error.message}`);
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getChartData } from "@/lib/usageDb";
|
import { getChartData } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]);
|
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]);
|
||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const period = searchParams.get("period") || "7d";
|
const period = searchParams.get("period") || "7d";
|
||||||
|
|
||||||
@@ -12,9 +14,10 @@ export async function GET(request) {
|
|||||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getChartData(period);
|
const data = await getChartData(period, user);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("[API] Failed to get chart data:", error);
|
console.error("[API] Failed to get chart data:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch chart data" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getUsageStats } from "@/lib/usageDb";
|
import { getUsageStats } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const stats = await getUsageStats();
|
const user = await requireUsageDashboardUser();
|
||||||
|
const stats = await getUsageStats("all", user);
|
||||||
return NextResponse.json(stats);
|
return NextResponse.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("Error fetching usage stats:", error);
|
console.error("Error fetching usage stats:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRecentLogs } from "@/lib/usageDb";
|
import { getRecentLogs } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const logs = await getRecentLogs(200);
|
const user = await requireUsageDashboardUser();
|
||||||
|
const logs = await getRecentLogs(200, user);
|
||||||
return NextResponse.json(logs);
|
return NextResponse.json(logs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("Error fetching logs:", error);
|
console.error("Error fetching logs:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getDistinctProviders } from "@/lib/requestDetailsDb";
|
import { getDistinctProviders } from "@/lib/requestDetailsDb";
|
||||||
import { getProviderNodes } from "@/lib/localDb";
|
import { getProviderNodes } from "@/lib/localDb";
|
||||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage/providers
|
* GET /api/usage/providers
|
||||||
@@ -9,9 +10,10 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
|||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
// Query DISTINCT provider column directly — avoids parsing every row's
|
// Query DISTINCT provider column directly — avoids parsing every row's
|
||||||
// full JSON blob (can be hundreds of MB), which previously caused OOM.
|
// full JSON blob (can be hundreds of MB), which previously caused OOM.
|
||||||
const providerIds = await getDistinctProviders();
|
const providerIds = await getDistinctProviders(user);
|
||||||
|
|
||||||
const providerNodes = await getProviderNodes();
|
const providerNodes = await getProviderNodes();
|
||||||
const nodeMap = {};
|
const nodeMap = {};
|
||||||
@@ -32,6 +34,7 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({ providers });
|
return NextResponse.json({ providers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("[API] Failed to get providers:", error);
|
console.error("[API] Failed to get providers:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch providers" },
|
{ error: "Failed to fetch providers" },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRequestDetails } from "@/lib/usageDb";
|
import { getRequestDetails } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage/request-details
|
* GET /api/usage/request-details
|
||||||
@@ -7,6 +8,7 @@ import { getRequestDetails } from "@/lib/usageDb";
|
|||||||
*/
|
*/
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
const pageRaw = parseInt(searchParams.get("page"));
|
const pageRaw = parseInt(searchParams.get("page"));
|
||||||
@@ -46,10 +48,11 @@ export async function GET(request) {
|
|||||||
if (startDate) filter.startDate = startDate;
|
if (startDate) filter.startDate = startDate;
|
||||||
if (endDate) filter.endDate = endDate;
|
if (endDate) filter.endDate = endDate;
|
||||||
|
|
||||||
const result = await getRequestDetails(filter);
|
const result = await getRequestDetails(filter, user);
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("[API] Failed to get request details:", error);
|
console.error("[API] Failed to get request details:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch request details" },
|
{ error: "Failed to fetch request details" },
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getRecentLogs } from "@/lib/usageDb";
|
import { getRecentLogs } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const logs = await getRecentLogs(200);
|
const user = await requireUsageDashboardUser();
|
||||||
|
const logs = await getRecentLogs(200, user);
|
||||||
return NextResponse.json(logs);
|
return NextResponse.json(logs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("[API ERROR] /api/usage/logs failed:", error);
|
console.error("[API ERROR] /api/usage/logs failed:", error);
|
||||||
console.error("[API ERROR] Stack:", error?.stack);
|
console.error("[API ERROR] Stack:", error?.stack);
|
||||||
return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getUsageStats } from "@/lib/usageDb";
|
import { getUsageStats } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]);
|
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]);
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export async function GET(request) {
|
export async function GET(request) {
|
||||||
try {
|
try {
|
||||||
|
const user = await requireUsageDashboardUser();
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const period = searchParams.get("period") || "7d";
|
const period = searchParams.get("period") || "7d";
|
||||||
|
|
||||||
@@ -14,9 +16,10 @@ export async function GET(request) {
|
|||||||
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid period" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await getUsageStats(period);
|
const stats = await getUsageStats(period, user);
|
||||||
return NextResponse.json(stats);
|
return NextResponse.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
console.error("[API] Failed to get usage stats:", error);
|
console.error("[API] Failed to get usage stats:", error);
|
||||||
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
|
return NextResponse.json({ error: "Failed to fetch usage stats" }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
|
import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
|
||||||
|
import { requireUsageDashboardUser } from "@/lib/auth/currentUser";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = await requireUsageDashboardUser();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.message === "Unauthorized") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
return Response.json({ error: "Failed to authorize usage stream" }, { status: 500 });
|
||||||
|
}
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null };
|
const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null };
|
||||||
|
|
||||||
@@ -14,12 +22,12 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
// Push lightweight update immediately so UI reflects changes fast
|
// Push lightweight update immediately so UI reflects changes fast
|
||||||
if (state.cachedStats) {
|
if (state.cachedStats) {
|
||||||
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
|
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(user);
|
||||||
const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
|
||||||
}
|
}
|
||||||
// Then do full recalc and update cache
|
// Then do full recalc and update cache
|
||||||
const stats = await getUsageStats();
|
const stats = await getUsageStats("all", user);
|
||||||
state.cachedStats = stats;
|
state.cachedStats = stats;
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -34,7 +42,7 @@ export async function GET() {
|
|||||||
state.sendPending = async () => {
|
state.sendPending = async () => {
|
||||||
if (state.closed || !state.cachedStats) return;
|
if (state.closed || !state.cachedStats) return;
|
||||||
try {
|
try {
|
||||||
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
|
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests(user);
|
||||||
const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getDashboardAuthSession } from "./dashboardSession.js";
|
import { getDashboardAuthSession } from "./dashboardSession.js";
|
||||||
import { getUserById, verifyUserPassword } from "@/lib/db";
|
import { getSettings, getUserById, verifyUserPassword } from "@/lib/db";
|
||||||
|
|
||||||
export async function getCurrentDashboardUser() {
|
export async function getCurrentDashboardUser() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -22,6 +22,20 @@ export async function requireCurrentDashboardUser() {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the user for dashboard data that is also available in the explicit
|
||||||
|
* single-user (`requireLogin=false`) deployment mode. That mode has no account
|
||||||
|
* boundary, so it intentionally uses the system-wide administrator scope.
|
||||||
|
*/
|
||||||
|
export async function requireUsageDashboardUser() {
|
||||||
|
const user = await getCurrentDashboardUser();
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings?.requireLogin === false) return { id: null, username: "local", role: "admin" };
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireAdminUser() {
|
export async function requireAdminUser() {
|
||||||
const user = await requireCurrentDashboardUser();
|
const user = await requireCurrentDashboardUser();
|
||||||
if (user.role !== "admin") throw new Error("Forbidden");
|
if (user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getAdapter } from "../driver.js";
|
import { getAdapter } from "../driver.js";
|
||||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
|
import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_RECORDS = 200;
|
const DEFAULT_MAX_RECORDS = 200;
|
||||||
const DEFAULT_BATCH_SIZE = 20;
|
const DEFAULT_BATCH_SIZE = 20;
|
||||||
@@ -142,8 +143,9 @@ export async function saveRequestDetail(detail) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRequestDetails(filter = {}) {
|
export async function getRequestDetails(filter = {}, user = null) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
const scope = await getUsageAccessScope(user);
|
||||||
const conds = [];
|
const conds = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ export async function getRequestDetails(filter = {}) {
|
|||||||
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 });
|
||||||
|
|
||||||
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);
|
||||||
@@ -175,15 +178,23 @@ export async function getRequestDetails(filter = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDistinctProviders() {
|
export async function getDistinctProviders(user = null) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`);
|
const scope = await getUsageAccessScope(user);
|
||||||
|
const conds = ["provider IS NOT NULL"];
|
||||||
|
const params = [];
|
||||||
|
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: 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);
|
return rows.map((r) => r.provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRequestDetailById(id) {
|
export async function getRequestDetailById(id, user = null) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
|
const scope = await getUsageAccessScope(user);
|
||||||
|
const conds = ["id = ?"];
|
||||||
|
const params = [id];
|
||||||
|
appendUsageAccessClause(conds, params, scope, { apiKeyColumn: null });
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { getAdapter } from "../driver.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the ownership boundary used by usage and observability queries.
|
||||||
|
*
|
||||||
|
* Administrators have system-wide visibility. A normal user can only see a
|
||||||
|
* request when it is attributable to one of their provider connections or
|
||||||
|
* dashboard API keys. Requests without either association are deliberately
|
||||||
|
* excluded for normal users rather than being treated as shared usage.
|
||||||
|
*/
|
||||||
|
export async function getUsageAccessScope(user) {
|
||||||
|
if (user?.role === "admin") {
|
||||||
|
return { isAdmin: true, connectionIds: [], apiKeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return { isAdmin: false, connectionIds: [], apiKeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getAdapter();
|
||||||
|
const connectionIds = db.all(
|
||||||
|
`SELECT id FROM providerConnections WHERE ownerId = ?`,
|
||||||
|
[user.id],
|
||||||
|
).map((row) => row.id);
|
||||||
|
const apiKeys = db.all(
|
||||||
|
`SELECT key FROM apiKeys WHERE ownerId = ?`,
|
||||||
|
[user.id],
|
||||||
|
).map((row) => row.key);
|
||||||
|
|
||||||
|
return { isAdmin: false, connectionIds, apiKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an ownership predicate to a SQL WHERE clause.
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export function appendUsageAccessClause(
|
||||||
|
conditions,
|
||||||
|
params,
|
||||||
|
scope,
|
||||||
|
{ connectionColumn = "connectionId", apiKeyColumn = "apiKey" } = {},
|
||||||
|
) {
|
||||||
|
if (scope?.isAdmin) return;
|
||||||
|
|
||||||
|
const ownershipConditions = [];
|
||||||
|
if (scope?.connectionIds?.length) {
|
||||||
|
ownershipConditions.push(`${connectionColumn} IN (${scope.connectionIds.map(() => "?").join(", ")})`);
|
||||||
|
params.push(...scope.connectionIds);
|
||||||
|
}
|
||||||
|
if (apiKeyColumn && scope?.apiKeys?.length) {
|
||||||
|
ownershipConditions.push(`${apiKeyColumn} IN (${scope.apiKeys.map(() => "?").join(", ")})`);
|
||||||
|
params.push(...scope.apiKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing ownership relation must never grant access to system usage.
|
||||||
|
conditions.push(ownershipConditions.length ? `(${ownershipConditions.join(" OR ")})` : "1 = 0");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { EventEmitter } from "events";
|
|||||||
import { getAdapter } from "../driver.js";
|
import { getAdapter } from "../driver.js";
|
||||||
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
|
||||||
import { getMeta, setMeta } from "../helpers/metaStore.js";
|
import { getMeta, setMeta } from "../helpers/metaStore.js";
|
||||||
|
import { appendUsageAccessClause, getUsageAccessScope } from "./usageAccessScope.js";
|
||||||
|
|
||||||
function maskApiKey(key) {
|
function maskApiKey(key) {
|
||||||
if (!key || typeof key !== "string") return null;
|
if (!key || typeof key !== "string") return null;
|
||||||
@@ -193,11 +194,14 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
|
|||||||
scheduleStatsEvent("pending");
|
scheduleStatsEvent("pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveRequests() {
|
export async function getActiveRequests(user = null) {
|
||||||
const activeRequests = [];
|
const activeRequests = [];
|
||||||
const connectionMap = await getConnectionMapCached();
|
const connectionMap = await getConnectionMapCached();
|
||||||
|
const scope = await getUsageAccessScope(user);
|
||||||
|
const allowedConnectionIds = new Set(scope.connectionIds);
|
||||||
|
|
||||||
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||||
|
if (!scope.isAdmin && !allowedConnectionIds.has(connectionId)) continue;
|
||||||
for (const [modelKey, count] of Object.entries(models)) {
|
for (const [modelKey, count] of Object.entries(models)) {
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||||
@@ -214,6 +218,7 @@ export async function getActiveRequests() {
|
|||||||
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))
|
||||||
.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 || {};
|
||||||
@@ -343,7 +348,148 @@ function loadDaysInRange(adapter, maxDays) {
|
|||||||
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]);
|
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsageStats(period = "all") {
|
function createEmptyUsageStats() {
|
||||||
|
return {
|
||||||
|
totalRequests: 0,
|
||||||
|
totalPromptTokens: 0,
|
||||||
|
totalCompletionTokens: 0,
|
||||||
|
totalCachedTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
byProvider: {},
|
||||||
|
byModel: {},
|
||||||
|
byAccount: {},
|
||||||
|
byApiKey: {},
|
||||||
|
byEndpoint: {},
|
||||||
|
last10Minutes: Array.from({ length: 10 }, () => ({ requests: 0, promptTokens: 0, completionTokens: 0, cost: 0 })),
|
||||||
|
pending: { byModel: {}, byAccount: {} },
|
||||||
|
activeRequests: [],
|
||||||
|
recentRequests: [],
|
||||||
|
errorProvider: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsagePeriodCutoff(period) {
|
||||||
|
if (period === "today") {
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
return startOfDay.toISOString();
|
||||||
|
}
|
||||||
|
if (period === "24h") return new Date(Date.now() - PERIOD_MS["24h"]).toISOString();
|
||||||
|
const days = { "7d": 7, "30d": 30, "60d": 60 }[period];
|
||||||
|
if (!days) return null;
|
||||||
|
const startOfRange = new Date();
|
||||||
|
startOfRange.setHours(0, 0, 0, 0);
|
||||||
|
startOfRange.setDate(startOfRange.getDate() - days + 1);
|
||||||
|
return startOfRange.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScopedUsageStats(period, user, scope) {
|
||||||
|
const db = await getAdapter();
|
||||||
|
const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([
|
||||||
|
import("./connectionsRepo.js"),
|
||||||
|
import("./apiKeysRepo.js"),
|
||||||
|
import("./nodesRepo.js"),
|
||||||
|
]);
|
||||||
|
const [connections, apiKeys, providerNodes] = await Promise.all([
|
||||||
|
getProviderConnections({ ownerId: user.id }),
|
||||||
|
getApiKeys(),
|
||||||
|
getProviderNodes(),
|
||||||
|
]);
|
||||||
|
const connectionMap = Object.fromEntries(connections.map((connection) => [connection.id, connection.name || connection.email || connection.id]));
|
||||||
|
const apiKeyMap = Object.fromEntries(apiKeys.map((key) => [key.key, key]));
|
||||||
|
const providerNodeNameMap = Object.fromEntries(providerNodes.filter((node) => node.id && node.name).map((node) => [node.id, node.name]));
|
||||||
|
const stats = createEmptyUsageStats();
|
||||||
|
const conds = [];
|
||||||
|
const params = [];
|
||||||
|
const cutoff = getUsagePeriodCutoff(period);
|
||||||
|
if (cutoff) { conds.push("timestamp >= ?"); params.push(cutoff); }
|
||||||
|
appendUsageAccessClause(conds, params, scope);
|
||||||
|
const rows = db.all(
|
||||||
|
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens FROM usageHistory WHERE ${conds.join(" AND ")} ORDER BY id DESC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const currentMinuteStart = Math.floor(Date.now() / 60000) * 60000;
|
||||||
|
const minuteBuckets = new Map(stats.last10Minutes.map((bucket, index) => [currentMinuteStart - (9 - index) * 60000, bucket]));
|
||||||
|
|
||||||
|
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 cost = row.cost || 0;
|
||||||
|
const provider = row.provider || "unknown";
|
||||||
|
const providerDisplayName = providerNodeNameMap[provider] || provider;
|
||||||
|
|
||||||
|
stats.totalRequests++;
|
||||||
|
stats.totalPromptTokens += promptTokens;
|
||||||
|
stats.totalCompletionTokens += completionTokens;
|
||||||
|
stats.totalCachedTokens += cachedTokens;
|
||||||
|
stats.totalCost += cost;
|
||||||
|
addToCounter(stats.byProvider, provider, { promptTokens, completionTokens, cachedTokens, cost });
|
||||||
|
|
||||||
|
const modelKey = `${row.model} (${provider})`;
|
||||||
|
addToCounter(stats.byModel, modelKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, lastUsed: row.timestamp } });
|
||||||
|
if (new Date(row.timestamp) > new Date(stats.byModel[modelKey].lastUsed)) stats.byModel[modelKey].lastUsed = row.timestamp;
|
||||||
|
|
||||||
|
if (row.connectionId && connectionMap[row.connectionId]) {
|
||||||
|
const accountName = connectionMap[row.connectionId];
|
||||||
|
const accountKey = `${row.model} (${provider} - ${accountName})`;
|
||||||
|
addToCounter(stats.byAccount, accountKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, connectionId: row.connectionId, accountName, lastUsed: row.timestamp } });
|
||||||
|
if (new Date(row.timestamp) > new Date(stats.byAccount[accountKey].lastUsed)) stats.byAccount[accountKey].lastUsed = row.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request may be visible through an owned connection even when its API
|
||||||
|
// key belongs to another account. Do not expose that key's name or mask.
|
||||||
|
if (!row.apiKey || scope.apiKeys.includes(row.apiKey)) {
|
||||||
|
const keyInfo = row.apiKey ? apiKeyMap[row.apiKey] : null;
|
||||||
|
const apiKeyMasked = maskApiKey(row.apiKey);
|
||||||
|
const apiKeyKey = row.apiKey ? `${apiKeyMasked}|${row.model}|${provider}` : "local-no-key";
|
||||||
|
addToCounter(stats.byApiKey, apiKeyKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { rawModel: row.model, provider: providerDisplayName, apiKeyMasked, keyName: keyInfo?.name || apiKeyMasked || "Local (No API Key)", apiKeyKey: apiKeyMasked || "local-no-key", lastUsed: row.timestamp } });
|
||||||
|
if (new Date(row.timestamp) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) stats.byApiKey[apiKeyKey].lastUsed = row.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = row.endpoint || "Unknown";
|
||||||
|
const endpointKey = `${endpoint}|${row.model}|${provider}`;
|
||||||
|
addToCounter(stats.byEndpoint, endpointKey, { promptTokens, completionTokens, cachedTokens, cost, meta: { endpoint, rawModel: row.model, provider: providerDisplayName, lastUsed: row.timestamp } });
|
||||||
|
if (new Date(row.timestamp) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = row.timestamp;
|
||||||
|
|
||||||
|
const minuteBucket = minuteBuckets.get(Math.floor(new Date(row.timestamp).getTime() / 60000) * 60000);
|
||||||
|
if (minuteBucket) {
|
||||||
|
minuteBucket.requests++;
|
||||||
|
minuteBucket.promptTokens += promptTokens;
|
||||||
|
minuteBucket.completionTokens += completionTokens;
|
||||||
|
minuteBucket.cost += cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
stats.recentRequests = rows.map((row) => {
|
||||||
|
const tokens = parseJson(row.tokens, {}) || {};
|
||||||
|
return { timestamp: row.timestamp, model: row.model, provider: row.provider || "", promptTokens: row.promptTokens || tokens.prompt_tokens || tokens.input_tokens || 0, completionTokens: row.completionTokens || tokens.completion_tokens || tokens.output_tokens || 0, cachedTokens: tokens.cached_tokens || tokens.cache_read_input_tokens || 0, status: row.status || "ok" };
|
||||||
|
}).filter((entry) => {
|
||||||
|
if (!entry.promptTokens && !entry.completionTokens) return false;
|
||||||
|
const key = `${entry.model}|${entry.provider}|${entry.promptTokens}|${entry.completionTokens}|${entry.timestamp?.slice(0, 16) || ""}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
}).slice(0, 20);
|
||||||
|
|
||||||
|
const active = await getActiveRequests(user);
|
||||||
|
stats.activeRequests = active.activeRequests;
|
||||||
|
stats.errorProvider = active.errorProvider;
|
||||||
|
for (const connectionId of scope.connectionIds) {
|
||||||
|
if (!pendingRequests.byAccount[connectionId]) continue;
|
||||||
|
stats.pending.byAccount[connectionId] = { ...pendingRequests.byAccount[connectionId] };
|
||||||
|
for (const [model, count] of Object.entries(pendingRequests.byAccount[connectionId])) {
|
||||||
|
stats.pending.byModel[model] = (stats.pending.byModel[model] || 0) + count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageStats(period = "all", user = null) {
|
||||||
|
const scope = await getUsageAccessScope(user);
|
||||||
|
if (!scope.isAdmin) return getScopedUsageStats(period, user, scope);
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
|
||||||
const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([
|
const [{ getProviderConnections }, { getApiKeys }, { getProviderNodes }] = await Promise.all([
|
||||||
@@ -658,8 +804,45 @@ export async function getUsageStats(period = "all") {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChartData(period = "7d") {
|
function buildChartDataFromRows(rows, period) {
|
||||||
|
const now = Date.now();
|
||||||
|
const isHourly = period === "today" || period === "24h";
|
||||||
|
const bucketCount = isHourly ? 24 : period === "7d" ? 7 : period === "30d" ? 30 : 60;
|
||||||
|
const bucketMs = isHourly ? 3600000 : 86400000;
|
||||||
|
const startTime = period === "today"
|
||||||
|
? new Date(new Date().setHours(0, 0, 0, 0)).getTime()
|
||||||
|
: isHourly ? now - bucketCount * bucketMs : new Date(new Date().setHours(0, 0, 0, 0) - (bucketCount - 1) * bucketMs).getTime();
|
||||||
|
const labelFn = isHourly
|
||||||
|
? (timestamp) => new Date(timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })
|
||||||
|
: (timestamp) => new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
const buckets = Array.from({ length: bucketCount }, (_, index) => ({ label: labelFn(startTime + index * bucketMs), tokens: 0, cost: 0 }));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const timestamp = new Date(row.timestamp).getTime();
|
||||||
|
if (timestamp < startTime || timestamp > now) continue;
|
||||||
|
const index = Math.min(Math.floor((timestamp - startTime) / bucketMs), bucketCount - 1);
|
||||||
|
if (index < 0 || index >= bucketCount) continue;
|
||||||
|
buckets[index].tokens += (row.promptTokens || 0) + (row.completionTokens || 0);
|
||||||
|
buckets[index].cost += row.cost || 0;
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChartData(period = "7d", user = null) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
const scope = await getUsageAccessScope(user);
|
||||||
|
if (!scope.isAdmin) {
|
||||||
|
const conds = [];
|
||||||
|
const params = [];
|
||||||
|
const cutoff = getUsagePeriodCutoff(period);
|
||||||
|
if (cutoff) { conds.push("timestamp >= ?"); params.push(cutoff); }
|
||||||
|
appendUsageAccessClause(conds, params, scope);
|
||||||
|
const rows = db.all(
|
||||||
|
`SELECT timestamp, promptTokens, completionTokens, cost FROM usageHistory WHERE ${conds.join(" AND ")}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return buildChartDataFromRows(rows, period);
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (period === "today") {
|
if (period === "today") {
|
||||||
@@ -739,12 +922,17 @@ function formatLogDate(date = new Date()) {
|
|||||||
// No-op: request log is now derived from usageHistory table on read.
|
// No-op: request log is now derived from usageHistory table on read.
|
||||||
export async function appendRequestLog() {}
|
export async function appendRequestLog() {}
|
||||||
|
|
||||||
export async function getRecentLogs(limit = 200) {
|
export async function getRecentLogs(limit = 200, user = null) {
|
||||||
try {
|
try {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
|
const scope = await getUsageAccessScope(user);
|
||||||
|
const conds = [];
|
||||||
|
const params = [];
|
||||||
|
appendUsageAccessClause(conds, params, scope);
|
||||||
|
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
||||||
const rows = db.all(
|
const rows = db.all(
|
||||||
`SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`,
|
`SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ${where} ORDER BY id DESC LIMIT ?`,
|
||||||
[limit],
|
[...params, limit],
|
||||||
);
|
);
|
||||||
if (!rows.length) return [];
|
if (!rows.length) return [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appendUsageAccessClause } from "../../src/lib/db/repos/usageAccessScope.js";
|
||||||
|
|
||||||
|
describe("usage access scope SQL predicates", () => {
|
||||||
|
it("does not restrict administrators", () => {
|
||||||
|
const conditions = ["timestamp >= ?"];
|
||||||
|
const params = ["2026-01-01T00:00:00.000Z"];
|
||||||
|
|
||||||
|
appendUsageAccessClause(conditions, params, {
|
||||||
|
isAdmin: true,
|
||||||
|
connectionIds: [],
|
||||||
|
apiKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conditions).toEqual(["timestamp >= ?"]);
|
||||||
|
expect(params).toEqual(["2026-01-01T00:00:00.000Z"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits users to their owned connections and API keys", () => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
appendUsageAccessClause(conditions, params, {
|
||||||
|
isAdmin: false,
|
||||||
|
connectionIds: ["connection-a", "connection-b"],
|
||||||
|
apiKeys: ["key-a"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conditions).toEqual(["(connectionId IN (?, ?) OR apiKey IN (?))"]);
|
||||||
|
expect(params).toEqual(["connection-a", "connection-b", "key-a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies users with no attributable resources", () => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
appendUsageAccessClause(conditions, params, {
|
||||||
|
isAdmin: false,
|
||||||
|
connectionIds: [],
|
||||||
|
apiKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(conditions).toEqual(["1 = 0"]);
|
||||||
|
expect(params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can scope request details by connection only", () => {
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
appendUsageAccessClause(
|
||||||
|
conditions,
|
||||||
|
params,
|
||||||
|
{ isAdmin: false, connectionIds: ["connection-a"], apiKeys: ["key-a"] },
|
||||||
|
{ apiKeyColumn: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(conditions).toEqual(["(connectionId IN (?))"]);
|
||||||
|
expect(params).toEqual(["connection-a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user