Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,6 @@ ecosystem.config.*
scripts/agSniffer/*
gitbooks/*
gitbook/README.md

# Claude Code local harness
.claude/
2 changes: 1 addition & 1 deletion open-sse/handlers/chatCore/nonStreamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions open-sse/handlers/chatCore/requestDetail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() => {});
}
4 changes: 2 additions & 2 deletions open-sse/handlers/chatCore/sseToJsonHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion open-sse/handlers/chatCore/streamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
21 changes: 21 additions & 0 deletions src/app/(dashboard)/dashboard/projects/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { Suspense } from "react";
import { UsageStats, CardSkeleton } from "@/shared/components";

export default function ProjectsPage() {
return (
<Suspense fallback={<CardSkeleton />}>
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
<div>
<h1 className="text-xl font-semibold">Projects</h1>
<p className="text-sm text-text-muted">
Per-project usage. Tag requests with the <code className="font-mono">x-project</code> header
to break down token and cost by project and model.
</p>
</div>
<UsageStats defaultTableView="project" lockTableView />
</div>
</Suspense>
);
}
43 changes: 39 additions & 4 deletions src/lib/db/repos/usageRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) {
Expand Down Expand Up @@ -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({}),
]
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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]
);

Expand Down Expand Up @@ -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;
}
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/db/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/shared/components/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
65 changes: 53 additions & 12 deletions src/shared/components/UsageStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
Expand Down Expand Up @@ -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" },
];

Expand All @@ -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();

Expand All @@ -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");
Expand Down Expand Up @@ -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) => (
<>
<td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-text-muted">—</td>
<td className="px-6 py-3 text-right">{fmt(group.summary.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">{fmtTime(group.summary.lastUsed)}</td>
</>
),
renderDetailCells: (item) => (
<>
<td className="px-6 py-3 font-medium">{item.projectName}</td>
<td className="px-6 py-3">{item.rawModel}</td>
<td className="px-6 py-3"><Badge variant="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 text-text-muted whitespace-nowrap">{fmtTime(item.lastUsed)}</td>
</>
),
};
}
case "apiKey": {
return {
columns: API_KEY_COLUMNS,
Expand Down Expand Up @@ -453,16 +488,22 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
{/* Table with dropdown selector */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<select
value={tableView}
onChange={(e) => setTableView(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto"
style={{ colorScheme: 'auto' }}
>
{TABLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{lockTableView ? (
<span className="text-sm font-medium text-text-main">
{TABLE_OPTIONS.find((opt) => opt.value === tableView)?.label || "Usage"}
</span>
) : (
<select
value={tableView}
onChange={(e) => setTableView(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-3 py-1.5 text-sm font-medium text-text-main focus:outline-none focus:ring-2 focus:ring-primary/50 sm:w-auto"
style={{ colorScheme: 'auto' }}
>
{TABLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex">
<button
onClick={() => setViewMode("costs")}
Expand Down
6 changes: 5 additions & 1 deletion src/sse/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
markAccountUnavailable,
clearAccountError,
extractApiKey,
extractProjectTag,
isValidApiKey,
} from "../services/auth.js";
import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js";
Expand Down Expand Up @@ -40,7 +41,10 @@ export async function handleChat(request, clientRawRequest = null) {
clientRawRequest = {
endpoint: url.pathname,
body,
headers: Object.fromEntries(request.headers.entries())
headers: Object.fromEntries(request.headers.entries()),
// Caller-supplied usage-grouping label (x-project header); rides
// alongside endpoint into saveUsageStats. null when untagged.
project: extractProjectTag(request)
};
}
cacheClaudeHeaders(clientRawRequest.headers);
Expand Down
22 changes: 22 additions & 0 deletions src/sse/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,28 @@ export function extractApiKey(request) {
return null;
}

// Max stored length for a project tag — bounds DB/log growth and blocks abuse
// via an oversized header value, keeping audit fields (A.12.4) sane.
const MAX_PROJECT_TAG_LENGTH = 100;

/**
* Extract the caller-supplied project tag used to group usage stats.
* Read from the `x-project` header (preferred) or `x-project-id` alias.
* Returns null when absent so usage rolls up under "Untagged".
*
* Distinct from the Google Cloud project id handled by
* getProjectIdForConnection — this is a free-form usage-grouping label.
* @param {Request} request - Incoming web Request exposing a headers map
* @returns {string|null} Trimmed, length-capped project tag, or null
*/
export function extractProjectTag(request) {
const rawProjectTag = request.headers.get("x-project") || request.headers.get("x-project-id");
if (!rawProjectTag) return null;

const projectTag = rawProjectTag.trim().slice(0, MAX_PROJECT_TAG_LENGTH);
return projectTag.length > 0 ? projectTag : null;
}

/**
* Validate API key (optional - for local use can skip)
*/
Expand Down