@coreplane/switchboard 1.238.0 → 1.240.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +6 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
- package/dist/assets/deploy/cloudflare-resident/worker.ts +223 -65
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
- package/dist/assets/deploy/secrets.manifest.json +4 -4
- package/dist/assets/package-lock.json +207 -3
- package/dist/assets/package.json +2 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +82 -0
- package/dist/assets/src/core/authz/actor.ts +33 -2
- package/dist/assets/src/core/authz/policy.ts +6 -0
- package/dist/assets/src/core/coordinator/contract.ts +12 -1
- package/dist/assets/src/core/coordinator/driver.ts +13 -0
- package/dist/assets/src/core/costs.ts +1114 -0
- package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
- package/dist/assets/src/core/delivery.ts +4 -11
- package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
- package/dist/assets/src/core/ingressTokens.ts +27 -13
- package/dist/assets/src/core/runLedger/types.ts +3 -0
- package/dist/assets/src/core/runRecord.ts +10 -3
- package/dist/assets/src/core/schedules.ts +2 -1
- package/dist/assets/src/core/ship/coordinator.ts +70 -0
- package/dist/assets/src/core/snapshotAge.ts +30 -0
- package/dist/assets/src/core/stateWorkerRef.ts +41 -0
- package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
- package/dist/assets/src/execution/residentRefresh.ts +25 -3
- package/dist/assets/src/execution/restoreWaiters.ts +61 -0
- package/dist/assets/src/execution/seedPlan.ts +267 -0
- package/dist/assets/src/mcp/registry.ts +7 -0
- package/dist/assets/web/dist/.vite/manifest.json +507 -427
- package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
- package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
- package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
- package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
- package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
- package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
- package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
- package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
- package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
- package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
- package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
- package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
- package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
- package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.js} +2 -2
- package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
- package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BYS8FtgQ.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.js} +1 -1
- package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
- package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
- package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-9rY3p-Rw.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
- package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
- package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.js} +1 -1
- package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-Cse3JGb0.js} +1 -1
- package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
- package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
- package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
- package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
- package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
- package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
- package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
- package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
- package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
- package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
- package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
- package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
- package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
- package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
- package/dist/cli.js +2586 -1603
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
- package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
- package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
- package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
- package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
- package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
- package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
- package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
- package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
- package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
- package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
- package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
import { systemClock } from "./trace/clock.js";
|
|
2
|
+
// Spend report: what a group of deployed pieces ("switchboard" = the bot
|
|
3
|
+
// Worker + its containers, the resident/sandbox/memory Workers) costs per day,
|
|
4
|
+
// assembled from the providers' own billing datasets and priced at list — and
|
|
5
|
+
// what share of the whole account's Cloudflare spend that is, so a deployer
|
|
6
|
+
// can tell our services' cost from everything else on their account.
|
|
7
|
+
//
|
|
8
|
+
// Two sources, both behind seams (AGENTS.md invariant 2 — ≥2 implementations):
|
|
9
|
+
// - Cloudflare GraphQL Analytics, every meter Cloudflare bills a Workers
|
|
10
|
+
// deployment on: `containersUsageAdaptiveGroups` (container CPU/memory/disk),
|
|
11
|
+
// `durableObjectsPeriodicGroups` (billable DO GB-s, SQLite rows read and
|
|
12
|
+
// written, per namespace), `durableObjectsInvocationsAdaptiveGroups` (DO
|
|
13
|
+
// requests, and which Worker hosts which namespace),
|
|
14
|
+
// `durableObjectsSqlStorageGroups` (DO SQLite bytes stored),
|
|
15
|
+
// `workersInvocationsAdaptive` (Worker requests and CPU time),
|
|
16
|
+
// `r2StorageAdaptiveGroups` and `r2OperationsAdaptiveGroups` (R2 bytes stored
|
|
17
|
+
// and class A/B operations), `workflowsAdaptiveGroups` (Workflow step
|
|
18
|
+
// endings; the state bytes have no dataset). NOT summed request wall time for DO duration:
|
|
19
|
+
// concurrent long requests (SSE streams, exec) overlap, so that sum runs ~2×
|
|
20
|
+
// above what is billed.
|
|
21
|
+
// - Anthropic Admin API `GET /v1/organizations/cost_report` grouped by
|
|
22
|
+
// workspace — LLM spend, attributed to a group by its Anthropic workspace.
|
|
23
|
+
// `buildCostReport` is pure and does every dollar of arithmetic, so the math is
|
|
24
|
+
// unit-tested against dataset-shaped rows and the sources only fetch + map.
|
|
25
|
+
//
|
|
26
|
+
// Attribution is by Worker script name: a group names its Workers, and
|
|
27
|
+
// everything Cloudflare bills hangs off a script — a Durable Object namespace
|
|
28
|
+
// is attributed to the script that hosts it (the invocations dataset says
|
|
29
|
+
// which), an R2 bucket by the exact `<script>-cache` name the deploy templates
|
|
30
|
+
// give it, a Workflow by the exact `<script>-refresh` name they give it.
|
|
31
|
+
// Container applications carry no script in any dataset, so they stay a
|
|
32
|
+
// configured id → label map. A resource that appears in the account after the
|
|
33
|
+
// config was written is therefore counted, not silently dropped, as long as it
|
|
34
|
+
// belongs to a named Worker.
|
|
35
|
+
//
|
|
36
|
+
// Pricing model (Cloudflare Containers): vCPU bills on ACTIVE seconds only;
|
|
37
|
+
// memory and disk bill on the PROVISIONED size for every awake second. That is
|
|
38
|
+
// why `cpuTimeSec` (actual) sits next to `allocatedMemory` (provisioned) in the
|
|
39
|
+
// same row, and why adding vCPU to an instance is free until it is used.
|
|
40
|
+
|
|
41
|
+
export interface CostGroupConfig {
|
|
42
|
+
/** Display name; defaults to the group key. */
|
|
43
|
+
label?: string;
|
|
44
|
+
/** Cloudflare Worker script names — the attribution root: their Durable Object
|
|
45
|
+
* namespaces, their requests and CPU, and the R2 buckets named after them. */
|
|
46
|
+
workers: string[];
|
|
47
|
+
/** Cloudflare container application id → display label (no dataset ties an
|
|
48
|
+
* application to its Worker, so this stays explicit). */
|
|
49
|
+
containerApps: Record<string, string>;
|
|
50
|
+
/** Durable Object namespace id → display label. Optional: a namespace hosted
|
|
51
|
+
* by one of `workers` is attributed anyway (labelled by its Worker); a listed
|
|
52
|
+
* one is attributed even when the range shows it no invocation to join on. */
|
|
53
|
+
durableObjectNamespaces: Record<string, string>;
|
|
54
|
+
/** R2 bucket name → display label. Optional: a bucket named the way the deploy
|
|
55
|
+
* templates name a Worker's (`<worker>-cache`) is attributed anyway. */
|
|
56
|
+
r2Buckets: Record<string, string>;
|
|
57
|
+
/** Anthropic workspace whose cost report is this group's LLM spend. Absent → no LLM line. */
|
|
58
|
+
anthropicWorkspaceId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CostsConfig {
|
|
62
|
+
cloudflareAccountId: string;
|
|
63
|
+
/** How the page names the account beside its share (the dashboard's account
|
|
64
|
+
* name, e.g. `acme-infra`); absent → the page prints the id's first eight hex. */
|
|
65
|
+
cloudflareAccountName?: string;
|
|
66
|
+
/** Env var holding a Cloudflare API token with Account Analytics:Read. */
|
|
67
|
+
cloudflareTokenEnv: string;
|
|
68
|
+
/** Env var holding an Anthropic Admin API key (sk-ant-admin…). Optional feature. */
|
|
69
|
+
anthropicAdminKeyEnv: string;
|
|
70
|
+
groups: Record<string, CostGroupConfig>;
|
|
71
|
+
/** The snapshot the page and the twins serve (src/core/costsSnapshot.ts). */
|
|
72
|
+
snapshot: {
|
|
73
|
+
/** Hours between two reads of the billing sources. */
|
|
74
|
+
everyHours: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_CF_TOKEN_ENV = "CF_ANALYTICS_TOKEN";
|
|
79
|
+
const DEFAULT_ANTHROPIC_ADMIN_ENV = "ANTHROPIC_ADMIN_KEY";
|
|
80
|
+
|
|
81
|
+
/** `costs.snapshot.everyHours`: the default and the bounds a value must keep. Daily by
|
|
82
|
+
* default — both sources bucket by UTC day, and the page is read about as often. */
|
|
83
|
+
export const COSTS_SNAPSHOT_EVERY_HOURS = Object.freeze({ default: 24, min: 1, max: 168 });
|
|
84
|
+
|
|
85
|
+
/** `costs.snapshot`: absent → the default interval; a value outside the bounds or not a whole number throws by name. */
|
|
86
|
+
function snapshotConfig(raw: unknown): CostsConfig["snapshot"] {
|
|
87
|
+
if (raw === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
|
|
88
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
89
|
+
throw new Error("costs.snapshot must be a mapping");
|
|
90
|
+
const every = (raw as Record<string, unknown>).everyHours;
|
|
91
|
+
if (every === undefined) return { everyHours: COSTS_SNAPSHOT_EVERY_HOURS.default };
|
|
92
|
+
if (
|
|
93
|
+
typeof every !== "number" ||
|
|
94
|
+
!Number.isInteger(every) ||
|
|
95
|
+
every < COSTS_SNAPSHOT_EVERY_HOURS.min ||
|
|
96
|
+
every > COSTS_SNAPSHOT_EVERY_HOURS.max
|
|
97
|
+
)
|
|
98
|
+
throw new Error(
|
|
99
|
+
`costs.snapshot.everyHours must be a whole number of hours between ${COSTS_SNAPSHOT_EVERY_HOURS.min} and ${COSTS_SNAPSHOT_EVERY_HOURS.max}`,
|
|
100
|
+
);
|
|
101
|
+
return { everyHours: every };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function labelMap(raw: unknown, what: string): Record<string, string> {
|
|
105
|
+
const m = raw ?? {};
|
|
106
|
+
if (typeof m !== "object" || Array.isArray(m) || Object.values(m as object).some((v) => typeof v !== "string"))
|
|
107
|
+
throw new Error(what);
|
|
108
|
+
return m as Record<string, string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Validates the `costs:` config block. Absent → undefined (feature off). A
|
|
112
|
+
* malformed block throws at startup rather than producing a half-wired page. */
|
|
113
|
+
export function parseCostsConfig(raw: unknown): CostsConfig | undefined {
|
|
114
|
+
if (raw === undefined || raw === null) return undefined;
|
|
115
|
+
if (typeof raw !== "object") throw new Error("costs: must be a mapping");
|
|
116
|
+
const r = raw as Record<string, unknown>;
|
|
117
|
+
if (typeof r.cloudflareAccountId !== "string" || !r.cloudflareAccountId)
|
|
118
|
+
throw new Error("costs.cloudflareAccountId is required");
|
|
119
|
+
if (r.cloudflareAccountName !== undefined && typeof r.cloudflareAccountName !== "string")
|
|
120
|
+
throw new Error("costs.cloudflareAccountName must be a string when given");
|
|
121
|
+
if (!r.groups || typeof r.groups !== "object")
|
|
122
|
+
throw new Error("costs.groups must be a mapping of group → { workers, containerApps }");
|
|
123
|
+
const groups: Record<string, CostGroupConfig> = {};
|
|
124
|
+
for (const [name, g] of Object.entries(r.groups as Record<string, unknown>)) {
|
|
125
|
+
if (!g || typeof g !== "object") throw new Error(`costs.groups.${name} must be a mapping`);
|
|
126
|
+
const gg = g as Record<string, unknown>;
|
|
127
|
+
if (!Array.isArray(gg.workers) || !gg.workers.every((w) => typeof w === "string"))
|
|
128
|
+
throw new Error(`costs.groups.${name}.workers must be a list of Worker script names`);
|
|
129
|
+
groups[name] = {
|
|
130
|
+
label: typeof gg.label === "string" ? gg.label : undefined,
|
|
131
|
+
workers: gg.workers as string[],
|
|
132
|
+
containerApps: labelMap(gg.containerApps, `costs.groups.${name}.containerApps must map application id → label`),
|
|
133
|
+
durableObjectNamespaces: labelMap(
|
|
134
|
+
gg.durableObjectNamespaces,
|
|
135
|
+
`costs.groups.${name}.durableObjectNamespaces must map namespace id → label`,
|
|
136
|
+
),
|
|
137
|
+
r2Buckets: labelMap(gg.r2Buckets, `costs.groups.${name}.r2Buckets must map bucket name → label`),
|
|
138
|
+
anthropicWorkspaceId: typeof gg.anthropicWorkspaceId === "string" ? gg.anthropicWorkspaceId : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
cloudflareAccountId: r.cloudflareAccountId,
|
|
143
|
+
...(typeof r.cloudflareAccountName === "string" && r.cloudflareAccountName
|
|
144
|
+
? { cloudflareAccountName: r.cloudflareAccountName }
|
|
145
|
+
: {}),
|
|
146
|
+
cloudflareTokenEnv: typeof r.cloudflareTokenEnv === "string" ? r.cloudflareTokenEnv : DEFAULT_CF_TOKEN_ENV,
|
|
147
|
+
anthropicAdminKeyEnv:
|
|
148
|
+
typeof r.anthropicAdminKeyEnv === "string" ? r.anthropicAdminKeyEnv : DEFAULT_ANTHROPIC_ADMIN_ENV,
|
|
149
|
+
groups,
|
|
150
|
+
snapshot: snapshotConfig(r.snapshot),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- prices -------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/** Cloudflare list prices (USD), developers.cloudflare.com/containers/pricing,
|
|
157
|
+
* /durable-objects/platform/pricing, /workers/platform/pricing, /r2/pricing
|
|
158
|
+
* and /workflows/reference/pricing. Gross: the Workers Paid plan's fee and
|
|
159
|
+
* included allowances are not subtracted — the page prices what was used, at
|
|
160
|
+
* the rate it would bill at. */
|
|
161
|
+
export const CLOUDFLARE_PRICES = {
|
|
162
|
+
vcpuSecond: 0.00002,
|
|
163
|
+
memoryGibSecond: 0.0000025,
|
|
164
|
+
diskGbSecond: 0.00000007,
|
|
165
|
+
/** DO duration: $12.50 per million GB-s (Cloudflare meters 128 MB × active
|
|
166
|
+
* wall-clock seconds and reports the product as `duration`). */
|
|
167
|
+
doDurationGbSecond: 12.5e-6,
|
|
168
|
+
doRequestsPerMillion: 0.15,
|
|
169
|
+
/** SQLite-backed Durable Objects: rows read $0.001/M, rows written $1.00/M, storage $0.20/GB-month. */
|
|
170
|
+
doRowsReadPerMillion: 0.001,
|
|
171
|
+
doRowsWrittenPerMillion: 1.0,
|
|
172
|
+
doStorageGbMonth: 0.2,
|
|
173
|
+
/** Workers: $0.30 per million requests, $0.02 per million CPU-milliseconds. */
|
|
174
|
+
workersRequestsPerMillion: 0.3,
|
|
175
|
+
workersCpuPerMillionMs: 0.02,
|
|
176
|
+
/** R2 Standard: storage $0.015/GB-month, class A $4.50/M, class B $0.36/M, egress free. */
|
|
177
|
+
r2StorageGbMonth: 0.015,
|
|
178
|
+
r2ClassAPerMillion: 4.5,
|
|
179
|
+
r2ClassBPerMillion: 0.36,
|
|
180
|
+
/** Workflows (developers.cloudflare.com/workflows/reference/pricing, Workers
|
|
181
|
+
* Paid): steps "500,000 included per month + $0.80/ additional 100,000",
|
|
182
|
+
* storage "1 GB-month included + $0.20/ GB-month". The other two line items,
|
|
183
|
+
* requests and CPU time, "use Workers Standard pricing" and land on the
|
|
184
|
+
* hosting Worker's own `workersInvocationsAdaptive` row — never re-metered here. */
|
|
185
|
+
workflowStepsPer100k: 0.8,
|
|
186
|
+
workflowStorageGbMonth: 0.2,
|
|
187
|
+
} as const;
|
|
188
|
+
|
|
189
|
+
const GIB = 2 ** 30;
|
|
190
|
+
const GB = 1e9;
|
|
191
|
+
/** A GB-month is prorated per day over the mean Gregorian month (365.25 ÷ 12). */
|
|
192
|
+
export const DAYS_PER_MONTH = 30.4375;
|
|
193
|
+
|
|
194
|
+
// ---- usage rows (what the sources return) -------------------------------------
|
|
195
|
+
|
|
196
|
+
export interface ContainerUsageRow {
|
|
197
|
+
date: string; // YYYY-MM-DD (UTC)
|
|
198
|
+
applicationId: string;
|
|
199
|
+
cpuTimeSec: number;
|
|
200
|
+
allocatedMemoryByteSec: number;
|
|
201
|
+
allocatedDiskByteSec: number;
|
|
202
|
+
}
|
|
203
|
+
/** `durableObjectsInvocationsAdaptiveGroups`: requests per namespace per day,
|
|
204
|
+
* and the join that says which Worker hosts which namespace. */
|
|
205
|
+
export interface DoRequestsRow {
|
|
206
|
+
date: string;
|
|
207
|
+
scriptName: string;
|
|
208
|
+
namespaceId: string;
|
|
209
|
+
requests: number;
|
|
210
|
+
}
|
|
211
|
+
/** `durableObjectsPeriodicGroups`: one namespace's day — billable GB-s as
|
|
212
|
+
* Cloudflare reports it (`sum.duration`) and the SQLite rows it read and wrote. */
|
|
213
|
+
export interface DoNamespaceDayRow {
|
|
214
|
+
date: string;
|
|
215
|
+
namespaceId: string;
|
|
216
|
+
gbSeconds: number;
|
|
217
|
+
rowsRead: number;
|
|
218
|
+
rowsWritten: number;
|
|
219
|
+
}
|
|
220
|
+
/** `durableObjectsSqlStorageGroups`: the day's peak bytes stored per namespace. */
|
|
221
|
+
export interface DoStorageRow {
|
|
222
|
+
date: string;
|
|
223
|
+
namespaceId: string;
|
|
224
|
+
storedBytes: number;
|
|
225
|
+
}
|
|
226
|
+
/** `workersInvocationsAdaptive`: a Worker's requests and CPU time per day. */
|
|
227
|
+
export interface WorkerUsageRow {
|
|
228
|
+
date: string;
|
|
229
|
+
scriptName: string;
|
|
230
|
+
requests: number;
|
|
231
|
+
cpuTimeUs: number;
|
|
232
|
+
}
|
|
233
|
+
/** `r2StorageAdaptiveGroups`: the day's peak bytes (payload + metadata) per bucket. */
|
|
234
|
+
export interface R2StorageRow {
|
|
235
|
+
date: string;
|
|
236
|
+
bucketName: string;
|
|
237
|
+
bytes: number;
|
|
238
|
+
}
|
|
239
|
+
/** `r2OperationsAdaptiveGroups`: operations per bucket per action per day. */
|
|
240
|
+
export interface R2OperationsRow {
|
|
241
|
+
date: string;
|
|
242
|
+
bucketName: string;
|
|
243
|
+
actionType: string;
|
|
244
|
+
requests: number;
|
|
245
|
+
}
|
|
246
|
+
/** `workflowsAdaptiveGroups`: a Workflow's billable steps per day — the step
|
|
247
|
+
* endings (`STEP_SUCCESS` + `STEP_FAILURE` events; attempts are retries and
|
|
248
|
+
* the pricing page excludes retries and rollback handlers from the count) —
|
|
249
|
+
* and its persisted state. No analytics dataset reports the state bytes, so
|
|
250
|
+
* the GraphQL source answers 0 there and only a source that knows them
|
|
251
|
+
* (none yet) prices the storage line. */
|
|
252
|
+
export interface WorkflowUsageRow {
|
|
253
|
+
date: string;
|
|
254
|
+
workflowName: string;
|
|
255
|
+
steps: number;
|
|
256
|
+
/** The day's peak bytes of persisted instance state; 0 when the source has no figure. */
|
|
257
|
+
stateBytes: number;
|
|
258
|
+
}
|
|
259
|
+
export interface CloudflareUsage {
|
|
260
|
+
containers: ContainerUsageRow[];
|
|
261
|
+
durableObjectRequests: DoRequestsRow[];
|
|
262
|
+
durableObjectDays: DoNamespaceDayRow[];
|
|
263
|
+
durableObjectStorage: DoStorageRow[];
|
|
264
|
+
workers: WorkerUsageRow[];
|
|
265
|
+
r2Storage: R2StorageRow[];
|
|
266
|
+
r2Operations: R2OperationsRow[];
|
|
267
|
+
workflows: WorkflowUsageRow[];
|
|
268
|
+
}
|
|
269
|
+
export const EMPTY_USAGE: CloudflareUsage = {
|
|
270
|
+
containers: [],
|
|
271
|
+
durableObjectRequests: [],
|
|
272
|
+
durableObjectDays: [],
|
|
273
|
+
durableObjectStorage: [],
|
|
274
|
+
workers: [],
|
|
275
|
+
r2Storage: [],
|
|
276
|
+
r2Operations: [],
|
|
277
|
+
workflows: [],
|
|
278
|
+
};
|
|
279
|
+
export interface LlmCostRow {
|
|
280
|
+
date: string;
|
|
281
|
+
/** null = the organization's default workspace. */
|
|
282
|
+
workspaceId: string | null;
|
|
283
|
+
amountUsd: number;
|
|
284
|
+
/** true = the cost report had no bucket for this day yet (it closes a day
|
|
285
|
+
* some hours after midnight UTC and never has one for the open day), so the
|
|
286
|
+
* amount is the hourly usage report priced at `ANTHROPIC_PRICES`. Absent =
|
|
287
|
+
* the invoice-grade cost report. */
|
|
288
|
+
estimated?: boolean;
|
|
289
|
+
/** Tokens an estimate met under a model id `ANTHROPIC_PRICES` has no entry
|
|
290
|
+
* for — reported, never priced at $0 in silence. Absent on a cost-report row. */
|
|
291
|
+
unpricedTokens?: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface DateRange {
|
|
295
|
+
from: string;
|
|
296
|
+
to: string;
|
|
297
|
+
days: number;
|
|
298
|
+
/** `to` is today (UTC): its figures are still accruing. */
|
|
299
|
+
partialLastDay: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- pricing math ---------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
export interface ContainerCost {
|
|
305
|
+
cpu: number;
|
|
306
|
+
memory: number;
|
|
307
|
+
disk: number;
|
|
308
|
+
total: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function containerCostUsd(row: ContainerUsageRow): ContainerCost {
|
|
312
|
+
const cpu = row.cpuTimeSec * CLOUDFLARE_PRICES.vcpuSecond;
|
|
313
|
+
const memory = (row.allocatedMemoryByteSec / GIB) * CLOUDFLARE_PRICES.memoryGibSecond;
|
|
314
|
+
const disk = (row.allocatedDiskByteSec / GB) * CLOUDFLARE_PRICES.diskGbSecond;
|
|
315
|
+
return { cpu, memory, disk, total: cpu + memory + disk };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function doDurationCostUsd(gbSeconds: number): number {
|
|
319
|
+
return gbSeconds * CLOUDFLARE_PRICES.doDurationGbSecond;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function doRequestsCostUsd(requests: number): number {
|
|
323
|
+
return (requests / 1e6) * CLOUDFLARE_PRICES.doRequestsPerMillion;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function doRowsCostUsd(rowsRead: number, rowsWritten: number): number {
|
|
327
|
+
return (
|
|
328
|
+
(rowsRead / 1e6) * CLOUDFLARE_PRICES.doRowsReadPerMillion +
|
|
329
|
+
(rowsWritten / 1e6) * CLOUDFLARE_PRICES.doRowsWrittenPerMillion
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** One day of storage at a GB-month rate: the day's peak bytes, prorated. */
|
|
334
|
+
export function storageDayCostUsd(bytes: number, perGbMonth: number): number {
|
|
335
|
+
return ((bytes / GB) * perGbMonth) / DAYS_PER_MONTH;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function workersCostUsd(requests: number, cpuTimeUs: number): number {
|
|
339
|
+
return (
|
|
340
|
+
(requests / 1e6) * CLOUDFLARE_PRICES.workersRequestsPerMillion +
|
|
341
|
+
(cpuTimeUs / 1000 / 1e6) * CLOUDFLARE_PRICES.workersCpuPerMillionMs
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type R2OperationClass = "A" | "B" | "free";
|
|
346
|
+
|
|
347
|
+
/** Cloudflare's classes: A mutates or lists, B reads, deletes are free. Named
|
|
348
|
+
* operations first; an operation this table has not met is classed by its
|
|
349
|
+
* verb, so a new API action is priced rather than dropped. */
|
|
350
|
+
export function r2OperationClass(actionType: string): R2OperationClass {
|
|
351
|
+
const a = actionType.trim();
|
|
352
|
+
if (/^(Delete|Abort)/.test(a)) return "free";
|
|
353
|
+
if (/^(Get|Head|Usage)/.test(a)) return "B";
|
|
354
|
+
return "A";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Workflows: steps at the per-100,000 rate plus one day of the state's GB-month rate. */
|
|
358
|
+
export function workflowsCostUsd(steps: number, stateBytes: number): number {
|
|
359
|
+
return (
|
|
360
|
+
(steps / 100_000) * CLOUDFLARE_PRICES.workflowStepsPer100k +
|
|
361
|
+
storageDayCostUsd(stateBytes, CLOUDFLARE_PRICES.workflowStorageGbMonth)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function r2OperationsCostUsd(actionType: string, requests: number): number {
|
|
366
|
+
const cls = r2OperationClass(actionType);
|
|
367
|
+
if (cls === "free") return 0;
|
|
368
|
+
return (requests / 1e6) * (cls === "A" ? CLOUDFLARE_PRICES.r2ClassAPerMillion : CLOUDFLARE_PRICES.r2ClassBPerMillion);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- attribution ----------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
/** What of the account the group's config plus the datasets' joins attribute
|
|
374
|
+
* to it — reported on the page so a reader can see what was counted. */
|
|
375
|
+
export interface CostAttribution {
|
|
376
|
+
workers: string[];
|
|
377
|
+
containerApps: Record<string, string>;
|
|
378
|
+
/** namespace id → label (a configured label, else the hosting Worker's name). */
|
|
379
|
+
durableObjectNamespaces: Record<string, string>;
|
|
380
|
+
/** bucket name → label (a configured label, else the bucket's name). */
|
|
381
|
+
r2Buckets: Record<string, string>;
|
|
382
|
+
/** Workflow name → the Worker that hosts it (the deploy template's `<worker>-refresh`). */
|
|
383
|
+
workflows: Record<string, string>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The bucket names the deploy templates give a Worker's buckets (`{{script}}-cache`
|
|
387
|
+
* in deploy/cloudflare-resident/wrangler.template.jsonc). Exact shapes, not a
|
|
388
|
+
* prefix: on a shared account `switchboard-2-tfstate` must not become
|
|
389
|
+
* `switchboard`'s. A bucket named any other way is attributed by `r2Buckets`. */
|
|
390
|
+
const TEMPLATE_BUCKET_NAMES: ReadonlyArray<(worker: string) => string> = [(w) => `${w}-cache`];
|
|
391
|
+
|
|
392
|
+
/** The Workflow names the deploy templates give a Worker's Workflows
|
|
393
|
+
* (`{{script}}-refresh`, the resident's refresh cycle). The analytics dataset
|
|
394
|
+
* carries no script name, so the name is the only join — exact, like the buckets'. */
|
|
395
|
+
const TEMPLATE_WORKFLOW_NAMES: ReadonlyArray<(worker: string) => string> = [(w) => `${w}-refresh`];
|
|
396
|
+
|
|
397
|
+
/** Namespace → hosting script, from every invocation row (any date in range). */
|
|
398
|
+
function namespaceHosts(usage: CloudflareUsage): Map<string, string> {
|
|
399
|
+
const hosts = new Map<string, string>();
|
|
400
|
+
for (const r of usage.durableObjectRequests)
|
|
401
|
+
if (r.namespaceId && r.scriptName) hosts.set(r.namespaceId, r.scriptName);
|
|
402
|
+
return hosts;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function attributionOf(cfg: CostGroupConfig, usage: CloudflareUsage): CostAttribution {
|
|
406
|
+
const workers = new Set(cfg.workers);
|
|
407
|
+
const durableObjectNamespaces: Record<string, string> = { ...cfg.durableObjectNamespaces };
|
|
408
|
+
for (const [ns, script] of namespaceHosts(usage))
|
|
409
|
+
if (workers.has(script) && !(ns in durableObjectNamespaces)) durableObjectNamespaces[ns] = script;
|
|
410
|
+
const r2Buckets: Record<string, string> = { ...cfg.r2Buckets };
|
|
411
|
+
const bucketNames = new Set([...usage.r2Storage, ...usage.r2Operations].map((r) => r.bucketName).filter(Boolean));
|
|
412
|
+
for (const name of bucketNames)
|
|
413
|
+
if (!(name in r2Buckets) && cfg.workers.some((w) => TEMPLATE_BUCKET_NAMES.some((shape) => shape(w) === name)))
|
|
414
|
+
r2Buckets[name] = name;
|
|
415
|
+
const workflows: Record<string, string> = {};
|
|
416
|
+
for (const name of new Set(usage.workflows.map((r) => r.workflowName).filter(Boolean))) {
|
|
417
|
+
const host = cfg.workers.find((w) => TEMPLATE_WORKFLOW_NAMES.some((shape) => shape(w) === name));
|
|
418
|
+
if (host) workflows[name] = host;
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
workers: [...cfg.workers],
|
|
422
|
+
containerApps: { ...cfg.containerApps },
|
|
423
|
+
durableObjectNamespaces,
|
|
424
|
+
r2Buckets,
|
|
425
|
+
workflows,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---- the report -------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export interface DailyCost {
|
|
432
|
+
date: string;
|
|
433
|
+
containers: Record<string, ContainerCost>;
|
|
434
|
+
/** Billable DO duration cost per attributed namespace label. Duration only — the
|
|
435
|
+
* full DO figure is this sum plus `doRequestsUsd` (`totals.byResource.durableObjects`). */
|
|
436
|
+
durableObjects: Record<string, number>;
|
|
437
|
+
/** DO request cost for the group's namespaces (cents a day). */
|
|
438
|
+
doRequestsUsd: number;
|
|
439
|
+
/** SQLite rows read and written by the group's namespaces. */
|
|
440
|
+
doRowsUsd: number;
|
|
441
|
+
/** SQLite bytes stored by the group's namespaces, one day of the GB-month rate. */
|
|
442
|
+
doStorageUsd: number;
|
|
443
|
+
/** The group's Workers: requests and CPU time. */
|
|
444
|
+
workersUsd: number;
|
|
445
|
+
/** The group's R2 buckets: one day of storage plus class A/B operations. */
|
|
446
|
+
r2Usd: number;
|
|
447
|
+
/** The group's Workflows: steps plus one day of persisted state. */
|
|
448
|
+
workflowsUsd: number;
|
|
449
|
+
cloudUsd: number;
|
|
450
|
+
llmUsd: number;
|
|
451
|
+
/** true = `llmUsd` is the usage report priced at list, because the cost
|
|
452
|
+
* report has not closed this day (`LlmCostRow.estimated`). */
|
|
453
|
+
llmEstimated: boolean;
|
|
454
|
+
/** Tokens of the group's workspace the estimate could not price (unknown model id). */
|
|
455
|
+
llmUnpricedTokens: number;
|
|
456
|
+
total: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export interface ResourceSplit {
|
|
460
|
+
cpu: number;
|
|
461
|
+
memory: number;
|
|
462
|
+
disk: number;
|
|
463
|
+
/** DO duration + requests. */
|
|
464
|
+
durableObjects: number;
|
|
465
|
+
doRows: number;
|
|
466
|
+
doStorage: number;
|
|
467
|
+
workers: number;
|
|
468
|
+
r2: number;
|
|
469
|
+
/** Workflow steps + state. */
|
|
470
|
+
workflows: number;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface CostReport {
|
|
474
|
+
group: string;
|
|
475
|
+
label: string;
|
|
476
|
+
range: DateRange;
|
|
477
|
+
/** false = no LLM source configured; every llmUsd is 0 and means "unknown". */
|
|
478
|
+
llmAvailable: boolean;
|
|
479
|
+
days: DailyCost[];
|
|
480
|
+
totals: {
|
|
481
|
+
cloudUsd: number;
|
|
482
|
+
llmUsd: number;
|
|
483
|
+
total: number;
|
|
484
|
+
byResource: ResourceSplit;
|
|
485
|
+
};
|
|
486
|
+
/** When the report was assembled (epoch ms): the page scales the open day's
|
|
487
|
+
* figures to a full day by the fraction of the UTC day that had elapsed. */
|
|
488
|
+
generatedAt: number;
|
|
489
|
+
/** The whole account, every row priced the same way, grouped or not — the
|
|
490
|
+
* denominator of "our services vs everything else on this account" — with
|
|
491
|
+
* the id the page links to and the configured name it prints. */
|
|
492
|
+
account: { id: string; name?: string; cloudUsd: number };
|
|
493
|
+
attribution: CostAttribution;
|
|
494
|
+
/** The snapshot the report was built from (src/core/costsSnapshot.ts): when it was
|
|
495
|
+
* taken, by whom, how long the read took. Absent on a report built straight from the sources. */
|
|
496
|
+
snapshot?: { takenAt: string; takenBy: string; durationMs: number };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** What the report carries beyond the priced rows: the account behind the
|
|
500
|
+
* denominator and the clock the page scales the open day by. */
|
|
501
|
+
export interface CostReportMeta {
|
|
502
|
+
/** The Cloudflare account the rows came from — required, since the page
|
|
503
|
+
* links to it and an empty id would link to the bare dashboard. */
|
|
504
|
+
accountId: string;
|
|
505
|
+
accountName?: string;
|
|
506
|
+
/** Absent → the system clock, for callers that do not assemble a page. */
|
|
507
|
+
generatedAt?: number;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function addDays(iso: string, n: number): string {
|
|
511
|
+
const d = new Date(`${iso}T00:00:00Z`);
|
|
512
|
+
d.setUTCDate(d.getUTCDate() + n);
|
|
513
|
+
return d.toISOString().slice(0, 10);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function eachDay(range: DateRange): string[] {
|
|
517
|
+
const out: string[] = [];
|
|
518
|
+
for (let d = range.from; d <= range.to; d = addDays(d, 1)) out.push(d);
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const emptySplit = (): ResourceSplit => ({
|
|
523
|
+
cpu: 0,
|
|
524
|
+
memory: 0,
|
|
525
|
+
disk: 0,
|
|
526
|
+
durableObjects: 0,
|
|
527
|
+
doRows: 0,
|
|
528
|
+
doStorage: 0,
|
|
529
|
+
workers: 0,
|
|
530
|
+
r2: 0,
|
|
531
|
+
workflows: 0,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/** Pure: prices the group's rows in the range. Rows for apps/namespaces/
|
|
535
|
+
* workers/buckets/workspaces outside the group are dropped from the group's
|
|
536
|
+
* figures (and counted in `account`), days with no rows are zero-filled, `llm`
|
|
537
|
+
* null means no LLM source (reported as unavailable, not $0). */
|
|
538
|
+
export function buildCostReport(
|
|
539
|
+
group: string,
|
|
540
|
+
cfg: CostGroupConfig,
|
|
541
|
+
usage: CloudflareUsage,
|
|
542
|
+
llm: LlmCostRow[] | null,
|
|
543
|
+
range: DateRange,
|
|
544
|
+
meta: CostReportMeta,
|
|
545
|
+
): CostReport {
|
|
546
|
+
const attribution = attributionOf(cfg, usage);
|
|
547
|
+
const workers = new Set(cfg.workers);
|
|
548
|
+
const namespaces = attribution.durableObjectNamespaces;
|
|
549
|
+
const buckets = attribution.r2Buckets;
|
|
550
|
+
const workflowNames = attribution.workflows;
|
|
551
|
+
const byResource = emptySplit();
|
|
552
|
+
let accountCloudUsd = 0;
|
|
553
|
+
const days: DailyCost[] = eachDay(range).map((date) => {
|
|
554
|
+
const containers: Record<string, ContainerCost> = {};
|
|
555
|
+
for (const row of usage.containers) {
|
|
556
|
+
if (row.date !== date) continue;
|
|
557
|
+
const c = containerCostUsd(row);
|
|
558
|
+
accountCloudUsd += c.total;
|
|
559
|
+
const label = cfg.containerApps[row.applicationId];
|
|
560
|
+
if (!label) continue;
|
|
561
|
+
const prev = containers[label];
|
|
562
|
+
containers[label] = prev
|
|
563
|
+
? {
|
|
564
|
+
cpu: prev.cpu + c.cpu,
|
|
565
|
+
memory: prev.memory + c.memory,
|
|
566
|
+
disk: prev.disk + c.disk,
|
|
567
|
+
total: prev.total + c.total,
|
|
568
|
+
}
|
|
569
|
+
: c;
|
|
570
|
+
byResource.cpu += c.cpu;
|
|
571
|
+
byResource.memory += c.memory;
|
|
572
|
+
byResource.disk += c.disk;
|
|
573
|
+
}
|
|
574
|
+
const durableObjects: Record<string, number> = {};
|
|
575
|
+
let doRowsUsd = 0;
|
|
576
|
+
for (const row of usage.durableObjectDays) {
|
|
577
|
+
if (row.date !== date) continue;
|
|
578
|
+
const duration = doDurationCostUsd(row.gbSeconds);
|
|
579
|
+
const rows = doRowsCostUsd(row.rowsRead, row.rowsWritten);
|
|
580
|
+
accountCloudUsd += duration + rows;
|
|
581
|
+
const label = namespaces[row.namespaceId];
|
|
582
|
+
if (!label) continue;
|
|
583
|
+
durableObjects[label] = (durableObjects[label] ?? 0) + duration;
|
|
584
|
+
byResource.durableObjects += duration;
|
|
585
|
+
doRowsUsd += rows;
|
|
586
|
+
}
|
|
587
|
+
byResource.doRows += doRowsUsd;
|
|
588
|
+
let doRequestsUsd = 0;
|
|
589
|
+
for (const row of usage.durableObjectRequests) {
|
|
590
|
+
if (row.date !== date) continue;
|
|
591
|
+
const usd = doRequestsCostUsd(row.requests);
|
|
592
|
+
accountCloudUsd += usd;
|
|
593
|
+
if (row.namespaceId in namespaces || (!row.namespaceId && workers.has(row.scriptName))) doRequestsUsd += usd;
|
|
594
|
+
}
|
|
595
|
+
byResource.durableObjects += doRequestsUsd;
|
|
596
|
+
let doStorageUsd = 0;
|
|
597
|
+
for (const row of usage.durableObjectStorage) {
|
|
598
|
+
if (row.date !== date) continue;
|
|
599
|
+
const usd = storageDayCostUsd(row.storedBytes, CLOUDFLARE_PRICES.doStorageGbMonth);
|
|
600
|
+
accountCloudUsd += usd;
|
|
601
|
+
if (row.namespaceId in namespaces) doStorageUsd += usd;
|
|
602
|
+
}
|
|
603
|
+
byResource.doStorage += doStorageUsd;
|
|
604
|
+
let workersUsd = 0;
|
|
605
|
+
for (const row of usage.workers) {
|
|
606
|
+
if (row.date !== date) continue;
|
|
607
|
+
const usd = workersCostUsd(row.requests, row.cpuTimeUs);
|
|
608
|
+
accountCloudUsd += usd;
|
|
609
|
+
if (workers.has(row.scriptName)) workersUsd += usd;
|
|
610
|
+
}
|
|
611
|
+
byResource.workers += workersUsd;
|
|
612
|
+
let r2Usd = 0;
|
|
613
|
+
for (const row of usage.r2Storage) {
|
|
614
|
+
if (row.date !== date) continue;
|
|
615
|
+
const usd = storageDayCostUsd(row.bytes, CLOUDFLARE_PRICES.r2StorageGbMonth);
|
|
616
|
+
accountCloudUsd += usd;
|
|
617
|
+
if (row.bucketName in buckets) r2Usd += usd;
|
|
618
|
+
}
|
|
619
|
+
for (const row of usage.r2Operations) {
|
|
620
|
+
if (row.date !== date) continue;
|
|
621
|
+
const usd = r2OperationsCostUsd(row.actionType, row.requests);
|
|
622
|
+
accountCloudUsd += usd;
|
|
623
|
+
if (row.bucketName in buckets) r2Usd += usd;
|
|
624
|
+
}
|
|
625
|
+
byResource.r2 += r2Usd;
|
|
626
|
+
let workflowsUsd = 0;
|
|
627
|
+
for (const row of usage.workflows) {
|
|
628
|
+
if (row.date !== date) continue;
|
|
629
|
+
const usd = workflowsCostUsd(row.steps, row.stateBytes);
|
|
630
|
+
accountCloudUsd += usd;
|
|
631
|
+
if (row.workflowName in workflowNames) workflowsUsd += usd;
|
|
632
|
+
}
|
|
633
|
+
byResource.workflows += workflowsUsd;
|
|
634
|
+
const llmRows =
|
|
635
|
+
llm && cfg.anthropicWorkspaceId
|
|
636
|
+
? llm.filter((r) => r.date === date && r.workspaceId === cfg.anthropicWorkspaceId)
|
|
637
|
+
: [];
|
|
638
|
+
const llmUsd = llmRows.reduce((s, r) => s + r.amountUsd, 0);
|
|
639
|
+
const llmEstimated = llmRows.some((r) => r.estimated === true);
|
|
640
|
+
const llmUnpricedTokens = llmRows.reduce((s, r) => s + (r.unpricedTokens ?? 0), 0);
|
|
641
|
+
const cloudUsd =
|
|
642
|
+
Object.values(containers).reduce((s, c) => s + c.total, 0) +
|
|
643
|
+
Object.values(durableObjects).reduce((s, v) => s + v, 0) +
|
|
644
|
+
doRequestsUsd +
|
|
645
|
+
doRowsUsd +
|
|
646
|
+
doStorageUsd +
|
|
647
|
+
workersUsd +
|
|
648
|
+
r2Usd +
|
|
649
|
+
workflowsUsd;
|
|
650
|
+
return {
|
|
651
|
+
date,
|
|
652
|
+
containers,
|
|
653
|
+
durableObjects,
|
|
654
|
+
doRequestsUsd,
|
|
655
|
+
doRowsUsd,
|
|
656
|
+
doStorageUsd,
|
|
657
|
+
workersUsd,
|
|
658
|
+
r2Usd,
|
|
659
|
+
workflowsUsd,
|
|
660
|
+
cloudUsd,
|
|
661
|
+
llmUsd,
|
|
662
|
+
llmEstimated,
|
|
663
|
+
llmUnpricedTokens,
|
|
664
|
+
total: cloudUsd + llmUsd,
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
const cloudUsd = days.reduce((s, d) => s + d.cloudUsd, 0);
|
|
668
|
+
const llmUsd = days.reduce((s, d) => s + d.llmUsd, 0);
|
|
669
|
+
return {
|
|
670
|
+
group,
|
|
671
|
+
label: cfg.label ?? group,
|
|
672
|
+
range,
|
|
673
|
+
llmAvailable: llm !== null && !!cfg.anthropicWorkspaceId,
|
|
674
|
+
days,
|
|
675
|
+
totals: { cloudUsd, llmUsd, total: cloudUsd + llmUsd, byResource },
|
|
676
|
+
generatedAt: meta.generatedAt ?? systemClock(),
|
|
677
|
+
account: {
|
|
678
|
+
id: meta.accountId,
|
|
679
|
+
...(meta.accountName ? { name: meta.accountName } : {}),
|
|
680
|
+
cloudUsd: accountCloudUsd,
|
|
681
|
+
},
|
|
682
|
+
attribution,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ---- Anthropic list prices ----------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
/** USD per million tokens of one kind, one model family. */
|
|
689
|
+
export interface AnthropicModelPrice {
|
|
690
|
+
input: number;
|
|
691
|
+
output: number;
|
|
692
|
+
cacheWrite5m: number;
|
|
693
|
+
cacheWrite1h: number;
|
|
694
|
+
cacheRead: number;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Anthropic list prices per model family (platform.claude.com/docs/en/about-claude/pricing;
|
|
698
|
+
* re-check when it moves). Keyed by the family id the usage report spells — a dated
|
|
699
|
+
* release (`claude-haiku-4-5-20251001`) resolves to its family through
|
|
700
|
+
* `anthropicPriceOf`. Only what the estimate needs: the open day priced at
|
|
701
|
+
* the rate it will bill at; the cost report remains the invoice. */
|
|
702
|
+
export const ANTHROPIC_PRICES: Record<string, AnthropicModelPrice> = {
|
|
703
|
+
"claude-fable-5-1": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 },
|
|
704
|
+
"claude-mythos-5-1": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 },
|
|
705
|
+
"claude-fable-5": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 },
|
|
706
|
+
"claude-mythos-5": { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 },
|
|
707
|
+
"claude-opus-5": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
708
|
+
"claude-opus-4-8": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
709
|
+
"claude-opus-4-7": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
710
|
+
"claude-opus-4-6": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
711
|
+
"claude-opus-4-5": { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 },
|
|
712
|
+
"claude-opus-4-1": { input: 15, output: 75, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
713
|
+
"claude-opus-4": { input: 15, output: 75, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.5 },
|
|
714
|
+
"claude-sonnet-5": { input: 2, output: 10, cacheWrite5m: 2.5, cacheWrite1h: 4, cacheRead: 0.2 },
|
|
715
|
+
"claude-sonnet-4-6": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
716
|
+
"claude-sonnet-4-5": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
717
|
+
"claude-sonnet-4": { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 },
|
|
718
|
+
"claude-haiku-4-5": { input: 1, output: 5, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.1 },
|
|
719
|
+
"claude-haiku-3-5": { input: 0.8, output: 4, cacheWrite5m: 1, cacheWrite1h: 1.6, cacheRead: 0.08 },
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const DATED_RELEASE_SUFFIX = /^-\d{8}$/;
|
|
723
|
+
|
|
724
|
+
/** The family prices of a model id: the id itself, or the id less a dated
|
|
725
|
+
* release suffix (`-YYYYMMDD`). Nothing else counts as "the same family" —
|
|
726
|
+
* `claude-fable-5-1` is not `claude-fable-5` with a suffix, and its cache
|
|
727
|
+
* reads bill differently. Unknown → undefined, never a guess. */
|
|
728
|
+
export function anthropicPriceOf(modelId: string): AnthropicModelPrice | undefined {
|
|
729
|
+
const exact = ANTHROPIC_PRICES[modelId];
|
|
730
|
+
if (exact) return exact;
|
|
731
|
+
for (const family of Object.keys(ANTHROPIC_PRICES)) {
|
|
732
|
+
if (modelId.startsWith(family) && DATED_RELEASE_SUFFIX.test(modelId.slice(family.length)))
|
|
733
|
+
return ANTHROPIC_PRICES[family];
|
|
734
|
+
}
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Token counts of one usage-report row, in the report's own kinds. */
|
|
739
|
+
export interface AnthropicTokens {
|
|
740
|
+
uncachedInput: number;
|
|
741
|
+
output: number;
|
|
742
|
+
cacheRead: number;
|
|
743
|
+
cacheWrite5m: number;
|
|
744
|
+
cacheWrite1h: number;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** What those tokens cost at the family's list prices; undefined for a model
|
|
748
|
+
* the table does not know (the caller reports the tokens, never $0). */
|
|
749
|
+
export function anthropicTokensCostUsd(modelId: string, t: AnthropicTokens): number | undefined {
|
|
750
|
+
const p = anthropicPriceOf(modelId);
|
|
751
|
+
if (!p) return undefined;
|
|
752
|
+
return (
|
|
753
|
+
(t.uncachedInput * p.input +
|
|
754
|
+
t.output * p.output +
|
|
755
|
+
t.cacheRead * p.cacheRead +
|
|
756
|
+
t.cacheWrite5m * p.cacheWrite5m +
|
|
757
|
+
t.cacheWrite1h * p.cacheWrite1h) /
|
|
758
|
+
1_000_000
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---- range ----------------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
const DEFAULT_DAYS = 30;
|
|
765
|
+
/** The widest range the page offers — and the window a costs snapshot is read for. */
|
|
766
|
+
export const MAX_DAYS = 90;
|
|
767
|
+
|
|
768
|
+
/** `?days=N` → a UTC date range ending today. Garbage → default; clamped 1..90. */
|
|
769
|
+
export function resolveRange(daysParam: string | null, now: Date = new Date(systemClock())): DateRange {
|
|
770
|
+
const parsed = daysParam === null ? NaN : Number(daysParam);
|
|
771
|
+
const days = Number.isInteger(parsed) ? Math.min(MAX_DAYS, Math.max(1, parsed)) : DEFAULT_DAYS;
|
|
772
|
+
const to = now.toISOString().slice(0, 10);
|
|
773
|
+
return { from: addDays(to, -(days - 1)), to, days, partialLastDay: true };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---- sources ----------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
export interface CloudflareUsageSource {
|
|
779
|
+
fetchUsage(range: DateRange): Promise<CloudflareUsage>;
|
|
780
|
+
}
|
|
781
|
+
export interface LlmCostSource {
|
|
782
|
+
/** null = this source has nothing (not configured); [] = configured, no spend. */
|
|
783
|
+
fetchDailyCost(range: DateRange): Promise<LlmCostRow[] | null>;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export class NullLlmCostSource implements LlmCostSource {
|
|
787
|
+
fetchDailyCost(_range: DateRange): Promise<LlmCostRow[] | null> {
|
|
788
|
+
return Promise.resolve(null);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const CF_GRAPHQL = "https://api.cloudflare.com/client/v4/graphql";
|
|
793
|
+
/** One request, every meter: the datasets Cloudflare bills a Workers deployment on. */
|
|
794
|
+
export const CF_USAGE_QUERY = `query SwitchboardCosts($accountTag: String!, $from: Time!, $to: Time!) {
|
|
795
|
+
viewer { accounts(filter: { accountTag: $accountTag }) {
|
|
796
|
+
containers: containersUsageAdaptiveGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
797
|
+
sum { cpuTimeSec allocatedMemory allocatedDisk } dimensions { date applicationId } }
|
|
798
|
+
durableObjectRequests: durableObjectsInvocationsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
799
|
+
sum { requests } dimensions { date scriptName namespaceId } }
|
|
800
|
+
durableObjectDays: durableObjectsPeriodicGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
801
|
+
sum { duration rowsRead rowsWritten } dimensions { date namespaceId } }
|
|
802
|
+
durableObjectStorage: durableObjectsSqlStorageGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
803
|
+
max { storedBytes } dimensions { date namespaceId } }
|
|
804
|
+
workers: workersInvocationsAdaptive(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
805
|
+
sum { requests cpuTimeUs } dimensions { date scriptName } }
|
|
806
|
+
r2Storage: r2StorageAdaptiveGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
807
|
+
max { payloadSize metadataSize } dimensions { date bucketName } }
|
|
808
|
+
r2Operations: r2OperationsAdaptiveGroups(limit: 10000, filter: { datetime_geq: $from, datetime_lt: $to }) {
|
|
809
|
+
sum { requests } dimensions { date bucketName actionType } }
|
|
810
|
+
workflows: workflowsAdaptiveGroups(limit: 10000, filter: { datetimeHour_geq: $from, datetimeHour_lt: $to }) {
|
|
811
|
+
count dimensions { date workflowName eventType } }
|
|
812
|
+
} }
|
|
813
|
+
}`;
|
|
814
|
+
|
|
815
|
+
/** The `workflowsAdaptiveGroups` events that are billable steps: one per step
|
|
816
|
+
* ending. `ATTEMPT_*` are the retries inside a step and `WORKFLOW_*` the
|
|
817
|
+
* instance's own lifecycle — neither is a step on the bill. */
|
|
818
|
+
export const WORKFLOW_STEP_EVENTS: ReadonlySet<string> = new Set(["STEP_SUCCESS", "STEP_FAILURE"]);
|
|
819
|
+
|
|
820
|
+
/** Fold the dataset's per-event rows into one steps figure per Workflow per day. */
|
|
821
|
+
export function workflowStepsFromEvents(
|
|
822
|
+
rows: ReadonlyArray<{ date: string; workflowName: string; eventType: string; count: number }>,
|
|
823
|
+
): WorkflowUsageRow[] {
|
|
824
|
+
const byKey = new Map<string, WorkflowUsageRow>();
|
|
825
|
+
for (const r of rows) {
|
|
826
|
+
if (!WORKFLOW_STEP_EVENTS.has(r.eventType) || !r.workflowName) continue;
|
|
827
|
+
const key = `${r.date} ${r.workflowName}`;
|
|
828
|
+
const row = byKey.get(key) ?? { date: r.date, workflowName: r.workflowName, steps: 0, stateBytes: 0 };
|
|
829
|
+
row.steps += r.count;
|
|
830
|
+
byKey.set(key, row);
|
|
831
|
+
}
|
|
832
|
+
return [...byKey.values()];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
836
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
837
|
+
|
|
838
|
+
type GqlGroup = {
|
|
839
|
+
dimensions?: Record<string, unknown>;
|
|
840
|
+
sum?: Record<string, unknown>;
|
|
841
|
+
max?: Record<string, unknown>;
|
|
842
|
+
count?: unknown;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
/** Reads Cloudflare's billing datasets over GraphQL with a scoped API token
|
|
846
|
+
* (Account Analytics:Read). The token travels only in the Authorization header
|
|
847
|
+
* and never appears in errors. */
|
|
848
|
+
export class CloudflareGraphqlUsageSource implements CloudflareUsageSource {
|
|
849
|
+
private readonly accountId: string;
|
|
850
|
+
private readonly token: string;
|
|
851
|
+
private readonly fetchImpl: typeof fetch;
|
|
852
|
+
constructor(opts: { accountId: string; token: string; fetchImpl?: typeof fetch }) {
|
|
853
|
+
this.accountId = opts.accountId;
|
|
854
|
+
this.token = opts.token;
|
|
855
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async fetchUsage(range: DateRange): Promise<CloudflareUsage> {
|
|
859
|
+
const res = await this.fetchImpl(CF_GRAPHQL, {
|
|
860
|
+
method: "POST",
|
|
861
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
862
|
+
body: JSON.stringify({
|
|
863
|
+
query: CF_USAGE_QUERY,
|
|
864
|
+
variables: {
|
|
865
|
+
accountTag: this.accountId,
|
|
866
|
+
from: `${range.from}T00:00:00Z`,
|
|
867
|
+
to: `${addDays(range.to, 1)}T00:00:00Z`,
|
|
868
|
+
},
|
|
869
|
+
}),
|
|
870
|
+
});
|
|
871
|
+
if (res.status !== 200) throw new Error(`cloudflare graphql ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
872
|
+
const body = (await res.json()) as { data?: unknown; errors?: { message?: string }[] | null };
|
|
873
|
+
if (Array.isArray(body.errors) && body.errors.length > 0)
|
|
874
|
+
throw new Error(`cloudflare graphql: ${body.errors.map((e) => e.message ?? "?").join("; ")}`);
|
|
875
|
+
const account = ((body.data as { viewer?: { accounts?: unknown[] } })?.viewer?.accounts?.[0] ?? {}) as {
|
|
876
|
+
containers?: GqlGroup[];
|
|
877
|
+
durableObjectRequests?: GqlGroup[];
|
|
878
|
+
durableObjectDays?: GqlGroup[];
|
|
879
|
+
durableObjectStorage?: GqlGroup[];
|
|
880
|
+
workers?: GqlGroup[];
|
|
881
|
+
r2Storage?: GqlGroup[];
|
|
882
|
+
r2Operations?: GqlGroup[];
|
|
883
|
+
workflows?: GqlGroup[];
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
containers: (account.containers ?? []).map((r) => ({
|
|
887
|
+
date: str(r.dimensions?.date),
|
|
888
|
+
applicationId: str(r.dimensions?.applicationId),
|
|
889
|
+
cpuTimeSec: num(r.sum?.cpuTimeSec),
|
|
890
|
+
allocatedMemoryByteSec: num(r.sum?.allocatedMemory),
|
|
891
|
+
allocatedDiskByteSec: num(r.sum?.allocatedDisk),
|
|
892
|
+
})),
|
|
893
|
+
durableObjectRequests: (account.durableObjectRequests ?? []).map((r) => ({
|
|
894
|
+
date: str(r.dimensions?.date),
|
|
895
|
+
scriptName: str(r.dimensions?.scriptName),
|
|
896
|
+
namespaceId: str(r.dimensions?.namespaceId),
|
|
897
|
+
requests: num(r.sum?.requests),
|
|
898
|
+
})),
|
|
899
|
+
durableObjectDays: (account.durableObjectDays ?? []).map((r) => ({
|
|
900
|
+
date: str(r.dimensions?.date),
|
|
901
|
+
namespaceId: str(r.dimensions?.namespaceId),
|
|
902
|
+
gbSeconds: num(r.sum?.duration),
|
|
903
|
+
rowsRead: num(r.sum?.rowsRead),
|
|
904
|
+
rowsWritten: num(r.sum?.rowsWritten),
|
|
905
|
+
})),
|
|
906
|
+
durableObjectStorage: (account.durableObjectStorage ?? []).map((r) => ({
|
|
907
|
+
date: str(r.dimensions?.date),
|
|
908
|
+
namespaceId: str(r.dimensions?.namespaceId),
|
|
909
|
+
storedBytes: num(r.max?.storedBytes),
|
|
910
|
+
})),
|
|
911
|
+
workers: (account.workers ?? []).map((r) => ({
|
|
912
|
+
date: str(r.dimensions?.date),
|
|
913
|
+
scriptName: str(r.dimensions?.scriptName),
|
|
914
|
+
requests: num(r.sum?.requests),
|
|
915
|
+
cpuTimeUs: num(r.sum?.cpuTimeUs),
|
|
916
|
+
})),
|
|
917
|
+
r2Storage: (account.r2Storage ?? []).map((r) => ({
|
|
918
|
+
date: str(r.dimensions?.date),
|
|
919
|
+
bucketName: str(r.dimensions?.bucketName),
|
|
920
|
+
bytes: num(r.max?.payloadSize) + num(r.max?.metadataSize),
|
|
921
|
+
})),
|
|
922
|
+
r2Operations: (account.r2Operations ?? []).map((r) => ({
|
|
923
|
+
date: str(r.dimensions?.date),
|
|
924
|
+
bucketName: str(r.dimensions?.bucketName),
|
|
925
|
+
actionType: str(r.dimensions?.actionType),
|
|
926
|
+
requests: num(r.sum?.requests),
|
|
927
|
+
})),
|
|
928
|
+
workflows: workflowStepsFromEvents(
|
|
929
|
+
(account.workflows ?? []).map((r) => ({
|
|
930
|
+
date: str(r.dimensions?.date),
|
|
931
|
+
workflowName: str(r.dimensions?.workflowName),
|
|
932
|
+
eventType: str(r.dimensions?.eventType),
|
|
933
|
+
count: num(r.count),
|
|
934
|
+
})),
|
|
935
|
+
),
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const ANTHROPIC_COST_REPORT = "https://api.anthropic.com/v1/organizations/cost_report";
|
|
941
|
+
const ANTHROPIC_USAGE_REPORT = "https://api.anthropic.com/v1/organizations/usage_report/messages";
|
|
942
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
943
|
+
const MAX_COST_PAGES = 20;
|
|
944
|
+
/** The open day plus two days of cost-report lag; older days without a bucket are not estimated. */
|
|
945
|
+
export const MAX_ESTIMATED_DAYS = 3;
|
|
946
|
+
|
|
947
|
+
type UsageReportBody = {
|
|
948
|
+
data?: {
|
|
949
|
+
starting_at?: string;
|
|
950
|
+
results?: {
|
|
951
|
+
workspace_id?: string | null;
|
|
952
|
+
model?: string;
|
|
953
|
+
uncached_input_tokens?: number;
|
|
954
|
+
output_tokens?: number;
|
|
955
|
+
cache_read_input_tokens?: number;
|
|
956
|
+
cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };
|
|
957
|
+
}[];
|
|
958
|
+
}[];
|
|
959
|
+
has_more?: boolean;
|
|
960
|
+
next_page?: string | null;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/** Anthropic Admin API cost report, grouped by workspace, all pages. Amounts
|
|
964
|
+
* arrive as decimal strings in cents; we return dollars. Requires an Admin API
|
|
965
|
+
* key — a regular API key is rejected upstream (401).
|
|
966
|
+
*
|
|
967
|
+
* The cost report is the invoice, and the invoice closes late: a day gets its
|
|
968
|
+
* bucket some hours after midnight UTC and the open day never has one (nor
|
|
969
|
+
* does the usage report at `1d`). So the days after the report's last bucket
|
|
970
|
+
* are priced from the HOURLY usage report at `ANTHROPIC_PRICES` and flagged
|
|
971
|
+
* `estimated` — the page can then show today, and a 1-day range means
|
|
972
|
+
* something. Read live when this was written: fourteen hourly buckets for the
|
|
973
|
+
* open day, none at `1d`; the cost report's last bucket was the day before. */
|
|
974
|
+
export class AnthropicCostReportSource implements LlmCostSource {
|
|
975
|
+
private readonly adminKey: string;
|
|
976
|
+
private readonly fetchImpl: typeof fetch;
|
|
977
|
+
constructor(opts: { adminKey: string; fetchImpl?: typeof fetch }) {
|
|
978
|
+
this.adminKey = opts.adminKey;
|
|
979
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async fetchDailyCost(range: DateRange): Promise<LlmCostRow[]> {
|
|
983
|
+
const report = await this.fetchCostReport(range);
|
|
984
|
+
// A bucket with no results is still a closed day: track buckets, not rows.
|
|
985
|
+
// Only the trailing MAX_ESTIMATED_DAYS are ever estimated: the report lags
|
|
986
|
+
// a day at most, so an older day with no bucket had no spend (a new
|
|
987
|
+
// workspace, a new organization) — and a page load must not turn into
|
|
988
|
+
// ninety live usage reads because the report answered nothing.
|
|
989
|
+
const open = eachDay(range).filter((date) => report.closedThrough === null || date > report.closedThrough);
|
|
990
|
+
const estimated = await Promise.all(open.slice(-MAX_ESTIMATED_DAYS).map((date) => this.estimateDay(date)));
|
|
991
|
+
return [...report.rows, ...estimated.flat()];
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The hourly usage report for one day, summed per workspace × model and
|
|
995
|
+
* priced at list; one row per workspace, `estimated`, with the tokens of
|
|
996
|
+
* any model the price table does not know reported as `unpricedTokens`. */
|
|
997
|
+
private async estimateDay(date: string): Promise<LlmCostRow[]> {
|
|
998
|
+
const perWorkspace = new Map<string | null, Map<string, AnthropicTokens>>();
|
|
999
|
+
let page: string | null = null;
|
|
1000
|
+
for (let i = 0; ; i++) {
|
|
1001
|
+
// ≤24 hourly buckets a day at limit=24: a second page is the API's
|
|
1002
|
+
// pagination quirk at most, twenty is the API having changed.
|
|
1003
|
+
if (i >= MAX_COST_PAGES)
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`anthropic usage_report: more than ${MAX_COST_PAGES} pages for ${date}; refusing a truncated estimate`,
|
|
1006
|
+
);
|
|
1007
|
+
const url = new URL(ANTHROPIC_USAGE_REPORT);
|
|
1008
|
+
url.searchParams.set("starting_at", `${date}T00:00:00Z`);
|
|
1009
|
+
url.searchParams.set("ending_at", `${addDays(date, 1)}T00:00:00Z`);
|
|
1010
|
+
url.searchParams.set("bucket_width", "1h");
|
|
1011
|
+
url.searchParams.append("group_by[]", "workspace_id");
|
|
1012
|
+
url.searchParams.append("group_by[]", "model");
|
|
1013
|
+
url.searchParams.set("limit", "24");
|
|
1014
|
+
if (page) url.searchParams.set("page", page);
|
|
1015
|
+
const res = await this.fetchImpl(url.toString(), {
|
|
1016
|
+
headers: { "x-api-key": this.adminKey, "anthropic-version": ANTHROPIC_VERSION },
|
|
1017
|
+
});
|
|
1018
|
+
if (res.status !== 200)
|
|
1019
|
+
throw new Error(`anthropic usage_report ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
1020
|
+
const body = (await res.json()) as UsageReportBody;
|
|
1021
|
+
for (const bucket of body.data ?? []) {
|
|
1022
|
+
for (const r of bucket.results ?? []) {
|
|
1023
|
+
const ws = r.workspace_id ?? null;
|
|
1024
|
+
const byModel = perWorkspace.get(ws) ?? new Map<string, AnthropicTokens>();
|
|
1025
|
+
const model = str(r.model);
|
|
1026
|
+
const t = byModel.get(model) ?? {
|
|
1027
|
+
uncachedInput: 0,
|
|
1028
|
+
output: 0,
|
|
1029
|
+
cacheRead: 0,
|
|
1030
|
+
cacheWrite5m: 0,
|
|
1031
|
+
cacheWrite1h: 0,
|
|
1032
|
+
};
|
|
1033
|
+
t.uncachedInput += num(r.uncached_input_tokens);
|
|
1034
|
+
t.output += num(r.output_tokens);
|
|
1035
|
+
t.cacheRead += num(r.cache_read_input_tokens);
|
|
1036
|
+
t.cacheWrite5m += num(r.cache_creation?.ephemeral_5m_input_tokens);
|
|
1037
|
+
t.cacheWrite1h += num(r.cache_creation?.ephemeral_1h_input_tokens);
|
|
1038
|
+
byModel.set(model, t);
|
|
1039
|
+
perWorkspace.set(ws, byModel);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (!body.has_more || !body.next_page) break;
|
|
1043
|
+
page = body.next_page;
|
|
1044
|
+
}
|
|
1045
|
+
const out: LlmCostRow[] = [];
|
|
1046
|
+
for (const [workspaceId, byModel] of perWorkspace) {
|
|
1047
|
+
let amountUsd = 0;
|
|
1048
|
+
let unpricedTokens = 0;
|
|
1049
|
+
for (const [model, t] of byModel) {
|
|
1050
|
+
const usd = anthropicTokensCostUsd(model, t);
|
|
1051
|
+
if (usd === undefined)
|
|
1052
|
+
unpricedTokens += t.uncachedInput + t.output + t.cacheRead + t.cacheWrite5m + t.cacheWrite1h;
|
|
1053
|
+
else amountUsd += usd;
|
|
1054
|
+
}
|
|
1055
|
+
out.push({ date, workspaceId, amountUsd, estimated: true, unpricedTokens });
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Every page of the cost report for the range, plus the last day it has a
|
|
1061
|
+
* bucket for (null when it has none in the range). */
|
|
1062
|
+
private async fetchCostReport(range: DateRange): Promise<{ rows: LlmCostRow[]; closedThrough: string | null }> {
|
|
1063
|
+
const rows: LlmCostRow[] = [];
|
|
1064
|
+
let closedThrough: string | null = null;
|
|
1065
|
+
// The cost report never holds the open day, and the API refuses a range
|
|
1066
|
+
// that begins there (400 "ending date must be after starting date", seen
|
|
1067
|
+
// live on `?days=1`): the query ends at the open day's start, exclusive,
|
|
1068
|
+
// and a range that is only the open day asks the cost report nothing.
|
|
1069
|
+
const endExclusive = range.partialLastDay ? range.to : addDays(range.to, 1);
|
|
1070
|
+
if (range.from >= endExclusive) return { rows, closedThrough };
|
|
1071
|
+
let page: string | null = null;
|
|
1072
|
+
for (let i = 0; ; i++) {
|
|
1073
|
+
// Like the non-USD check: refuse rather than mis-sum. A ≤90-day range at
|
|
1074
|
+
// limit=31 is at most 3 pages, so hitting the cap means the API changed.
|
|
1075
|
+
if (i >= MAX_COST_PAGES)
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`anthropic cost_report: more than ${MAX_COST_PAGES} pages; refusing to return a truncated total`,
|
|
1078
|
+
);
|
|
1079
|
+
const url = new URL(ANTHROPIC_COST_REPORT);
|
|
1080
|
+
url.searchParams.set("starting_at", `${range.from}T00:00:00Z`);
|
|
1081
|
+
url.searchParams.set("ending_at", `${endExclusive}T00:00:00Z`);
|
|
1082
|
+
url.searchParams.set("bucket_width", "1d");
|
|
1083
|
+
url.searchParams.append("group_by[]", "workspace_id");
|
|
1084
|
+
url.searchParams.set("limit", "31");
|
|
1085
|
+
if (page) url.searchParams.set("page", page);
|
|
1086
|
+
const res = await this.fetchImpl(url.toString(), {
|
|
1087
|
+
headers: { "x-api-key": this.adminKey, "anthropic-version": ANTHROPIC_VERSION },
|
|
1088
|
+
});
|
|
1089
|
+
if (res.status !== 200)
|
|
1090
|
+
throw new Error(`anthropic cost_report ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
1091
|
+
const body = (await res.json()) as {
|
|
1092
|
+
data?: {
|
|
1093
|
+
starting_at?: string;
|
|
1094
|
+
results?: { amount?: string; currency?: string; workspace_id?: string | null }[];
|
|
1095
|
+
}[];
|
|
1096
|
+
has_more?: boolean;
|
|
1097
|
+
next_page?: string | null;
|
|
1098
|
+
};
|
|
1099
|
+
for (const bucket of body.data ?? []) {
|
|
1100
|
+
const date = str(bucket.starting_at).slice(0, 10);
|
|
1101
|
+
if (closedThrough === null || date > closedThrough) closedThrough = date;
|
|
1102
|
+
for (const r of bucket.results ?? []) {
|
|
1103
|
+
if (r.currency !== "USD") throw new Error(`anthropic cost_report: unexpected currency ${r.currency ?? "?"}`);
|
|
1104
|
+
const cents = Number(r.amount);
|
|
1105
|
+
if (!Number.isFinite(cents)) throw new Error("anthropic cost_report: non-numeric amount");
|
|
1106
|
+
rows.push({ date, workspaceId: r.workspace_id ?? null, amountUsd: cents / 100 });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (!body.has_more || !body.next_page) break;
|
|
1110
|
+
page = body.next_page;
|
|
1111
|
+
}
|
|
1112
|
+
return { rows, closedThrough };
|
|
1113
|
+
}
|
|
1114
|
+
}
|