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:
Sai Shankar
2026-03-08 20:56:13 +05:30
committed by Dotta
parent db20f4f46e
commit 9d21380699
9 changed files with 192 additions and 56 deletions

View File

@@ -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);

View File

@@ -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`;

View File

@@ -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"),