The home ADR 0050 left open for a usage reading. mesh-control is a CLI and
cannot consume events, so the store that keeps the current usage picture is a
MODULE — the audit-logger's sibling: it consumes `module.*.usage.*` and upserts
each reading into its own provisioned postgres store, latest per
(licence, consumer, period, metric), in the clear. One vendor-neutral table
holds BOTH grains; they differ only in `consumer` (the holding module for the
licence grain, the session for the finer one). The consumer creates its table
on startup and, as a restart-until-ready service, self-heals rather than
gating the apply on a run-once that must reach a provider over the overlay.
The vendor->row normalisation moves into the adapter, as ADR 0054 requires:
anthropic-manager (licence grain, utilization%) and anthropic-consumer (session
grain, token/cost) now emit already-normalised { rows: UsageRow[], raw } on
their existing keys, so the store stays vendor-blind.
Proven end to end by the mesh-lab model-usage bed (green): a usage event
emitted into the mesh is upserted at both grains, latest-per-key, in the clear.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
39 lines
2.1 KiB
TypeScript
39 lines
2.1 KiB
TypeScript
// model-usage's entrypoint — the usage context store's consumer (novox/hq ADR 0054). mesh-control is
|
|
// a CLI and cannot consume events, so the store that keeps the latest usage reading is a MODULE: it
|
|
// subscribes to `module.*.usage.*` and upserts each row. Like the audit-logger, the on(...) IS the
|
|
// whole handshake — the runtime imports this once the broker is bound, and every usage event any
|
|
// producer emits lands here as well as on the audit trail.
|
|
//
|
|
// The producers (the anthropic adapters) emit ALREADY-NORMALISED rows: the vendor→row normalisation
|
|
// lives in the adapter, not here, so this consumer is vendor-neutral (ADR 0054). A body is
|
|
// `{ rows: UsageRow[], raw }`; each row is upserted, carrying its own `raw` or the body's as a
|
|
// fallback. Delivery is at-least-once, so a duplicate is fine — the upsert keeps the latest.
|
|
|
|
import { on } from "@novox/mesh-sdk/events";
|
|
import { UsageStore, type UsageRow } from "./store.js";
|
|
|
|
const store = UsageStore.fromEnv();
|
|
|
|
// Create the store's one table before subscribing. The DDL is idempotent (CREATE TABLE IF NOT
|
|
// EXISTS), so a restart re-runs it harmlessly. This is done here, in the long-lived consumer, rather
|
|
// than as a gating run-once step: the consumer is a `--restart unless-stopped` service, so if the
|
|
// provider is not yet reachable — its overlay address comes up as the same push settles — this exits
|
|
// and is restarted until it can connect, without ever halting the apply. A run-once migrate that had
|
|
// to reach the provider over the overlay would block the very apply that brings the overlay up.
|
|
await store.migrate();
|
|
|
|
await on("module.*.usage.*", async (event) => {
|
|
const body = event.body as { rows?: UsageRow[]; raw?: unknown };
|
|
for (const row of body.rows ?? []) {
|
|
try {
|
|
await store.upsert({ ...row, raw: row.raw ?? body.raw ?? {} });
|
|
} catch (err) {
|
|
// A failed upsert is a loud line, never a throw back into the broker that would wedge the
|
|
// subscription (the audit-logger's discipline).
|
|
console.error(`[model-usage] could not upsert a row from ${event.type}: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log("[model-usage] recording model usage to its store");
|