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"} + + ) : ( + + )}