Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Usage statistics query.
|
||||
*
|
||||
* Cost is **computed in real time**: usage_records only stores Tokens (pricing may
|
||||
* be added later), so at query time each Model's cost is converted using the
|
||||
* current Project's configured pricing — the repo returns raw Token totals broken
|
||||
* down by `(provider, model_id)` paired reference, and this service looks up each
|
||||
* reference's price once and folds it into cost / hasUncosted (if a Model has no
|
||||
* pricing, its consumption is excluded from cost and hasUncosted is flagged).
|
||||
* Summary cards (today / last 7 days / cumulative), grouped aggregation (date /
|
||||
* agent / model / session, with the session dimension supporting agentId drill-down
|
||||
* filtering), and a 30-day trend.
|
||||
* Server-side error statistics (error_records) ride along on the same response:
|
||||
* the statistics center fetches everything in one request, and filters are
|
||||
* naturally shared; unattributed errors (login failures, process crashes, and other
|
||||
* errors with no Project context) are visible only to admins, see the ErrorsRepo
|
||||
* file header.
|
||||
*/
|
||||
import type {
|
||||
UsageBucket,
|
||||
UsageErrors,
|
||||
UsageGroupBy,
|
||||
UsageGroupRow,
|
||||
UsageResponse,
|
||||
} from "../api/types.js";
|
||||
import type { ErrorFilter, ErrorsRepo } from "../db/repos/errors.js";
|
||||
import type {
|
||||
UsageRepo,
|
||||
UsageModelSums,
|
||||
UsageGroupModelSums,
|
||||
UsageFilter,
|
||||
} from "../db/repos/usage.js";
|
||||
import { formatLocalDate, localDateMinusDays } from "../internal/dates.js";
|
||||
|
||||
/** Number of most-recent entries kept in the error detail table. */
|
||||
const ERROR_RECENT_N = 20;
|
||||
|
||||
/** The three pricing buckets (usd_per_mtok convention), returned by the pricing lookup callback. */
|
||||
export interface PricingRates {
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
export type PricingLookup = (
|
||||
projectId: string,
|
||||
provider: string,
|
||||
modelId: string,
|
||||
) => Promise<PricingRates | undefined>;
|
||||
|
||||
export interface UsageQuery {
|
||||
from?: string;
|
||||
to?: string;
|
||||
groupBy: UsageGroupBy;
|
||||
/** Top-level filter: view by Agent (also used for groupBy=session drill-down). */
|
||||
agentId?: string;
|
||||
/** Top-level filter: view by Model (paired with modelId; the dropdown always sends them as a pair). */
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
/** Whether to include unattributed errors: admin only (the route passes user.isAdmin), defaults to false. */
|
||||
includeGlobalErrors?: boolean;
|
||||
}
|
||||
|
||||
/** Cost formula: sum of the three buckets, in USD per million Tokens. */
|
||||
function costOf(sums: UsageModelSums, rates: PricingRates): number {
|
||||
return (
|
||||
(sums.cacheRead * rates.cacheRead +
|
||||
sums.cacheWrite * rates.cacheWrite +
|
||||
sums.output * rates.output) /
|
||||
1e6
|
||||
);
|
||||
}
|
||||
|
||||
/** In-process Map key for a paired reference (\0-separated, the same style as session-manager's agentKey; never persisted). */
|
||||
function refKey(provider: string, modelId: string): string {
|
||||
return `${provider}\0${modelId}`;
|
||||
}
|
||||
|
||||
export class UsageService {
|
||||
constructor(
|
||||
private readonly usage: UsageRepo,
|
||||
private readonly errors: ErrorsRepo,
|
||||
private readonly lookupPricing: PricingLookup,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async query(projectId: string, q: UsageQuery): Promise<UsageResponse> {
|
||||
const today = formatLocalDate(this.now());
|
||||
// Top-level filter: agent + model (the cost center switches views by agent/model; the model filter is always sent as a pair).
|
||||
const base: UsageFilter = {};
|
||||
if (q.agentId !== undefined) base.agentId = q.agentId;
|
||||
if (q.provider !== undefined) base.provider = q.provider;
|
||||
if (q.modelId !== undefined) base.modelId = q.modelId;
|
||||
|
||||
const win = (from?: string, to?: string): UsageFilter => ({
|
||||
...base,
|
||||
...(from !== undefined ? { from } : {}),
|
||||
...(to !== undefined ? { to } : {}),
|
||||
});
|
||||
|
||||
const todayRows = this.usage.bucketByModel(projectId, win(today, today));
|
||||
const last7dRows = this.usage.bucketByModel(projectId, win(localDateMinusDays(this.now(), 6)));
|
||||
const totalRows = this.usage.bucketByModel(projectId, win(q.from, q.to));
|
||||
const groupRows = this.usage.groupsByModel(projectId, q.groupBy, win(q.from, q.to));
|
||||
// Fixed 30-day window; affected by the agent/model filter.
|
||||
const trendFrom = localDateMinusDays(this.now(), 29);
|
||||
const trendRows = this.usage.groupsByModel(projectId, "date", win(trendFrom));
|
||||
// Agent call-count chart: not affected by the agent filter (shows all agents), but still affected by the date + model filter.
|
||||
const agentRows = this.usage.groupsByModel(projectId, "agent", {
|
||||
...(q.provider !== undefined ? { provider: q.provider } : {}),
|
||||
...(q.modelId !== undefined ? { modelId: q.modelId } : {}),
|
||||
...(q.from !== undefined ? { from: q.from } : {}),
|
||||
...(q.to !== undefined ? { to: q.to } : {}),
|
||||
});
|
||||
// Model success-rate chart: not affected by the model filter (shows all models), but still affected by the date + agent filter.
|
||||
const statusRows = this.usage.statusByModel(projectId, {
|
||||
...(q.agentId !== undefined ? { agentId: q.agentId } : {}),
|
||||
...(q.from !== undefined ? { from: q.from } : {}),
|
||||
...(q.to !== undefined ? { to: q.to } : {}),
|
||||
});
|
||||
// Error statistics: likewise not affected by the model filter (HTTP / process errors have no Model dimension), but still affected by the date + agent filter.
|
||||
const errorFilter: ErrorFilter = {
|
||||
...(q.agentId !== undefined ? { agentId: q.agentId } : {}),
|
||||
...(q.from !== undefined ? { from: q.from } : {}),
|
||||
...(q.to !== undefined ? { to: q.to } : {}),
|
||||
// Unattributed errors are visible only to admins (regular members only see errors within their own Project, see the ErrorsRepo file header).
|
||||
...(q.includeGlobalErrors === true ? { includeGlobal: true } : {}),
|
||||
};
|
||||
|
||||
// Each paired reference that occurs is looked up for its current price only once.
|
||||
const rates = new Map<string, PricingRates | undefined>();
|
||||
const allRefs = new Map<string, { provider: string; modelId: string }>();
|
||||
for (const r of [...todayRows, ...last7dRows, ...totalRows, ...groupRows, ...trendRows]) {
|
||||
allRefs.set(refKey(r.provider, r.modelId), { provider: r.provider, modelId: r.modelId });
|
||||
}
|
||||
for (const [key, ref] of allRefs) {
|
||||
rates.set(key, await this.lookupPricing(projectId, ref.provider, ref.modelId));
|
||||
}
|
||||
|
||||
const byAgentMap = new Map<string, { requests: number; total: number }>();
|
||||
for (const r of agentRows) {
|
||||
const acc = byAgentMap.get(r.key) ?? { requests: 0, total: 0 };
|
||||
acc.requests += r.requests;
|
||||
acc.total += r.total;
|
||||
byAgentMap.set(r.key, acc);
|
||||
}
|
||||
|
||||
return {
|
||||
summary: {
|
||||
today: this.foldBucket(todayRows, rates),
|
||||
last7d: this.foldBucket(last7dRows, rates),
|
||||
total: this.foldBucket(totalRows, rates),
|
||||
},
|
||||
groupBy: q.groupBy,
|
||||
groups: this.foldGroups(groupRows, rates, q.groupBy),
|
||||
trend: this.foldTrend(trendRows, rates),
|
||||
byAgent: [...byAgentMap.entries()]
|
||||
.map(([agentId, v]) => ({ agentId, requests: v.requests, total: v.total }))
|
||||
.sort((a, b) => b.requests - a.requests),
|
||||
success: statusRows.sort((a, b) => b.total - a.total),
|
||||
errors: this.foldErrors(projectId, errorFilter),
|
||||
agentIds: this.usage.distinctAgentIds(projectId),
|
||||
models: this.usage.distinctModels(projectId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Error statistics: summary info (total / unexpected / most common error code) + the last N entries, all filtered by the selected range. */
|
||||
private foldErrors(projectId: string, f: ErrorFilter): UsageErrors {
|
||||
const { total, unexpected } = this.errors.summary(projectId, f);
|
||||
return {
|
||||
total,
|
||||
unexpected,
|
||||
topCode: this.errors.topCode(projectId, f),
|
||||
recent: this.errors.recent(projectId, f, ERROR_RECENT_N),
|
||||
};
|
||||
}
|
||||
|
||||
private foldBucket(
|
||||
rows: UsageModelSums[],
|
||||
rates: Map<string, PricingRates | undefined>,
|
||||
): UsageBucket {
|
||||
let total = 0;
|
||||
let requests = 0;
|
||||
let cost: number | null = null;
|
||||
let hasUncosted = false;
|
||||
for (const r of rows) {
|
||||
total += r.total;
|
||||
requests += r.requests;
|
||||
const rate = rates.get(refKey(r.provider, r.modelId));
|
||||
if (rate) cost = (cost ?? 0) + costOf(r, rate);
|
||||
else hasUncosted = true;
|
||||
}
|
||||
return { total, requests, cost, hasUncosted };
|
||||
}
|
||||
|
||||
private foldGroups(
|
||||
rows: UsageGroupModelSums[],
|
||||
rates: Map<string, PricingRates | undefined>,
|
||||
groupBy: UsageGroupBy,
|
||||
): UsageGroupRow[] {
|
||||
// The model dimension folds by paired reference (a shared model_id name across providers is split into separate rows); other dimensions fold by their group key.
|
||||
const keyOf = (r: UsageGroupModelSums): string =>
|
||||
groupBy === "model" ? refKey(r.provider, r.key) : r.key;
|
||||
const byKey = new Map<string, UsageGroupRow>();
|
||||
for (const r of rows) {
|
||||
const acc = byKey.get(keyOf(r)) ?? {
|
||||
key: r.key,
|
||||
...(groupBy === "model" ? { provider: r.provider } : {}),
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
requests: 0,
|
||||
cost: null as number | null,
|
||||
hasUncosted: false,
|
||||
};
|
||||
acc.cacheRead += r.cacheRead;
|
||||
acc.cacheWrite += r.cacheWrite;
|
||||
acc.output += r.output;
|
||||
acc.total += r.total;
|
||||
acc.requests += r.requests;
|
||||
const rate = rates.get(refKey(r.provider, r.modelId));
|
||||
if (rate) acc.cost = (acc.cost ?? 0) + costOf(r, rate);
|
||||
else acc.hasUncosted = true;
|
||||
byKey.set(keyOf(r), acc);
|
||||
}
|
||||
const out = [...byKey.values()];
|
||||
// The date dimension sorts by key descending (most recent first); other dimensions sort by total Token count descending.
|
||||
if (groupBy === "date") out.sort((a, b) => b.key.localeCompare(a.key));
|
||||
else out.sort((a, b) => b.total - a.total);
|
||||
return out;
|
||||
}
|
||||
|
||||
private foldTrend(
|
||||
rows: UsageGroupModelSums[],
|
||||
rates: Map<string, PricingRates | undefined>,
|
||||
): UsageResponse["trend"] {
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{ total: number; cacheRead: number; cacheWrite: number; output: number; cost: number | null }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
const acc = byDate.get(r.key) ?? {
|
||||
total: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
output: 0,
|
||||
cost: null,
|
||||
};
|
||||
acc.total += r.total;
|
||||
acc.cacheRead += r.cacheRead;
|
||||
acc.cacheWrite += r.cacheWrite;
|
||||
acc.output += r.output;
|
||||
const rate = rates.get(refKey(r.provider, r.modelId));
|
||||
if (rate) acc.cost = (acc.cost ?? 0) + costOf(r, rate);
|
||||
byDate.set(r.key, acc);
|
||||
}
|
||||
return [...byDate.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([date, v]) => ({
|
||||
date,
|
||||
total: v.total,
|
||||
cacheRead: v.cacheRead,
|
||||
cacheWrite: v.cacheWrite,
|
||||
output: v.output,
|
||||
cost: v.cost,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user