Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: Fix budget incident resolution edge cases Fix agent budget tab routing Fix budget auth and monthly spend rollups Harden budget enforcement and migration startup Add budget tabs and sidebar budget indicators feat(costs): add billing, quota, and budget control plane refactor(quota): move provider quota logic into adapter layer, add unit tests fix(costs): replace non-null map assertions with nullish coalescing, clarify weekData guard fix(costs): guard byProject against duplicate null keys, memoize ProviderQuotaCard row aggregations fix(costs): align byAgent run filter to startedAt, tighten providerTabItems memo deps, stabilize byProject row keys feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows fix(costs): guard routes, fix DST ranges, sync provider state, wire live updates feat(costs): consolidate /usage into /costs with Spend + Providers tabs feat(usage): add subscription quota windows per provider on /usage page address greptile review: per-provider deficit notch, startedAt filter, weekRange refresh, deduplicate providerDisplayName feat(ui): add resource and usage dashboard (/usage route) # Conflicts: # packages/db/src/migration-runtime.ts # packages/db/src/migrations/meta/0031_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
@@ -9,6 +9,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"goals",
|
||||
"approvals",
|
||||
"costs",
|
||||
"usage",
|
||||
"activity",
|
||||
"inbox",
|
||||
"design-guide",
|
||||
|
||||
@@ -167,6 +167,12 @@ const dashboard: DashboardSummary = {
|
||||
monthUtilizationPercent: 90,
|
||||
},
|
||||
pendingApprovals: 1,
|
||||
budgets: {
|
||||
activeIncidents: 0,
|
||||
pendingApprovals: 0,
|
||||
pausedAgents: 0,
|
||||
pausedProjects: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe("inbox helpers", () => {
|
||||
|
||||
@@ -49,6 +49,9 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
},
|
||||
budgets: {
|
||||
overview: (companyId: string) => ["budgets", "overview", companyId] as const,
|
||||
},
|
||||
approvals: {
|
||||
list: (companyId: string, status?: string) =>
|
||||
["approvals", companyId, status] as const,
|
||||
@@ -77,6 +80,22 @@ export const queryKeys = {
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
costs: (companyId: string, from?: string, to?: string) =>
|
||||
["costs", companyId, from, to] as const,
|
||||
usageByProvider: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-provider", companyId, from, to] as const,
|
||||
usageByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["usage-by-biller", companyId, from, to] as const,
|
||||
financeSummary: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-summary", companyId, from, to] as const,
|
||||
financeByBiller: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-biller", companyId, from, to] as const,
|
||||
financeByKind: (companyId: string, from?: string, to?: string) =>
|
||||
["finance-by-kind", companyId, from, to] as const,
|
||||
financeEvents: (companyId: string, from?: string, to?: string, limit: number = 100) =>
|
||||
["finance-events", companyId, from, to, limit] as const,
|
||||
usageWindowSpend: (companyId: string) =>
|
||||
["usage-window-spend", companyId] as const,
|
||||
usageQuotaWindows: (companyId: string) =>
|
||||
["usage-quota-windows", companyId] as const,
|
||||
heartbeats: (companyId: string, agentId?: string) =>
|
||||
["heartbeats", companyId, agentId] as const,
|
||||
runDetail: (runId: string) => ["heartbeat-run", runId] as const,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { deriveAgentUrlKey, deriveProjectUrlKey } from "@paperclipai/shared";
|
||||
import type { BillingType, FinanceDirection, FinanceEventKind } from "@paperclipai/shared";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -48,6 +49,98 @@ export function formatTokens(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** Map a raw provider slug to a display-friendly name. */
|
||||
export function providerDisplayName(provider: string): string {
|
||||
const map: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
openrouter: "OpenRouter",
|
||||
chatgpt: "ChatGPT",
|
||||
google: "Google",
|
||||
cursor: "Cursor",
|
||||
jetbrains: "JetBrains AI",
|
||||
};
|
||||
return map[provider.toLowerCase()] ?? provider;
|
||||
}
|
||||
|
||||
export function billingTypeDisplayName(billingType: BillingType): string {
|
||||
const map: Record<BillingType, string> = {
|
||||
metered_api: "Metered API",
|
||||
subscription_included: "Subscription",
|
||||
subscription_overage: "Subscription overage",
|
||||
credits: "Credits",
|
||||
fixed: "Fixed",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
return map[billingType];
|
||||
}
|
||||
|
||||
export function quotaSourceDisplayName(source: string): string {
|
||||
const map: Record<string, string> = {
|
||||
"anthropic-oauth": "Anthropic OAuth",
|
||||
"claude-cli": "Claude CLI",
|
||||
"codex-rpc": "Codex app server",
|
||||
"codex-wham": "ChatGPT WHAM",
|
||||
};
|
||||
return map[source] ?? source;
|
||||
}
|
||||
|
||||
function coerceBillingType(value: unknown): BillingType | null {
|
||||
if (
|
||||
value === "metered_api" ||
|
||||
value === "subscription_included" ||
|
||||
value === "subscription_overage" ||
|
||||
value === "credits" ||
|
||||
value === "fixed" ||
|
||||
value === "unknown"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRunCostUsd(payload: Record<string, unknown> | null): number {
|
||||
if (!payload) return 0;
|
||||
for (const key of ["costUsd", "cost_usd", "total_cost_usd"] as const) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function visibleRunCostUsd(
|
||||
usage: Record<string, unknown> | null,
|
||||
result: Record<string, unknown> | null = null,
|
||||
): number {
|
||||
const billingType = coerceBillingType(usage?.billingType) ?? coerceBillingType(result?.billingType);
|
||||
if (billingType === "subscription_included") return 0;
|
||||
return readRunCostUsd(usage) || readRunCostUsd(result);
|
||||
}
|
||||
|
||||
export function financeEventKindDisplayName(eventKind: FinanceEventKind): string {
|
||||
const map: Record<FinanceEventKind, string> = {
|
||||
inference_charge: "Inference charge",
|
||||
platform_fee: "Platform fee",
|
||||
credit_purchase: "Credit purchase",
|
||||
credit_refund: "Credit refund",
|
||||
credit_expiry: "Credit expiry",
|
||||
byok_fee: "BYOK fee",
|
||||
gateway_overhead: "Gateway overhead",
|
||||
log_storage_charge: "Log storage",
|
||||
logpush_charge: "Logpush",
|
||||
provisioned_capacity_charge: "Provisioned capacity",
|
||||
training_charge: "Training",
|
||||
custom_model_import_charge: "Custom model import",
|
||||
custom_model_storage_charge: "Custom model storage",
|
||||
manual_adjustment: "Manual adjustment",
|
||||
};
|
||||
return map[eventKind];
|
||||
}
|
||||
|
||||
export function financeDirectionDisplayName(direction: FinanceDirection): string {
|
||||
return direction === "credit" ? "Credit" : "Debit";
|
||||
}
|
||||
|
||||
/** Build an issue URL using the human-readable identifier when available. */
|
||||
export function issueUrl(issue: { id: string; identifier?: string | null }): string {
|
||||
return `/issues/${issue.identifier ?? issue.id}`;
|
||||
|
||||
Reference in New Issue
Block a user