diff --git a/.gitignore b/.gitignore
index 328c2e413dd..1cea75e88e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,6 @@ ecosystem.config.*
scripts/agSniffer/*
gitbooks/*
gitbook/README.md
+
+# Claude Code local harness
+.claude/
diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js
index 61d5cfcc106..e3211df6fa8 100644
--- a/open-sse/handlers/chatCore/nonStreamingHandler.js
+++ b/open-sse/handlers/chatCore/nonStreamingHandler.js
@@ -159,7 +159,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const usage = extractUsageFromResponse(responseBody);
appendLog({ tokens: usage, status: "200 OK" });
- saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
+ saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project });
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js
index d9dde1a36ef..ca65b2dc2af 100644
--- a/open-sse/handlers/chatCore/requestDetail.js
+++ b/open-sse/handlers/chatCore/requestDetail.js
@@ -72,7 +72,7 @@ export function buildRequestDetail(base, overrides = {}) {
};
}
-export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
+export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, project, label = "USAGE" }) {
if (!tokens || typeof tokens !== "object") return;
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
@@ -97,6 +97,7 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
timestamp: new Date().toISOString(),
connectionId: connectionId || undefined,
apiKey: apiKey || undefined,
- endpoint: endpoint || null
+ endpoint: endpoint || null,
+ project: project || null
}).catch(() => {});
}
diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js
index b4d68ae5d2f..7d2cc312c8c 100644
--- a/open-sse/handlers/chatCore/sseToJsonHandler.js
+++ b/open-sse/handlers/chatCore/sseToJsonHandler.js
@@ -120,7 +120,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
const usage = jsonResponse.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
- saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
+ saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project });
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const totalLatency = Date.now() - requestStartTime;
@@ -195,7 +195,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
const usage = parsed.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
- saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
+ saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project });
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js
index 03d12c13942..1b7097961f5 100644
--- a/open-sse/handlers/chatCore/streamingHandler.js
+++ b/open-sse/handlers/chatCore/streamingHandler.js
@@ -92,7 +92,7 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
- saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
+ saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, project: clientRawRequest?.project, label: "STREAM USAGE" });
};
return { onStreamComplete, streamDetailId };
diff --git a/src/app/(dashboard)/dashboard/projects/page.js b/src/app/(dashboard)/dashboard/projects/page.js
new file mode 100644
index 00000000000..94ec8337d25
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/projects/page.js
@@ -0,0 +1,21 @@
+"use client";
+
+import { Suspense } from "react";
+import { UsageStats, CardSkeleton } from "@/shared/components";
+
+export default function ProjectsPage() {
+ return (
+ }>
+
+
+
Projects
+
+ Per-project usage. Tag requests with the x-project header
+ to break down token and cost by project and model.
+
+
+
+
+
+ );
+}
diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js
index 63d0494eb3c..751b87a8a32 100644
--- a/src/lib/db/repos/usageRepo.js
+++ b/src/lib/db/repos/usageRepo.js
@@ -57,6 +57,7 @@ function aggregateEntryToDay(day, entry) {
day.byAccount ||= {};
day.byApiKey ||= {};
day.byEndpoint ||= {};
+ day.byProject ||= {};
if (entry.provider) addToCounter(day.byProvider, entry.provider, vals);
@@ -74,6 +75,11 @@ function aggregateEntryToDay(day, entry) {
const endpoint = entry.endpoint || "Unknown";
const epKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`;
addToCounter(day.byEndpoint, epKey, { ...vals, meta: { endpoint, rawModel: entry.model, provider: entry.provider } });
+
+ const projectValue = entry.project && typeof entry.project === "string" ? entry.project : null;
+ const projectKeySegment = projectValue || "__untagged__";
+ const projectKey = `${projectKeySegment}|${entry.model}|${entry.provider || "unknown"}`;
+ addToCounter(day.byProject, projectKey, { ...vals, meta: { project: projectValue, rawModel: entry.model, provider: entry.provider } });
}
function pushToRing(entry) {
@@ -255,10 +261,11 @@ export async function saveRequestUsage(entry) {
// better-sqlite3 is sync → no JS yield mid-transaction → no race in same process.
db.transaction(() => {
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, endpoint, project, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
entry.timestamp, entry.provider || null, entry.model || null,
entry.connectionId || null, entry.apiKey || null, entry.endpoint || null,
+ entry.project || null,
promptTokens, completionTokens, entry.cost || 0, entry.status || "ok",
stringifyJson(tokens), stringifyJson({}),
]
@@ -367,7 +374,7 @@ export async function getUsageStats(period = "all") {
const stats = {
totalRequests: 0,
totalPromptTokens: 0, totalCompletionTokens: 0, totalCost: 0,
- byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {},
+ byProvider: {}, byModel: {}, byAccount: {}, byApiKey: {}, byEndpoint: {}, byProject: {},
last10Minutes: [],
pending: pendingRequests,
activeRequests: [],
@@ -500,12 +507,27 @@ export async function getUsageStats(period = "all") {
stats.byEndpoint[epKey].cost += ep.cost || 0;
if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey;
}
+
+ for (const [projectKey, pr] of Object.entries(day.byProject || {})) {
+ const rawModel = pr.rawModel || "";
+ const provider = pr.provider || "";
+ const providerDisplayName = providerNodeNameMap[provider] || provider;
+ const projectName = pr.project || "Untagged";
+ if (!stats.byProject[projectKey]) {
+ stats.byProject[projectKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, project: pr.project || null, projectName, lastUsed: dateKey };
+ }
+ stats.byProject[projectKey].requests += pr.requests || 0;
+ stats.byProject[projectKey].promptTokens += pr.promptTokens || 0;
+ stats.byProject[projectKey].completionTokens += pr.completionTokens || 0;
+ stats.byProject[projectKey].cost += pr.cost || 0;
+ if (dateKey > (stats.byProject[projectKey].lastUsed || "")) stats.byProject[projectKey].lastUsed = dateKey;
+ }
}
// Overlay precise lastUsed timestamps from history
const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0;
const histRows = db.all(
- `SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`,
+ `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, project FROM usageHistory WHERE timestamp >= ?`,
[new Date(overlayCutoff).toISOString()]
);
for (const e of histRows) {
@@ -527,6 +549,10 @@ export async function getUsageStats(period = "all") {
const endpoint = e.endpoint || "Unknown";
const endpointKey = `${endpoint}|${e.model}|${e.provider || "unknown"}`;
if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) stats.byEndpoint[endpointKey].lastUsed = ts;
+
+ const projectKeySegment = (e.project && typeof e.project === "string") ? e.project : "__untagged__";
+ const projectKey = `${projectKeySegment}|${e.model}|${e.provider || "unknown"}`;
+ if (stats.byProject[projectKey] && new Date(ts) > new Date(stats.byProject[projectKey].lastUsed)) stats.byProject[projectKey].lastUsed = ts;
}
} else {
// 24h / today: live history
@@ -539,7 +565,7 @@ export async function getUsageStats(period = "all") {
cutoff = new Date(Date.now() - PERIOD_MS["24h"]).toISOString();
}
const filtered = db.all(
- `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
+ `SELECT timestamp, provider, model, connectionId, apiKey, endpoint, project, promptTokens, completionTokens, cost, tokens FROM usageHistory WHERE timestamp >= ?`,
[cutoff]
);
@@ -610,6 +636,15 @@ export async function getUsageStats(period = "all") {
const epe = stats.byEndpoint[epKey];
epe.requests++; epe.promptTokens += promptTokens; epe.completionTokens += completionTokens; epe.cost += entryCost;
if (new Date(r.timestamp) > new Date(epe.lastUsed)) epe.lastUsed = r.timestamp;
+
+ const projectValue = (r.project && typeof r.project === "string") ? r.project : null;
+ const projectKey = `${projectValue || "__untagged__"}|${r.model}|${r.provider || "unknown"}`;
+ if (!stats.byProject[projectKey]) {
+ stats.byProject[projectKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, project: projectValue, projectName: projectValue || "Untagged", lastUsed: r.timestamp };
+ }
+ const projectEntry = stats.byProject[projectKey];
+ projectEntry.requests++; projectEntry.promptTokens += promptTokens; projectEntry.completionTokens += completionTokens; projectEntry.cost += entryCost;
+ if (new Date(r.timestamp) > new Date(projectEntry.lastUsed)) projectEntry.lastUsed = r.timestamp;
}
}
diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js
index 71c230c8c8e..1c53dae3e1d 100644
--- a/src/lib/db/schema.js
+++ b/src/lib/db/schema.js
@@ -111,6 +111,7 @@ export const TABLES = {
connectionId: "TEXT",
apiKey: "TEXT",
endpoint: "TEXT",
+ project: "TEXT",
promptTokens: "INTEGER DEFAULT 0",
completionTokens: "INTEGER DEFAULT 0",
cost: "REAL DEFAULT 0",
diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js
index 4dc3e66612b..bfa18bdc12e 100644
--- a/src/shared/components/Sidebar.js
+++ b/src/shared/components/Sidebar.js
@@ -22,6 +22,7 @@ const navItems = [
// { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
+ { href: "/dashboard/projects", label: "Projects", icon: "folder" },
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
{ href: "/dashboard/mitm", label: "MITM", icon: "security" },
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js
index 671219290a7..97ac0a77a05 100644
--- a/src/shared/components/UsageStats.js
+++ b/src/shared/components/UsageStats.js
@@ -110,6 +110,7 @@ function getGroupKey(item, keyField) {
case "accountName": return item.accountName || `Account ${item.connectionId?.slice(0, 8)}...` || "Unknown Account";
case "keyName": return item.keyName || "Unknown Key";
case "endpoint": return item.endpoint || "Unknown Endpoint";
+ case "projectName": return item.projectName || "Untagged";
default: return item[keyField] || "Unknown";
}
}
@@ -174,10 +175,19 @@ const ENDPOINT_COLUMNS = [
{ field: "lastUsed", label: "Last Used", align: "right" },
];
+const PROJECT_COLUMNS = [
+ { field: "projectName", label: "Project" },
+ { field: "rawModel", label: "Model" },
+ { field: "provider", label: "Provider" },
+ { field: "requests", label: "Requests", align: "right" },
+ { field: "lastUsed", label: "Last Used", align: "right" },
+];
+
const TABLE_OPTIONS = [
{ value: "model", label: "Usage by Model" },
{ value: "account", label: "Usage by Account" },
{ value: "apiKey", label: "Usage by API Key" },
+ { value: "project", label: "Usage by Project" },
{ value: "endpoint", label: "Usage by Endpoint" },
];
@@ -189,7 +199,7 @@ const PERIODS = [
{ value: "60d", label: "60D" },
];
-export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
+export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false, defaultTableView = "model", lockTableView = false } = {}) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -199,7 +209,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false);
- const [tableView, setTableView] = useState("model");
+ const [tableView, setTableView] = useState(defaultTableView);
const [viewMode, setViewMode] = useState("costs");
const [providers, setProviders] = useState([]);
const [periodLocal, setPeriodLocal] = useState("today");
@@ -346,6 +356,31 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
),
};
}
+ case "project": {
+ return {
+ columns: PROJECT_COLUMNS,
+ groupedData: groupDataByKey(sortData(stats.byProject, {}, sortBy, sortOrder), "projectName"),
+ storageKey: "usage-stats:expanded-projects",
+ emptyMessage: "No project usage recorded yet. Send the x-project header to tag requests.",
+ renderSummaryCells: (group) => (
+ <>
+ — |
+ — |
+ {fmt(group.summary.requests)} |
+ {fmtTime(group.summary.lastUsed)} |
+ >
+ ),
+ renderDetailCells: (item) => (
+ <>
+ {item.projectName} |
+ {item.rawModel} |
+ {item.provider} |
+ {fmt(item.requests)} |
+ {fmtTime(item.lastUsed)} |
+ >
+ ),
+ };
+ }
case "apiKey": {
return {
columns: API_KEY_COLUMNS,
@@ -453,16 +488,22 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
{/* Table with dropdown selector */}
-
+ {lockTableView ? (
+
+ {TABLE_OPTIONS.find((opt) => opt.value === tableView)?.label || "Usage"}
+
+ ) : (
+
+ )}