From af648701f404d7edfeca3b586e3377becde3ea08 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 04:57:54 +0000 Subject: [PATCH] feat: exclude API-billed instances from federated usage totals Across Instances combined totals assume every machine rides the viewer's subscriptions. A per-row Subscriptions toggle marks an instance as paying API rates so it stays listed but drops out of the combined figures. The choice is machine-local (settings.usageApiBilledInstanceIds) and does not ride the usage digest. --- .../src/components/usage/FleetUsageCard.jsx | 142 +++++++++++++++--- .../components/usage/FleetUsageCard.test.jsx | 53 ++++++- client/src/pages/UsagePage.jsx | 2 +- client/src/services/README.md | 2 +- client/src/services/apiSystem.js | 9 ++ .../2026-09-01-federated-usage-metrics.md | 7 + server/lib/apiRouteCatalog.generated.json | 12 +- server/lib/validation.js | 16 ++ server/routes/settings.js | 31 +++- server/routes/usage.js | 15 +- server/routes/usage.test.js | 39 ++++- server/services/peerUsage.js | 46 ++++-- server/services/peerUsage.test.js | 15 ++ server/services/usageFleetBilling.js | 82 ++++++++++ server/services/usageFleetBilling.test.js | 68 +++++++++ 15 files changed, 490 insertions(+), 49 deletions(-) create mode 100644 server/services/usageFleetBilling.js create mode 100644 server/services/usageFleetBilling.test.js diff --git a/client/src/components/usage/FleetUsageCard.jsx b/client/src/components/usage/FleetUsageCard.jsx index 0371a6ed71..0a3a13afbb 100644 --- a/client/src/components/usage/FleetUsageCard.jsx +++ b/client/src/components/usage/FleetUsageCard.jsx @@ -1,7 +1,50 @@ +import { useState } from 'react'; import { Network } from 'lucide-react'; import Pill from '../ui/Pill'; +import ToggleSwitch from '../ToggleSwitch'; +import toast from '../ui/Toast'; +import * as api from '../../services/api'; import { formatCompactCountOrDash as formatNumber, formatUsd, timeAgo } from '../../utils/formatters'; +/** + * Overlay the viewer's in-flight Subscriptions toggles onto a fleet payload + * and re-sum the combined total from the rows that still count. Used by the + * card so a click updates the numbers immediately, and by the tests so they + * can assert the same math the UI shows. + */ +export function applyFleetBilling(fleet, overrides = {}) { + const instances = (fleet?.instances || []).map((row) => { + const usesSubscriptions = Object.hasOwn(overrides, row.instanceId) + ? overrides[row.instanceId] + : row.usesSubscriptions !== false; + return { ...row, usesSubscriptions }; + }); + const included = instances.filter((row) => row.usesSubscriptions); + const totals = included.reduce((acc, r) => { + for (const [field, value] of Object.entries(r.totals || {})) { + if (typeof value === 'number') acc[field] = (acc[field] || 0) + value; + } + return acc; + }, {}); + totals.estimatedCost = Math.round((totals.estimatedCost || 0) * 100) / 100; + return { instances, totals, includedCount: included.length }; +} + +function BillingToggle({ row, disabled, onToggle }) { + const name = row.name || row.instanceId; + return ( + onToggle(row)} + ariaLabel={row.usesSubscriptions + ? `Count ${name} toward subscription totals` + : `Exclude ${name} from subscription totals (pays API rates)`} + /> + ); +} + /** * Per-instance AI usage across the federation, for the same report window the * rest of the page is showing. @@ -11,11 +54,24 @@ import { formatCompactCountOrDash as formatNumber, formatUsd, timeAgo } from '.. * * A peer row is only as fresh as the last sync cycle, so each states when its * digest was captured rather than implying it is live. + * + * Each row has a Subscriptions toggle. Off means this instance pays API rates + * rather than the viewer's plans: the row stays listed but drops out of the + * combined total. The choice is this install's view and is persisted locally. */ -export default function FleetUsageCard({ fleet }) { +export default function FleetUsageCard({ fleet, onSaved }) { + const [overrides, setOverrides] = useState({}); + const [pending, setPending] = useState({}); + const rows = fleet?.instances || []; if (rows.length < 2) return null; + const view = applyFleetBilling(fleet, overrides); + const excludedCount = view.instances.length - view.includedCount; + const combinedLabel = excludedCount > 0 + ? `${view.includedCount} of ${view.instances.length} instances on subscriptions` + : `${view.instances.length} instances combined`; + const label = (row) => (row.self ? This machine : row.capturedAt && ( @@ -24,6 +80,45 @@ export default function FleetUsageCard({ fleet }) { )); + const toggleBilling = async (row) => { + const next = !row.usesSubscriptions; + setOverrides((prev) => ({ ...prev, [row.instanceId]: next })); + setPending((prev) => ({ ...prev, [row.instanceId]: true })); + const ok = await api.updateUsageFleetBilling( + { instanceId: row.instanceId, usesSubscriptions: next }, + { silent: true }, + ).then(() => true).catch((err) => { + toast.error(err?.message || 'Failed to update instance billing'); + return false; + }); + if (!ok) { + // Revert the optimistic overlay; a successful save keeps it so a slow + // refetch can't flash the old combined total back onto the card. + setOverrides((prev) => { + const copy = { ...prev }; + delete copy[row.instanceId]; + return copy; + }); + } else { + await onSaved?.(); + } + setPending((prev) => { + const copy = { ...prev }; + delete copy[row.instanceId]; + return copy; + }); + }; + + const rowClass = (row) => (row.usesSubscriptions ? 'text-white' : 'text-gray-500'); + + const instanceLabel = (row) => ( + <> + {row.name} + {label(row)} + {!row.usesSubscriptions && API billed} + + ); + return (
@@ -34,26 +129,30 @@ export default function FleetUsageCard({ fleet }) {

Every federated instance’s usage over this period, priced the same way. + Turn off Subscriptions on a machine that pays API rates so it stays listed but is left out of the combined total.

-
{formatUsd(fleet.totals?.estimatedCost)}
-
{rows.length} instances combined
+
{formatUsd(view.totals?.estimatedCost)}
+
{combinedLabel}
{/* Mobile view (< sm): card list, so the numbers stay readable without a horizontal scroll — same pairing the cost report uses. */}
- {rows.map((row) => ( -
+ {view.instances.map((row) => ( +
-
- {row.name} - {label(row)} +
+ {instanceLabel(row)}
{formatUsd(row.totals?.estimatedCost)}
+
+ Subscriptions + +
Sessions @@ -74,7 +173,7 @@ export default function FleetUsageCard({ fleet }) { ))}
Fleet total - {formatUsd(fleet.totals?.estimatedCost)} + {formatUsd(view.totals?.estimatedCost)}
@@ -84,6 +183,7 @@ export default function FleetUsageCard({ fleet }) { Instance + Subscriptions Sessions Messages Tokens In @@ -92,11 +192,15 @@ export default function FleetUsageCard({ fleet }) { - {rows.map((row) => ( - + {view.instances.map((row) => ( + - {row.name} - {label(row)} + {instanceLabel(row)} + + +
+ +
{formatNumber(row.totals?.sessions)} {formatNumber(row.totals?.messages)} @@ -108,12 +212,12 @@ export default function FleetUsageCard({ fleet }) { - Fleet total - {formatNumber(fleet.totals?.sessions)} - {formatNumber(fleet.totals?.messages)} - {formatNumber(fleet.totals?.tokensIn)} - {formatNumber(fleet.totals?.tokensOut)} - {formatUsd(fleet.totals?.estimatedCost)} + Fleet total + {formatNumber(view.totals?.sessions)} + {formatNumber(view.totals?.messages)} + {formatNumber(view.totals?.tokensIn)} + {formatNumber(view.totals?.tokensOut)} + {formatUsd(view.totals?.estimatedCost)} diff --git a/client/src/components/usage/FleetUsageCard.test.jsx b/client/src/components/usage/FleetUsageCard.test.jsx index 98610e9efb..325ee8ecc0 100644 --- a/client/src/components/usage/FleetUsageCard.test.jsx +++ b/client/src/components/usage/FleetUsageCard.test.jsx @@ -1,12 +1,16 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import FleetUsageCard from './FleetUsageCard'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import FleetUsageCard, { applyFleetBilling } from './FleetUsageCard'; + +const api = vi.hoisted(() => ({ updateUsageFleetBilling: vi.fn() })); +vi.mock('../../services/api', () => api); const row = (overrides = {}) => ({ instanceId: 'inst-a', name: 'Workshop', self: false, capturedAt: '2026-08-30T09:00:00.000Z', + usesSubscriptions: true, totals: { sessions: 4, messages: 12, tokensIn: 1000, tokensOut: 2000, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCost: 3.5 }, ...overrides, }); @@ -19,6 +23,32 @@ const fleet = { totals: { sessions: 8, messages: 24, tokensIn: 2000, tokensOut: 4000, cacheReadTokens: 0, cacheWriteTokens: 0, estimatedCost: 7 }, }; +beforeEach(() => { + vi.clearAllMocks(); + api.updateUsageFleetBilling.mockResolvedValue({ instanceId: 'inst-peer', usesSubscriptions: false, apiBilledInstanceIds: ['inst-peer'] }); +}); + +describe('applyFleetBilling', () => { + it('drops an overridden API-billed row from the combined total but keeps it listed', () => { + const view = applyFleetBilling(fleet, { 'inst-peer': false }); + expect(view.instances).toHaveLength(2); + expect(view.includedCount).toBe(1); + expect(view.totals.estimatedCost).toBe(3.5); + expect(view.totals.sessions).toBe(4); + }); + + // An older payload that predates the field must still count every row — + // missing is "on subscriptions", never "API billed". + it('treats a missing usesSubscriptions flag as included', () => { + const legacy = { + instances: [row({ usesSubscriptions: undefined }), row({ instanceId: 'inst-peer', name: 'Studio', usesSubscriptions: undefined })], + }; + const view = applyFleetBilling(legacy); + expect(view.includedCount).toBe(2); + expect(view.totals.estimatedCost).toBe(7); + }); +}); + describe('FleetUsageCard', () => { it('renders a row per instance plus the combined total', () => { render(); @@ -41,4 +71,21 @@ describe('FleetUsageCard', () => { const { container: absent } = render(); expect(absent).toBeEmptyDOMElement(); }); + + it('excludes an instance from the combined total when Subscriptions is turned off', async () => { + const onSaved = vi.fn().mockResolvedValue({}); + render(); + + fireEvent.click(screen.getAllByRole('switch', { name: 'Count Studio toward subscription totals' })[0]); + + await waitFor(() => { + expect(api.updateUsageFleetBilling).toHaveBeenCalledWith( + { instanceId: 'inst-peer', usesSubscriptions: false }, + { silent: true }, + ); + }); + expect(screen.getByText('1 of 2 instances on subscriptions')).toBeInTheDocument(); + expect(screen.getAllByText('API billed').length).toBeGreaterThan(0); + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + }); }); diff --git a/client/src/pages/UsagePage.jsx b/client/src/pages/UsagePage.jsx index 02391f04f3..18f99bd3b8 100644 --- a/client/src/pages/UsagePage.jsx +++ b/client/src/pages/UsagePage.jsx @@ -843,7 +843,7 @@ function InternalUsageMetrics() { {/* Same window, split by machine — renders only once a peer's usage has synced, so a single-machine install sees no change. */} - + {/* Directly under the API estimate it is derived from: the estimate is the opportunity cost, this is what the quota plans actually cost to avoid it. */} diff --git a/client/src/services/README.md b/client/src/services/README.md index 5045f62030..4e486dce79 100644 --- a/client/src/services/README.md +++ b/client/src/services/README.md @@ -63,7 +63,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire | `apiSchedules.js` | Automation schedules. | | `apiQuotaBurn.js` | Quota Burn plan + live status, the job-type catalog its config form renders, and manual runs (`getQuotaBurn`/`getQuotaBurnCatalog`/`saveQuotaBurn`/`runQuotaBurn`), plus `rearmQuotaBurn` to put spent `run once` steps back into the rotation. | | `apiRapidReader.js` | Rapid Reader's optional author-hosted Accelerando loader and machine-local shelf API. | -| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison). | +| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). | | `apiAuth.js` | Optional login password — status, login, set/clear password. | | `apiLoops.js` | Scheduled loops. | diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js index aa4e5bee94..b94967a733 100644 --- a/client/src/services/apiSystem.js +++ b/client/src/services/apiSystem.js @@ -170,6 +170,15 @@ export const getUsageBackfillStatus = (options = {}) => request('/usage/backfill // the report (`getUsage().subscriptionSavings`), so there is no getter here. export const updateSubscriptionCosts = (costs, options = {}) => request('/usage/subscriptions', { method: 'PUT', body: JSON.stringify({ costs }), ...options }); +// Mark one federated instance as paying API rates (`usesSubscriptions: false`) +// or riding this install's subscriptions (`true`). The Across Instances +// combined total skips API-billed rows; the row itself stays listed. +export const updateUsageFleetBilling = ({ instanceId, usesSubscriptions }, options = {}) => + request('/usage/fleet-billing', { + method: 'PUT', + body: JSON.stringify({ instanceId, usesSubscriptions }), + ...options, + }); export const startUsageBackfill = (options = {}) => request('/usage/backfill', { method: 'POST', ...options }); // Subscription-quota status for every enabled provider family (claude, codex, diff --git a/docs/decisions/2026-09-01-federated-usage-metrics.md b/docs/decisions/2026-09-01-federated-usage-metrics.md index 744377af04..b6471f7025 100644 --- a/docs/decisions/2026-09-01-federated-usage-metrics.md +++ b/docs/decisions/2026-09-01-federated-usage-metrics.md @@ -143,6 +143,13 @@ excluded on both privacy and payload grounds. peer (`enabled: false`), which stops every direction — checked in `hasAnySyncEnabled`, so it holds on the `peer:online` path too, not only in the polling loop. +- An instance that pays API rates rather than the viewer's subscriptions can be + dropped from the Across Instances **combined total** via a per-row + Subscriptions toggle. The row stays listed (the spend is still real). The + choice is machine-local (`settings.usageApiBilledInstanceIds`) — it is this + install's view of which fleet members ride the plans, not a property of the + instance itself — and never rides the digest. Setting it on one machine does + not change the total another machine shows. - **Removing a peer retires its digest via a tombstone**, not a plain delete. Our snapshot forwards every digest we hold, so a surviving peer would hand a deleted row straight back on the next cycle and a decommissioned machine's diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json index d6511e45f5..a45eafe863 100644 --- a/server/lib/apiRouteCatalog.generated.json +++ b/server/lib/apiRouteCatalog.generated.json @@ -16214,6 +16214,14 @@ "server/routes/usage.js" ] }, + { + "method": "PUT", + "path": "/api/usage/fleet-billing", + "mountPath": "/api/usage", + "sources": [ + "server/routes/usage.js" + ] + }, { "method": "POST", "path": "/api/usage/messages", @@ -17417,8 +17425,8 @@ ], "stats": { "mounts": 147, - "operations": 2157, - "declarations": 2161, + "operations": 2158, + "declarations": 2162, "sourceFiles": 229 } } diff --git a/server/lib/validation.js b/server/lib/validation.js index 3c41d300e1..7d5dd501a0 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -1522,6 +1522,22 @@ export const subscriptionCostsMapSchema = z.partialRecord( /** Body for PUT /api/usage/subscriptions. */ export const subscriptionCostsSchema = z.object({ costs: subscriptionCostsMapSchema }); +/** + * Instances that pay API rates rather than the viewer's subscriptions, so the + * Across Instances combined total can leave them out. Used by BOTH write + * paths — `PUT /api/usage/fleet-billing` (the per-row toggle) and the + * `usageApiBilledInstanceIds` slice of `PUT /api/settings` — so a restore + * dump can't write an unbounded or non-string list through the generic + * settings endpoint. Cap matches the stored peer-digest cap (64). + */ +export const usageApiBilledInstanceIdsSchema = z.array(z.string().min(1).max(200)).max(64); + +/** Body for PUT /api/usage/fleet-billing. */ +export const usageFleetBillingSchema = z.object({ + instanceId: z.string().min(1).max(200), + usesSubscriptions: z.boolean(), +}); + // ============================================================================= // PORTS diff --git a/server/routes/settings.js b/server/routes/settings.js index cf2e96d19a..ac2cf67f4b 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getSettings, updateSettingsWith } from '../services/settings.js'; import { getAiAssignments, updateAiAssignment } from '../services/aiAssignments.js'; import { saveSubscriptionCosts } from '../services/subscriptionCosts.js'; +import { saveApiBilledInstanceIds } from '../services/usageFleetBilling.js'; import { setCodexParallelLimit, CODEX_PARALLEL_MIN, @@ -18,7 +19,7 @@ import { asyncHandler } from '../lib/errorHandler.js'; import { isPlainObject } from '../lib/objects.js'; import { agentContextSettingsSchema } from '../lib/agentContextValidation.js'; import { EFFORT_LEVELS } from '../lib/providerModels.js'; -import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, autofixerSettingsSchema, codeReviewSettingsSchema, locationSettingsSchema, settingsEmbeddingsSchema, localLlmSettingsSchema, openWorldSnapshotConfigSchema, imessageConfigSchema, signalConfigSchema, spotifyConfigSchema, youtubeConfigSchema, apiAccessSettingsSchema, instanceFeatureSettingsSchema, instanceFeatureIdSchema, instanceFeatureUpdateSchema, loraTrainingConfigSchema, pipelineEditorialChecksSettingsSchema, creativeDirectorSettingsSchema, musicSettingsSchema, federationSettingsSchema, privacySettingsSchema, seriesAutopilotSettingsSchema, layeredIntelligenceSettingsSchema, imageGenGrokSettingsSchema, imageGenAgySettingsSchema, renderDefaultsSettingsSchema, videoGenSettingsSchema, subscriptionCostsMapSchema, validateRequest } from '../lib/validation.js'; +import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, autofixerSettingsSchema, codeReviewSettingsSchema, locationSettingsSchema, settingsEmbeddingsSchema, localLlmSettingsSchema, openWorldSnapshotConfigSchema, imessageConfigSchema, signalConfigSchema, spotifyConfigSchema, youtubeConfigSchema, apiAccessSettingsSchema, instanceFeatureSettingsSchema, instanceFeatureIdSchema, instanceFeatureUpdateSchema, loraTrainingConfigSchema, pipelineEditorialChecksSettingsSchema, creativeDirectorSettingsSchema, musicSettingsSchema, federationSettingsSchema, privacySettingsSchema, seriesAutopilotSettingsSchema, layeredIntelligenceSettingsSchema, imageGenGrokSettingsSchema, imageGenAgySettingsSchema, renderDefaultsSettingsSchema, videoGenSettingsSchema, subscriptionCostsMapSchema, usageApiBilledInstanceIdsSchema, validateRequest } from '../lib/validation.js'; const router = Router(); @@ -375,6 +376,11 @@ router.put('/', asyncHandler(async (req, res) => { if (req.body?.subscriptionCosts !== undefined) { validateRequest(subscriptionCostsMapSchema, req.body.subscriptionCosts); } + // Same schema PUT /api/usage/fleet-billing's store uses, so a restore dump + // can't write an unbounded or non-string list through the generic endpoint. + if (req.body?.usageApiBilledInstanceIds !== undefined) { + validateRequest(usageApiBilledInstanceIdsSchema, req.body.usageApiBilledInstanceIds); + } // User-defined catalog types moved out of settings.json into PostgreSQL // (`catalog_user_types`, #1001). The `/api/catalog/types` routes are the only // write path; a `catalogUserTypes` key in a PUT /api/settings body (legacy @@ -387,12 +393,19 @@ router.put('/', asyncHandler(async (req, res) => { // would bypass the current-password proof the /api/auth/password routes // require. Secrets are write-only through their dedicated routes // (/api/auth/password, /api/github/secrets, etc.). - // subscriptionCosts is excluded from the generic shallow spread below and - // routed through the same per-family merge PUT /api/usage/subscriptions - // uses (saveSubscriptionCosts) — a shallow `{ ...current, ...settingsPatch }` - // would replace the whole map, silently dropping any family the incoming - // patch didn't mention. - const { secrets: _ignoredSecrets, catalogUserTypes: _ignoredTypes, subscriptionCosts: subscriptionCostsPatch, ...settingsPatch } = req.body || {}; + // subscriptionCosts and usageApiBilledInstanceIds are excluded from the + // generic shallow spread below and routed through their dedicated savers — + // the same merge PUT /api/usage/subscriptions and PUT /api/usage/fleet-billing + // use — so a restore dump can't persist an unvalidated slice, and a shallow + // `{ ...current, ...settingsPatch }` can't replace a map by dropping keys + // the incoming patch didn't mention. + const { + secrets: _ignoredSecrets, + catalogUserTypes: _ignoredTypes, + subscriptionCosts: subscriptionCostsPatch, + usageApiBilledInstanceIds: apiBilledPatch, + ...settingsPatch + } = req.body || {}; // updateSettingsWith (not updateSettings) so the multi-owner `federation` // slice merges per sub-key and persisted write-only tokens the incoming patch // omits get re-injected — both against the freshest snapshot inside the write @@ -409,6 +422,10 @@ router.put('/', asyncHandler(async (req, res) => { const costs = await saveSubscriptionCosts(subscriptionCostsPatch, { actor: 'user' }); merged = { ...merged, subscriptionCosts: costs }; } + if (apiBilledPatch !== undefined) { + const ids = await saveApiBilledInstanceIds(apiBilledPatch, { actor: 'user' }); + merged = { ...merged, usageApiBilledInstanceIds: ids }; + } // The queue caches codex.parallelLimit in-process; sync it from the // merged value so a save takes effect without a restart and without // re-reading the file. diff --git a/server/routes/usage.js b/server/routes/usage.js index 52ecaf62ea..4b5bbca5dc 100644 --- a/server/routes/usage.js +++ b/server/routes/usage.js @@ -4,9 +4,10 @@ import { getClaudeCodeUsage } from '../services/claudeCodeUsage.js'; import { getProviderQuotas } from '../services/providerUsage.js'; import { getAllProviders } from '../services/providers.js'; import { asyncHandler } from '../lib/errorHandler.js'; -import { validateRequest, usageQuerySchema, usageMessagesSchema, providerUsageQuerySchema, subscriptionCostsSchema } from '../lib/validation.js'; +import { validateRequest, usageQuerySchema, usageMessagesSchema, providerUsageQuerySchema, subscriptionCostsSchema, usageFleetBillingSchema } from '../lib/validation.js'; import { saveSubscriptionCosts, getSubscriptionSavings } from '../services/subscriptionCosts.js'; import { getFleetUsage } from '../services/peerUsage.js'; +import { getApiBilledInstanceIds, setInstanceUsesSubscriptions } from '../services/usageFleetBilling.js'; import { resolveUsageRange } from '../lib/usageRange.js'; import { WAIT } from '../lib/staleWhileRevalidate.js'; import { @@ -41,7 +42,8 @@ router.get('/', asyncHandler(async (req, res) => { // history; every other range already has one, so don't pay the scan. firstActivityDay: from ? null : usage.getFirstActivityDay() }), - getFleetUsage({ from, to, providers }), + getApiBilledInstanceIds().then((apiBilledInstanceIds) => + getFleetUsage({ from, to, providers, apiBilledInstanceIds })), ]); res.json({ ...summary, subscriptionSavings, fleet }); })); @@ -53,6 +55,15 @@ router.put('/subscriptions', asyncHandler(async (req, res) => { res.json({ costs: await saveSubscriptionCosts(costs, { actor: 'user' }) }); })); +// PUT /api/usage/fleet-billing - Mark one federated instance as paying API +// rates (`usesSubscriptions: false`) or riding the viewer's subscriptions +// (`true`). The Across Instances combined total skips API-billed rows. +router.put('/fleet-billing', asyncHandler(async (req, res) => { + const { instanceId, usesSubscriptions } = validateRequest(usageFleetBillingSchema, req.body); + const apiBilledInstanceIds = await setInstanceUsesSubscriptions(instanceId, usesSubscriptions, { actor: 'user' }); + res.json({ instanceId, usesSubscriptions, apiBilledInstanceIds }); +})); + // GET /api/usage/providers - Subscription-quota status for every enabled // provider family (claude, codex, agy, grok). Providers without a queryable // usage surface report `supported: false`. diff --git a/server/routes/usage.test.js b/server/routes/usage.test.js index d4c2aa7767..805d04486e 100644 --- a/server/routes/usage.test.js +++ b/server/routes/usage.test.js @@ -17,6 +17,11 @@ vi.mock('../services/peerUsage.js', () => ({ getFleetUsage: vi.fn(async () => ({ instances: [], totals: null })) })); +vi.mock('../services/usageFleetBilling.js', () => ({ + getApiBilledInstanceIds: vi.fn(async () => []), + setInstanceUsesSubscriptions: vi.fn(async () => []), +})); + vi.mock('../services/subscriptionCosts.js', () => ({ saveSubscriptionCosts: vi.fn(async (costs) => costs), getSubscriptionSavings: vi.fn(async () => ({ configured: false, families: [] })) @@ -47,6 +52,7 @@ import { getProviderQuotas } from '../services/providerUsage.js'; import { getHistoricalUsageBackfillStatus, startHistoricalUsageBackfill } from '../services/usageBackfill.js'; import { getSubscriptionSavings, saveSubscriptionCosts } from '../services/subscriptionCosts.js'; import { getFleetUsage } from '../services/peerUsage.js'; +import { getApiBilledInstanceIds, setInstanceUsesSubscriptions } from '../services/usageFleetBilling.js'; import usageRoutes from './usage.js'; const buildApp = () => { @@ -78,7 +84,7 @@ describe('usage routes', () => { expect(arg.providers).toEqual([]); // The fleet breakdown must be priced over the SAME window as the summary, // or a peer row would silently report a different period than its heading. - expect(getFleetUsage).toHaveBeenCalledWith({ from: arg.from, to: null, providers: [] }); + expect(getFleetUsage).toHaveBeenCalledWith({ from: arg.from, to: null, providers: [], apiBilledInstanceIds: [] }); }); it('GET /api/usage passes an explicit from/to range through', async () => { @@ -182,6 +188,37 @@ describe('usage routes', () => { expect(res.status).toBe(400); }); + it('PUT /api/usage/fleet-billing marks an instance as API-billed', async () => { + setInstanceUsesSubscriptions.mockResolvedValue(['inst-peer']); + const res = await request(buildApp()) + .put('/api/usage/fleet-billing') + .send({ instanceId: 'inst-peer', usesSubscriptions: false }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + instanceId: 'inst-peer', + usesSubscriptions: false, + apiBilledInstanceIds: ['inst-peer'], + }); + expect(setInstanceUsesSubscriptions).toHaveBeenCalledWith('inst-peer', false, { actor: 'user' }); + }); + + it('PUT /api/usage/fleet-billing rejects a blank instance id', async () => { + const res = await request(buildApp()) + .put('/api/usage/fleet-billing') + .send({ instanceId: '', usesSubscriptions: false }); + expect(res.status).toBe(400); + expect(setInstanceUsesSubscriptions).not.toHaveBeenCalled(); + }); + + it('GET /api/usage prices the fleet with the stored API-billed set', async () => { + usage.getUsageSummary.mockReturnValue({}); + getApiBilledInstanceIds.mockResolvedValueOnce(['inst-peer']); + await request(buildApp()).get('/api/usage'); + expect(getFleetUsage).toHaveBeenCalledWith(expect.objectContaining({ + apiBilledInstanceIds: ['inst-peer'], + })); + }); + it('GET /api/usage/providers returns quota entries and honors refresh', async () => { getProviderQuotas.mockResolvedValue([ { family: 'claude', supported: true, limits: [] }, diff --git a/server/services/peerUsage.js b/server/services/peerUsage.js index 18858ded76..d554a375fc 100644 --- a/server/services/peerUsage.js +++ b/server/services/peerUsage.js @@ -312,6 +312,10 @@ export async function applyUsageRemote(remoteData) { * * Peer rows are as fresh as the last sync, so `capturedAt` is surfaced for the * UI to age them rather than implying they are live. + * + * `apiBilledInstanceIds` are instances the viewer marked as paying API rates + * rather than subscriptions. They stay in `instances` (the spend is real) with + * `usesSubscriptions: false`, but they do not feed the combined `totals`. */ /** * Keep only month buckets the window covers END TO END. `buildUsageReport` @@ -337,10 +341,28 @@ function dropPartiallyCoveredMonths(monthlyActivity, from, to) { return out; } -export async function getFleetUsage({ from = null, to = null, providers = [] } = {}) { +function sumFleetTotals(rows) { + // Derived from the report's own totals rather than a second hardcoded field + // list, so a field added to `buildUsageReport` can't silently sum to zero here. + const totals = rows.reduce((acc, r) => { + for (const [field, value] of Object.entries(r.totals || {})) { + if (typeof value === 'number') acc[field] = (acc[field] || 0) + value; + } + return acc; + }, {}); + totals.estimatedCost = roundCents(totals.estimatedCost || 0); + return totals; +} + +export async function getFleetUsage({ from = null, to = null, providers = [], apiBilledInstanceIds = [] } = {}) { const { self, peers } = await entriesWithSelf(); if (peers.length === 0) return { instances: [], totals: null }; + // Instances the viewer marked as paying API rates (not subscriptions). They + // stay in `instances` so the spend is still visible, but they do not feed + // the combined total. Default ON (not in the set) — a missing id counts. + const apiBilled = new Set(Array.isArray(apiBilledInstanceIds) ? apiBilledInstanceIds : []); + const row = ({ instanceId, name, capturedAt, activity, isSelf }) => { const report = buildUsageReport(activity.dailyActivity || {}, { from, @@ -359,7 +381,14 @@ export async function getFleetUsage({ from = null, to = null, providers = [] } = // must not, or it attributes all-time residue to the range. totalTokens: from || to ? null : activity.totalTokens, }); - return { instanceId, name: name || instanceId, self: isSelf, capturedAt, totals: report.totals }; + return { + instanceId, + name: name || instanceId, + self: isSelf, + capturedAt, + totals: report.totals, + usesSubscriptions: !apiBilled.has(instanceId), + }; }; const rows = peers.map((e) => row({ ...e, activity: e.usage, isSelf: false })); @@ -367,17 +396,8 @@ export async function getFleetUsage({ from = null, to = null, providers = [] } = rows.sort((a, b) => (b.self ? 1 : 0) - (a.self ? 1 : 0) || b.totals.estimatedCost - a.totals.estimatedCost); - // Derived from the report's own totals rather than a second hardcoded field - // list, so a field added to `buildUsageReport` can't silently sum to zero here. - const totals = rows.reduce((acc, r) => { - for (const [field, value] of Object.entries(r.totals)) { - if (typeof value === 'number') acc[field] = (acc[field] || 0) + value; - } - return acc; - }, {}); - totals.estimatedCost = roundCents(totals.estimatedCost || 0); - - return { instances: rows, totals }; + const included = rows.filter((r) => r.usesSubscriptions); + return { instances: rows, totals: sumFleetTotals(included) }; } /** diff --git a/server/services/peerUsage.test.js b/server/services/peerUsage.test.js index a25f37ff48..d40292ea87 100644 --- a/server/services/peerUsage.test.js +++ b/server/services/peerUsage.test.js @@ -275,6 +275,21 @@ describe('fleet report', () => { expect(fleet.instances[1].capturedAt).toBe('2026-08-30T09:00:00.000Z'); expect(fleet.totals.tokensOut).toBe(5000); expect(fleet.totals.sessions).toBe(4); + expect(fleet.instances.every((i) => i.usesSubscriptions)).toBe(true); + }); + + // A machine that pays API rates is still listed — the spend is real — but + // must not inflate the combined "what subscriptions covered" total. + it('leaves an API-billed instance listed but out of the combined total', async () => { + await applyUsageRemote({ instances: { 'inst-peer': peerEntry() } }); + const fleet = await getFleetUsage({ providers: [], apiBilledInstanceIds: ['inst-peer'] }); + + expect(fleet.instances.map((i) => [i.instanceId, i.usesSubscriptions])).toEqual([ + ['inst-self', true], + ['inst-peer', false], + ]); + expect(fleet.totals.tokensOut).toBe(1000); + expect(fleet.totals.sessions).toBe(2); }); // The wire digest folds days past its retention window into WHOLE months, and diff --git a/server/services/usageFleetBilling.js b/server/services/usageFleetBilling.js new file mode 100644 index 0000000000..1b02ea2d02 --- /dev/null +++ b/server/services/usageFleetBilling.js @@ -0,0 +1,82 @@ +/** + * Which federated instances pay API rates instead of the user's subscriptions. + * + * The Across Instances card prices every machine the same way — published API + * rates — so a box that actually pays those rates inflates the combined + * "what subscriptions saved me" total. Marking it API-billed leaves the row + * listed (the spend is still real) but drops it from the combined figures. + * + * Machine-local, stored in `data/settings.json` under + * `usageApiBilledInstanceIds`. It is THIS install's view of which fleet + * members ride the plans, not a property of the instance itself, and never + * rides the usage digest. A user looking at Usage on another machine sets + * the same toggle there. + */ + +import { getSettings, updateSettingsWith } from './settings.js'; + +const SETTINGS_KEY = 'usageApiBilledInstanceIds'; + +// Same cap as stored peer digests: a home federation is a handful of machines. +// The list only exists so a restore/hand-edit can't grow settings.json without +// bound; the route schema enforces the same ceiling on the way in. +const MAX_IDS = 64; +const MAX_ID_LEN = 200; + +const isInstanceId = (id) => typeof id === 'string' && id.length > 0 && id.length <= MAX_ID_LEN; + +/** + * Pure: stored/incoming ids as a de-duplicated list. Non-arrays, blanks, + * over-long ids, and anything past the cap are dropped so a corrupt settings + * slice can't poison the fleet total. + */ +export function normalizeApiBilledInstanceIds(raw) { + if (!Array.isArray(raw)) return []; + const out = []; + const seen = new Set(); + for (const id of raw) { + if (!isInstanceId(id) || seen.has(id)) continue; + seen.add(id); + out.push(id); + if (out.length >= MAX_IDS) break; + } + return out; +} + +/** Instance ids that pay API rates and must not count toward fleet totals. */ +export async function getApiBilledInstanceIds() { + const settings = await getSettings(); + return normalizeApiBilledInstanceIds(settings?.[SETTINGS_KEY]); +} + +/** + * Replace the whole API-billed set. Used by a settings PUT that carries the + * slice (restore / generic client) so it goes through the same normalizer as + * the per-row toggle rather than landing unvalidated. + */ +export async function saveApiBilledInstanceIds(ids, options) { + const normalized = normalizeApiBilledInstanceIds(ids); + const next = await updateSettingsWith((current) => ( + { ...current, [SETTINGS_KEY]: normalized } + ), options); + return normalizeApiBilledInstanceIds(next?.[SETTINGS_KEY]); +} + +/** + * Per-row toggle: `usesSubscriptions: true` takes the instance OUT of the + * API-billed set (it counts again); `false` puts it in. Absent vs present + * follows the same merge convention as subscription prices — this function + * is the one write the Usage page issues. + */ +export async function setInstanceUsesSubscriptions(instanceId, usesSubscriptions, options) { + if (!isInstanceId(instanceId)) return getApiBilledInstanceIds(); + const next = await updateSettingsWith((current) => { + const ids = new Set(normalizeApiBilledInstanceIds(current?.[SETTINGS_KEY])); + if (usesSubscriptions) ids.delete(instanceId); + else ids.add(instanceId); + return { ...current, [SETTINGS_KEY]: normalizeApiBilledInstanceIds([...ids]) }; + }, options); + return normalizeApiBilledInstanceIds(next?.[SETTINGS_KEY]); +} + +export { SETTINGS_KEY as USAGE_API_BILLED_SETTINGS_KEY, MAX_IDS as MAX_API_BILLED_IDS }; diff --git a/server/services/usageFleetBilling.test.js b/server/services/usageFleetBilling.test.js new file mode 100644 index 0000000000..49d1f21f78 --- /dev/null +++ b/server/services/usageFleetBilling.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +let stored = {}; +vi.mock('./settings.js', () => ({ + getSettings: vi.fn(async () => structuredClone(stored)), + updateSettingsWith: vi.fn(async (mutate) => { + stored = await mutate(structuredClone(stored)); + return structuredClone(stored); + }), +})); + +import { + normalizeApiBilledInstanceIds, + getApiBilledInstanceIds, + saveApiBilledInstanceIds, + setInstanceUsesSubscriptions, + USAGE_API_BILLED_SETTINGS_KEY, + MAX_API_BILLED_IDS, +} from './usageFleetBilling.js'; + +beforeEach(() => { + stored = {}; + vi.clearAllMocks(); +}); + +describe('normalizeApiBilledInstanceIds', () => { + it('keeps unique non-empty ids and drops everything else', () => { + expect(normalizeApiBilledInstanceIds(['inst-a', 'inst-a', '', null, 'inst-b'])).toEqual(['inst-a', 'inst-b']); + }); + + it('tolerates a non-array stored value', () => { + expect(normalizeApiBilledInstanceIds(null)).toEqual([]); + expect(normalizeApiBilledInstanceIds({ 'inst-a': true })).toEqual([]); + }); + + it('caps the list so a hand-edit cannot grow settings without bound', () => { + const ids = Array.from({ length: MAX_API_BILLED_IDS + 5 }, (_, i) => `inst-${i}`); + expect(normalizeApiBilledInstanceIds(ids)).toHaveLength(MAX_API_BILLED_IDS); + }); +}); + +describe('setInstanceUsesSubscriptions', () => { + it('marks an instance API-billed and reads it back', async () => { + expect(await setInstanceUsesSubscriptions('inst-peer', false)).toEqual(['inst-peer']); + expect(await getApiBilledInstanceIds()).toEqual(['inst-peer']); + expect(stored[USAGE_API_BILLED_SETTINGS_KEY]).toEqual(['inst-peer']); + }); + + it('puts the instance back on subscriptions by removing it from the set', async () => { + await setInstanceUsesSubscriptions('inst-peer', false); + expect(await setInstanceUsesSubscriptions('inst-peer', true)).toEqual([]); + }); + + // A blank id would otherwise sit in settings.json forever with no row that + // could ever clear it — the same hole an unknown subscription family key + // would open. + it('ignores a blank instance id', async () => { + expect(await setInstanceUsesSubscriptions('', false)).toEqual([]); + expect(await getApiBilledInstanceIds()).toEqual([]); + }); +}); + +describe('saveApiBilledInstanceIds', () => { + it('replaces the whole set, dropping invalid entries', async () => { + await setInstanceUsesSubscriptions('inst-old', false); + expect(await saveApiBilledInstanceIds(['inst-new', '', 'inst-new'])).toEqual(['inst-new']); + }); +});