feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries
- add byAgentModel endpoint and expandable per-agent model sub-rows in the spend tab - validate date range inputs with isNaN + badRequest to return HTTP 400 on bad input - move CostByProject from a local api/costs.ts definition into packages/shared types - gate providerData query on mainTab === providers, consistent with weekData/windowData/quotaData - fix byProject range filter from finishedAt to startedAt, consistent with byProvider runs query - fix WHAM used_percent threshold from <= 1 to < 1 to avoid misclassifying 1% usage as 100% - replace inline opacity style with tailwind bg-primary/85 class in ProviderQuotaCard - reset expandedAgents set when company or date range changes - sort agent model sub-rows by cost descending in ui memo
This commit is contained in:
@@ -5,6 +5,7 @@ import { validate } from "../middleware/validate.js";
|
||||
import { costService, companyService, agentService, logActivity } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
export function costRoutes(db: Db) {
|
||||
const router = Router();
|
||||
@@ -42,8 +43,12 @@ export function costRoutes(db: Db) {
|
||||
});
|
||||
|
||||
function parseDateRange(query: Record<string, unknown>) {
|
||||
const from = query.from ? new Date(query.from as string) : undefined;
|
||||
const to = query.to ? new Date(query.to as string) : undefined;
|
||||
const fromRaw = query.from as string | undefined;
|
||||
const toRaw = query.to as string | undefined;
|
||||
const from = fromRaw ? new Date(fromRaw) : undefined;
|
||||
const to = toRaw ? new Date(toRaw) : undefined;
|
||||
if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date");
|
||||
if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date");
|
||||
return (from || to) ? { from, to } : undefined;
|
||||
}
|
||||
|
||||
@@ -63,6 +68,14 @@ export function costRoutes(db: Db) {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const range = parseDateRange(req.query);
|
||||
const rows = await costs.byAgentModel(companyId, range);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -268,6 +268,32 @@ export function costService(db: Db) {
|
||||
return results.flat();
|
||||
},
|
||||
|
||||
byAgentModel: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
// single query: group by agent + provider + model.
|
||||
// the (companyId, agentId, occurredAt) composite index covers this well.
|
||||
// order by provider + model for stable db-level ordering; cost-desc sort
|
||||
// within each agent's sub-rows is done client-side in the ui memo.
|
||||
return db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
provider: costEvents.provider,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, costEvents.provider, costEvents.model)
|
||||
.orderBy(costEvents.provider, costEvents.model);
|
||||
},
|
||||
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
const runProjectLinks = db
|
||||
@@ -295,8 +321,8 @@ export function costService(db: Db) {
|
||||
.as("run_project_links");
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(heartbeatRuns.finishedAt, range.from));
|
||||
if (range?.to) conditions.push(lte(heartbeatRuns.finishedAt, range.to));
|
||||
if (range?.from) conditions.push(gte(heartbeatRuns.startedAt, range.from));
|
||||
if (range?.to) conditions.push(lte(heartbeatRuns.startedAt, range.to));
|
||||
|
||||
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
|
||||
|
||||
|
||||
@@ -188,10 +188,11 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
|
||||
const rateLimit = body.rate_limit;
|
||||
if (rateLimit?.primary_window != null) {
|
||||
const w = rateLimit.primary_window;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent = rawPct != null
|
||||
? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct))
|
||||
? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct))
|
||||
: null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Primary"),
|
||||
@@ -202,10 +203,11 @@ async function fetchCodexQuota(token: string, accountId: string | null): Promise
|
||||
}
|
||||
if (rateLimit?.secondary_window != null) {
|
||||
const w = rateLimit.secondary_window;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent = rawPct != null
|
||||
? Math.min(100, Math.round(rawPct <= 1 ? rawPct * 100 : rawPct))
|
||||
? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct))
|
||||
: null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
|
||||
|
||||
Reference in New Issue
Block a user