Skip to content

Commit 2979328

Browse files
ersinkocclaude
andcommitted
✨ feat(analytics): add billing overview with subscription vs API breakdown
New GET /costs/subscriptions endpoint: - Lists all subscription providers with monthly cost + plan name - Returns counts by billing type (payPerUse, subscription, free) - Returns totalMonthlyUsd for all subscriptions combined Analytics page gains "Billing Overview" section (between KPIs and charts): - Donut chart: pay-per-use vs subscription vs free provider distribution - Big number: total monthly subscription cost - Subscription list: provider name + plan + $/mo Shows only when subscriptions or free providers are configured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e625f40 commit 2979328

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

packages/gateway/src/routes/costs.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,81 @@ costRoutes.get('/', async (c) => {
101101
});
102102
});
103103

104+
/**
105+
* GET /costs/subscriptions - Get subscription provider costs
106+
*/
107+
costRoutes.get('/subscriptions', async (c) => {
108+
try {
109+
const userId = getUserId(c) ?? 'default';
110+
const { ModelConfigsRepository } = await import('../db/repositories/model-configs.js');
111+
const repo = new ModelConfigsRepository();
112+
113+
// Get all provider configs with billing info
114+
const configs = await repo.listUserProviderConfigs(userId);
115+
const customProviders = await repo.listProviders(userId);
116+
117+
const subscriptions: Array<{
118+
providerId: string;
119+
displayName: string;
120+
billingType: string;
121+
monthlyCostUsd: number;
122+
planName?: string;
123+
}> = [];
124+
125+
let totalMonthly = 0;
126+
let freeCount = 0;
127+
let apiCount = 0;
128+
129+
// Built-in provider overrides
130+
for (const cfg of configs) {
131+
if (cfg.billingType === 'subscription' && cfg.subscriptionCostUsd) {
132+
subscriptions.push({
133+
providerId: cfg.providerId,
134+
displayName: cfg.subscriptionPlan || cfg.providerId,
135+
billingType: 'subscription',
136+
monthlyCostUsd: cfg.subscriptionCostUsd,
137+
planName: cfg.subscriptionPlan,
138+
});
139+
totalMonthly += cfg.subscriptionCostUsd;
140+
} else if (cfg.billingType === 'free') {
141+
freeCount++;
142+
} else {
143+
apiCount++;
144+
}
145+
}
146+
147+
// Custom providers
148+
for (const cp of customProviders) {
149+
if (cp.billingType === 'subscription' && cp.subscriptionCostUsd) {
150+
subscriptions.push({
151+
providerId: cp.providerId,
152+
displayName: cp.subscriptionPlan || cp.displayName,
153+
billingType: 'subscription',
154+
monthlyCostUsd: cp.subscriptionCostUsd,
155+
planName: cp.subscriptionPlan,
156+
});
157+
totalMonthly += cp.subscriptionCostUsd;
158+
} else if (cp.billingType === 'free') {
159+
freeCount++;
160+
} else {
161+
apiCount++;
162+
}
163+
}
164+
165+
return apiResponse(c, {
166+
subscriptions,
167+
totalMonthlyUsd: Math.round(totalMonthly * 100) / 100,
168+
counts: {
169+
subscription: subscriptions.length,
170+
payPerUse: apiCount,
171+
free: freeCount,
172+
},
173+
});
174+
} catch (err) {
175+
return apiError(c, { code: ERROR_CODES.INTERNAL_ERROR, message: getErrorMessage(err) }, 500);
176+
}
177+
});
178+
104179
/**
105180
* GET /costs/usage - Get usage stats for UI dashboard
106181
*/

packages/ui/src/api/endpoints/summary.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,16 @@ export const costsApi = {
2727
}),
2828
setBudget: (budget: { dailyLimit?: number; weeklyLimit?: number; monthlyLimit?: number }) =>
2929
apiClient.post<{ status: BudgetStatus }>('/costs/budget', budget),
30+
getSubscriptions: () =>
31+
apiClient.get<{
32+
subscriptions: Array<{
33+
providerId: string;
34+
displayName: string;
35+
billingType: string;
36+
monthlyCostUsd: number;
37+
planName?: string;
38+
}>;
39+
totalMonthlyUsd: number;
40+
counts: { subscription: number; payPerUse: number; free: number };
41+
}>('/costs/subscriptions'),
3042
};

packages/ui/src/pages/AnalyticsPage.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,16 @@ export function AnalyticsPage() {
374374
fleets: 0,
375375
workflows: 0,
376376
});
377+
const [subscriptionData, setSubscriptionData] = useState<{
378+
subscriptions: Array<{
379+
providerId: string;
380+
displayName: string;
381+
monthlyCostUsd: number;
382+
planName?: string;
383+
}>;
384+
totalMonthlyUsd: number;
385+
counts: { subscription: number; payPerUse: number; free: number };
386+
} | null>(null);
377387

378388
const fetchAll = useCallback(
379389
async (showRefresh = false) => {
@@ -389,6 +399,7 @@ export function AnalyticsPage() {
389399
bgRes,
390400
fleetsRes,
391401
wfRes,
402+
subsRes,
392403
] = await Promise.allSettled([
393404
costsApi.usage(),
394405
costsApi.getBreakdown(period),
@@ -398,6 +409,7 @@ export function AnalyticsPage() {
398409
backgroundAgentsApi.list(),
399410
fleetApi.list(),
400411
workflowsApi.list(),
412+
costsApi.getSubscriptions(),
401413
]);
402414

403415
if (usageRes.status === 'fulfilled') setUsage(usageRes.value);
@@ -422,6 +434,8 @@ export function AnalyticsPage() {
422434
fleets: count(fleetsRes),
423435
workflows: count(wfRes),
424436
});
437+
438+
if (subsRes.status === 'fulfilled') setSubscriptionData(subsRes.value);
425439
} finally {
426440
setIsLoading(false);
427441
setIsRefreshing(false);
@@ -627,6 +641,78 @@ export function AnalyticsPage() {
627641
/>
628642
</div>
629643

644+
{/* ---- Billing Overview ---- */}
645+
{subscriptionData &&
646+
(subscriptionData.subscriptions.length > 0 || subscriptionData.counts.free > 0) && (
647+
<SectionCard title="Billing Overview" icon={DollarSign} iconColor="text-violet-500">
648+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
649+
{/* Billing type distribution */}
650+
<div className="flex items-center gap-4">
651+
<div className="w-24 h-24 flex-shrink-0">
652+
<MiniDonut
653+
data={[
654+
{ name: 'Pay-per-use', value: subscriptionData.counts.payPerUse },
655+
{ name: 'Subscription', value: subscriptionData.counts.subscription },
656+
{ name: 'Free', value: subscriptionData.counts.free },
657+
].filter((d) => d.value > 0)}
658+
colors={['#3b82f6', '#8b5cf6', '#22c55e']}
659+
/>
660+
</div>
661+
<DonutLegend
662+
data={[
663+
{ name: 'Pay-per-use (API)', value: subscriptionData.counts.payPerUse },
664+
{ name: 'Subscription', value: subscriptionData.counts.subscription },
665+
{ name: 'Free', value: subscriptionData.counts.free },
666+
].filter((d) => d.value > 0)}
667+
colors={['#3b82f6', '#8b5cf6', '#22c55e']}
668+
/>
669+
</div>
670+
671+
{/* Monthly subscription total */}
672+
<div className="flex flex-col justify-center items-center">
673+
<p className="text-xs text-text-muted dark:text-dark-text-muted uppercase tracking-wider mb-1">
674+
Monthly Subscriptions
675+
</p>
676+
<p className="text-3xl font-bold text-violet-500">
677+
${subscriptionData.totalMonthlyUsd.toFixed(2)}
678+
</p>
679+
<p className="text-xs text-text-muted dark:text-dark-text-muted mt-1">
680+
{subscriptionData.subscriptions.length} subscription
681+
{subscriptionData.subscriptions.length !== 1 ? 's' : ''}
682+
</p>
683+
</div>
684+
685+
{/* Subscription list */}
686+
<div className="space-y-2">
687+
{subscriptionData.subscriptions.length > 0 ? (
688+
subscriptionData.subscriptions.map((sub) => (
689+
<div
690+
key={sub.providerId}
691+
className="flex items-center justify-between p-2 rounded-lg bg-bg-tertiary dark:bg-dark-bg-tertiary"
692+
>
693+
<div>
694+
<p className="text-sm font-medium text-text-primary dark:text-dark-text-primary">
695+
{sub.displayName}
696+
</p>
697+
{sub.planName && (
698+
<p className="text-[10px] text-text-muted dark:text-dark-text-muted">
699+
{sub.planName}
700+
</p>
701+
)}
702+
</div>
703+
<p className="text-sm font-bold text-violet-500">${sub.monthlyCostUsd}/mo</p>
704+
</div>
705+
))
706+
) : (
707+
<p className="text-xs text-text-muted dark:text-dark-text-muted text-center py-4">
708+
No subscriptions configured
709+
</p>
710+
)}
711+
</div>
712+
</div>
713+
</SectionCard>
714+
)}
715+
630716
{/* ---- Row 2: Cost Trend + Token Volume ---- */}
631717
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
632718
<SectionCard title="Cost Trend" icon={TrendingUp} iconColor="text-indigo-500">

0 commit comments

Comments
 (0)