Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions client/src/pages/UsagePage.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { RefreshCw, Clock, AlertTriangle, DatabaseZap } from 'lucide-react';
import { RefreshCw, Clock, AlertTriangle, DatabaseZap, Network } from 'lucide-react';
import * as api from '../services/api';
import BrailleSpinner from '../components/BrailleSpinner';
import PageSkeleton from '../components/ui/PageSkeleton';
import Pill from '../components/ui/Pill';
import { formatCompactCount, formatCompactCountOrDash as formatNumber, formatUsd, timeUntil } from '../utils/formatters';
import { formatCompactCount, formatCompactCountOrDash as formatNumber, formatUsd, timeAgo, timeUntil } from '../utils/formatters';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import SubscriptionSavingsCard from '../components/usage/SubscriptionSavingsCard';
Expand Down Expand Up @@ -84,6 +84,21 @@ function StatTile({ label, value, detail }) {
);
}

// A subscription is one account across every federated instance, but each
// instance can only read its own CLI's panel. When peers have contributed a
// reading, say so on the card and name them — the meters are the freshest
// reading across the fleet and the activity counts are summed, which is a
// different claim than "what this box saw".
function FleetSourcesPill({ fleet }) {
if (!fleet || fleet.count < 2) return null;
const describe = (i) => `${i.self ? 'this machine' : (i.name || i.instanceId)}${i.self || !i.fetchedAt ? '' : ` (read ${timeAgo(i.fetchedAt)})`}`;
return (
<Pill tone="context" size="xs" icon={Network} className="hidden sm:inline-flex shrink-0" title={fleet.instances.map(describe).join(' · ')}>
{fleet.count} instances
</Pill>
);
}

// One subscription-quota card per enabled provider family. Providers with no
// queryable usage surface (supported: false) render a muted note, never an
// error; a supported adapter that failed transiently shows a soft warning.
Expand All @@ -96,6 +111,7 @@ function ProviderQuotaCard({ quota, onRefresh, refreshing, disabled }) {
{quota.plan && quota.plan !== 'unknown' && (
<Pill tone="context" size="xs" className="hidden sm:inline-flex">{quota.plan}</Pill>
)}
<FleetSourcesPill fleet={quota.fleet} />
{/* Per-card refresh: every family's reading is its own multi-second
CLI/TUI scrape, so re-reading one provider must not respawn all
of them. */}
Expand Down
37 changes: 37 additions & 0 deletions client/src/pages/UsagePage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,43 @@ describe('UsagePage per-provider refresh', () => {
});
});

describe('UsagePage federated quota readings', () => {
const fleetCard = {
family: 'claude',
label: 'Claude Code',
supported: true,
limits: [{ key: 'week', label: 'Weekly', percentUsed: 65, percentRemaining: 35 }],
activity: [],
approximate: true,
fetchedAt: '2026-09-03T11:00:00.000Z',
note: 'Across 2 federated instances (this machine, Example Box) — meters show the freshest reading across them.',
fleet: {
count: 2,
instances: [
{ instanceId: 'inst-self', name: null, self: true, fetchedAt: '2026-09-03T10:00:00.000Z' },
{ instanceId: 'inst-peer', name: 'Example Box', self: false, fetchedAt: '2026-09-03T11:00:00.000Z' },
],
},
};

it('says the card spans instances and names which ones', async () => {
api.getProviderUsage.mockResolvedValue({ providers: [fleetCard] });
render(<MemoryRouter><UsagePage /></MemoryRouter>);

const pill = await screen.findByText('2 instances');
expect(pill.closest('[title]').getAttribute('title')).toContain('Example Box');
expect(screen.getByText(fleetCard.note)).toBeInTheDocument();
});

it('shows no fleet pill on a single-machine install', async () => {
api.getProviderUsage.mockResolvedValue({ providers: [{ ...fleetCard, fleet: undefined, note: 'This machine only — other federated instances have not reported a reading yet.' }] });
render(<MemoryRouter><UsagePage /></MemoryRouter>);

await screen.findByText('Claude Code');
expect(screen.queryByText(/^\d+ instances$/)).not.toBeInTheDocument();
});
});

describe('arrangeQuotaCells', () => {
const q = (family) => ({ family, label: family });

Expand Down
51 changes: 51 additions & 0 deletions docs/decisions/2026-09-01-federated-usage-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,54 @@ excluded on both privacy and payload grounds.
far smaller depth budget than native `JSON.stringify` — so a digest deep
enough to pass `atomicWrite` but blow that recursion would otherwise 500 the
snapshot endpoint for every peer, permanently and across restarts.

## Amendment (2026-09-03): subscription-quota readings ride the same category

A subscription is **one account across every federated instance**, but each
install can only read the quota panel of its own local CLI. So every
subscription card on the Usage page was a partial view of a shared allowance,
captioned with the CLI's own wording — "Local sessions only — does not include
other devices or claude.ai." That caption was accurate and useless: the
federation already carries this user's other devices.

**Each instance's last quota reading now rides the `usage` category alongside
its usage digest**, and the cards are unified before they render
([`server/lib/fleetQuotas.js`](../../server/lib/fleetQuotas.js)).

- **Two merge rules, because the halves mean different things.** `limits` (the
meters) are account-wide — every machine reads the same server-side allowance,
just at a different moment — so the FRESHEST reading per limit key wins;
summing them would multiply one allowance by the number of machines that
looked at it. `activity` (requests/sessions) is per-machine, which is exactly
what the provider's caption is about, so those SUM. `metrics[]` is left local:
its values are prose (`"3 renders · 24h"`), not addends.
- **A card this machine could not read is filled from a peer that could** — a
logged-out CLI or a scrape still in flight stops reporting a failure once
another instance has read the same account.
- **Only families this install has enabled get a card.** A peer running a
provider we don't is that machine's business; a meter for a plan the viewer
can't spend would be noise.
- **API-billed instances are excluded.** The existing per-row Subscriptions
toggle already marks fleet members that pay API rates rather than riding the
viewer's plans; those meter a different account, so folding their readings in
would be a wrong number rather than a fuller one.
- **A single-machine install is unchanged, caption included.** With nothing to
combine, claiming otherwise would be worse than the wording this replaces —
so the local note says only that no other instance has reported yet.

Mechanically, the readings live in an in-memory stale-while-revalidate cache
(a reading costs a 10-20s CLI/TUI spawn), which cannot be federated: it dies
with the process, and this category's checksum is invalidated by FILE
fingerprints. So `services/providerQuotaShare.js` persists them to
`data/provider-quotas.json` — added to `USAGE_CHECKSUM_PATHS`, and folded into
the entry's `capturedAt` so a quota refresh with no new AI runs still advances
the slot a peer pulls. The write is skipped when a card's *claim* is unchanged
(comparing whole cards would rewrite the file on every page poll, since some
adapters stamp the clock on read).

Nothing here reads a provider: the AI Provider Usage Policy still holds, because
this only records and forwards what a user-triggered reading already produced.
Privacy is unchanged in kind — a quota card carries provider ids, percentages,
reset times and the publishing instance's name; no prompts, no transcripts, no
PII. The peer payload is rebuilt to the wire shape on arrival for the same
recursion-depth reason the usage digest is.
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `credentialRegistry.js` | Pure catalog of PortOS credentials (`CREDENTIALS`, `CREDENTIAL_IDS`, `CREDENTIAL_TIERS`) — one entry per key/token an install can use (`id`, `label`, `unlocks`, `tier`, `getUrl`, `envVars`, `settingsPath`, `configurePath`, optional `feature`). Sits beside `instanceFeatureRegistry.js` so the two lists stay greppable together. Runtime resolution (settings / repo `.env` / inherited `process.env` / CLI / instance config) lives in `services/credentialInventory.js`. The Settings > Credentials page never receives a value or masked prefix. |
| `instanceFeatureRegistry.js` | The registry of optional per-install features (`INSTANCE_FEATURES`, `INSTANCE_FEATURE_IDS`, `APP_FEATURE_IDS`) — pure data, so `validation.js` derives its feature schemas from it and `navManifest.js` can be checked against it without a service→lib inversion. Runtime resolution (stored override → auto-detection → `defaultEnabled`) lives in `services/instanceFeatures.js`. A feature id tagged on a nav entry hides that page from ⌘K and the sidebar when the feature is off. |
| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. |
| `fleetQuotas.js` | Unifies subscription-quota readings across federated instances — one plan, several machines, each able to read only its own local CLI. `sanitizeQuotaCards` bounds a peer-supplied payload to the wire shape; `mergeFleetQuotaCards(localCards, peerEntries)` folds every peer's reading into this install's cards, taking the FRESHEST reading per limit key (the meters are account-wide, so summing them would multiply one allowance) and SUMMING activity counts (those are per-machine, which is why the provider captions them "does not include other devices"); `fleetNote` writes the caption naming what was combined. `metrics[]` is left local — its values are prose, not addends. Fed by `services/providerQuotaShare.js` (this machine's readings, persisted) and `services/peerUsage.js` (the `usage` sync category that carries them). |
| `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. |
| `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. |
| `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects/<cwd-slug>/*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions/<encodeURIComponent(cwd)>/<id>/`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. |
Expand Down
Loading