@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.
Files changed (155) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  3. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +223 -65
  5. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
  7. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
  8. package/dist/assets/deploy/secrets.manifest.json +4 -4
  9. package/dist/assets/package-lock.json +207 -3
  10. package/dist/assets/package.json +2 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +82 -0
  13. package/dist/assets/src/core/authz/actor.ts +33 -2
  14. package/dist/assets/src/core/authz/policy.ts +6 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +12 -1
  16. package/dist/assets/src/core/coordinator/driver.ts +13 -0
  17. package/dist/assets/src/core/costs.ts +1114 -0
  18. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  19. package/dist/assets/src/core/delivery.ts +4 -11
  20. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  21. package/dist/assets/src/core/ingressTokens.ts +27 -13
  22. package/dist/assets/src/core/runLedger/types.ts +3 -0
  23. package/dist/assets/src/core/runRecord.ts +10 -3
  24. package/dist/assets/src/core/schedules.ts +2 -1
  25. package/dist/assets/src/core/ship/coordinator.ts +70 -0
  26. package/dist/assets/src/core/snapshotAge.ts +30 -0
  27. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  28. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  29. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  30. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  31. package/dist/assets/src/execution/seedPlan.ts +267 -0
  32. package/dist/assets/src/mcp/registry.ts +7 -0
  33. package/dist/assets/web/dist/.vite/manifest.json +507 -427
  34. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
  35. package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
  36. package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
  37. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
  38. package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
  39. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
  40. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
  41. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
  42. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
  43. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
  44. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
  45. package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
  46. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
  47. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  48. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
  49. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
  50. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
  51. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
  52. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
  54. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
  55. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
  56. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
  57. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
  58. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
  59. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
  60. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
  61. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
  62. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
  63. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
  64. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  65. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.js} +2 -2
  66. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  67. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BYS8FtgQ.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
  69. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
  70. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.js} +1 -1
  71. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  72. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  73. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-9rY3p-Rw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
  77. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
  78. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
  79. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
  80. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
  81. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
  83. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
  84. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.js} +1 -1
  85. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  86. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-Cse3JGb0.js} +1 -1
  87. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
  88. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
  89. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
  91. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
  93. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
  94. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
  95. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
  96. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
  97. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  98. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
  99. package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
  100. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
  101. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
  102. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
  103. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
  104. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
  105. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
  106. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
  107. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
  108. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
  109. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
  110. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
  111. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
  112. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
  113. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
  114. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
  116. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
  117. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  118. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
  119. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
  120. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
  121. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
  122. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
  123. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
  124. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
  125. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
  126. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
  127. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
  128. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
  129. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
  130. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
  131. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
  133. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
  136. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
  138. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
  139. package/dist/cli.js +2586 -1603
  140. package/package.json +1 -1
  141. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  142. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  143. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  144. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  145. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  146. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  147. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  148. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  149. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  150. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  151. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  152. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  153. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  154. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  155. 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
+ }