@coreplane/switchboard 1.253.0 → 1.254.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 (130) hide show
  1. package/dist/assets/config/config.example.yaml +40 -11
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +310 -7
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/grants.ts +6 -0
  7. package/dist/assets/src/core/authz/policy.ts +11 -0
  8. package/dist/assets/src/core/budgets.ts +8 -0
  9. package/dist/assets/src/core/coordinator/driver.ts +9 -0
  10. package/dist/assets/src/core/costs.ts +221 -2
  11. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  12. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  13. package/dist/assets/src/core/plane/decide.ts +203 -0
  14. package/dist/assets/src/core/provider.ts +11 -0
  15. package/dist/assets/src/core/runEvents.ts +26 -0
  16. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  17. package/dist/assets/src/core/runRecord.ts +15 -0
  18. package/dist/assets/src/core/runUsage.ts +41 -9
  19. package/dist/assets/src/core/ship/coordinator.ts +45 -9
  20. package/dist/assets/web/dist/.vite/manifest.json +450 -430
  21. package/dist/assets/web/dist/assets/{AppShell-Bmz8nzz3.js → AppShell-CSXDCV4x.js} +1 -1
  22. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  23. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  24. package/dist/assets/web/dist/assets/{DeliveryPage-C7jaEJip.js → DeliveryPage-ngPsO2to.js} +1 -1
  25. package/dist/assets/web/dist/assets/{HomePage-cWbupQzy.js → HomePage-DvxTHzPx.js} +3 -3
  26. package/dist/assets/web/dist/assets/{InputMenu-C9E7YoII.js → InputMenu-BdZXnibG.js} +1 -1
  27. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  28. package/dist/assets/web/dist/assets/{NotFoundPage-4nlrq7DP.js → NotFoundPage-DHuWyjlq.js} +1 -1
  29. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  30. package/dist/assets/web/dist/assets/{PlanePage-BNy4tSgJ.js → PlanePage-DpWfiX4C.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-Dbtiq6_J.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CKtz9H1o.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  34. package/dist/assets/web/dist/assets/{RunRoutePage-Clh68nw9.js → RunRoutePage-ysJBY8xQ.js} +4 -4
  35. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  36. package/dist/assets/web/dist/assets/{RunsTabs-DotpuCSP.js → RunsTabs-kHbbxVHx.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-C8FYoZy5.js → ScheduledPage-BuLmfcbG.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingSelect-CSiBQCSX.js → SettingSelect-BfID5nF4.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SettingsPage-CkYIhLUl.js → SettingsPage-BujWkdU_.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-C0TUgnC1.js → StatusDot-C8Bc0pTX.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-CN5cZdl3.js → Tooltip-BWwJx27K.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-D_EtAl8r.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  43. package/dist/assets/web/dist/assets/{angular-html-qW40ZL9x.js → angular-html-C8b5YTHf.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-PUtZU8Cq.js → angular-ts-C3f4WYNe.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-D3fC7Qjd.js → apl-DSHxUJc3.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-Bi3JFHhh.js → astro-BSGCCBP6.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-CSpsf9Z9.js → blade-DFeYrMKk.js} +1 -1
  48. package/dist/assets/web/dist/assets/{c-DE_b1oWP.js → c-L_QOnFVv.js} +1 -1
  49. package/dist/assets/web/dist/assets/{chapel-CWXd9giL.js → chapel-DLQWUwhu.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cobol-CnMOmWNF.js → cobol-1DlMmrEO.js} +1 -1
  51. package/dist/assets/web/dist/assets/{coffee-B9WvURu8.js → coffee-BTB6os6N.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cpp-BZAJXlWI.js → cpp-BvJI2z3u.js} +1 -1
  53. package/dist/assets/web/dist/assets/{crystal-Bm48wAlb.js → crystal-B11wGW_h.js} +1 -1
  54. package/dist/assets/web/dist/assets/{css-WtMeC3ab.js → css-BiIWx5Pw.js} +1 -1
  55. package/dist/assets/web/dist/assets/{dist-B1Mf2JbM.js → dist-CpnyQGOb.js} +2 -2
  56. package/dist/assets/web/dist/assets/{edge-BmwNa6Ss.js → edge-nSglEXaD.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elixir-BotfP-Mn.js → elixir-x7o8qMl8.js} +1 -1
  58. package/dist/assets/web/dist/assets/{elm-DAFc6laN.js → elm-DGrRyqTQ.js} +1 -1
  59. package/dist/assets/web/dist/assets/{erb-QkOON3bB.js → erb--_dh58Nw.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-BPs-dd73.js → git-rebase-DbpUXwyg.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-D8eooepk.js → glimmer-js-BG0CuZG5.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-B0bRiDVg.js → glimmer-ts-CQIl5qyR.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Bv6bMe1n.js → glsl-9PJNQTcw.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-Bh9y-n9o.js → graphql-CzVTzjEI.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-DSEbn7Ir.js → hack-BhCmAz3Q.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-0NW1-z1f.js → haml-BmI12COR.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-BfgCHFbK.js → handlebars-Cib-ISTS.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-CQHc0gQ5.js → html-D3lDapvI.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-B0Tm7wLP.js → html-derivative-DRPY8vOK.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-GAuU3aOF.js → http-B9VttK6m.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-CrDFbF5B.js → hurl-C0saNJYj.js} +1 -1
  72. package/dist/assets/web/dist/assets/{indexRow-BxKswdlK.js → indexRow-DABQtONT.js} +1 -1
  73. package/dist/assets/web/dist/assets/{java-DYv5KhUe.js → java-BCFbtYAj.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-32SiLGg0.js → javascript-nwBjnrhh.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja--gJDUGYa.js → jinja-CMYO9byN.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-BlKLGzZH.js → jison-Cko8HnE_.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-BICanD1N.js → json-Dzv__YsX.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-CgW3-uFT.js → jsx-CTFxagih.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-BErRpwwL.js → julia-_Xvten1-.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-CordHv0h.js → just-bLps4qOu.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-D7LVPLbi.js → latex-D6UaYsUP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-3ehz2i4U.js → liquid-CoDIeULa.js} +1 -1
  83. package/dist/assets/web/dist/assets/{lua-C9ppSVrm.js → lua-D1KFRnVf.js} +1 -1
  84. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  85. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  86. package/dist/assets/web/dist/assets/{marko-xBZLUBEJ.js → marko-B5N5XcOw.js} +1 -1
  87. package/dist/assets/web/dist/assets/{mdc-BJxGoVgD.js → mdc-uxzjQO3q.js} +1 -1
  88. package/dist/assets/web/dist/assets/{nginx-B-sZmpsb.js → nginx-BMJxs2TK.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nim--s9wRABK.js → nim-DqQ3-8p1.js} +1 -1
  90. package/dist/assets/web/dist/assets/{org-DoUvssoI.js → org-DsY6LK59.js} +1 -1
  91. package/dist/assets/web/dist/assets/{perl-B-vGHKOy.js → perl--pq9iJFp.js} +1 -1
  92. package/dist/assets/web/dist/assets/{php-CerWx_Xb.js → php-RHCMbsM2.js} +1 -1
  93. package/dist/assets/web/dist/assets/{pug-CzKrBMb1.js → pug-CFNq_FCp.js} +1 -1
  94. package/dist/assets/web/dist/assets/{qml-Bs8L8I6k.js → qml-C9Acx9E8.js} +1 -1
  95. package/dist/assets/web/dist/assets/{r-w6uG6mob.js → r-DxlU8FBd.js} +1 -1
  96. package/dist/assets/web/dist/assets/{razor-dz2oj2bd.js → razor-CLJ2kKih.js} +1 -1
  97. package/dist/assets/web/dist/assets/{regexp-CfQdprnY.js → regexp-CzmdGoEp.js} +1 -1
  98. package/dist/assets/web/dist/assets/{rst-DAOaX2tb.js → rst-D9yuxBET.js} +1 -1
  99. package/dist/assets/web/dist/assets/{ruby-QcCe4udB.js → ruby-CIRcEvnD.js} +1 -1
  100. package/dist/assets/web/dist/assets/{sas-BrM4lAAc.js → sas-LOl-uUPh.js} +1 -1
  101. package/dist/assets/web/dist/assets/{scss-DVbJXoUb.js → scss-BX2_sZHY.js} +1 -1
  102. package/dist/assets/web/dist/assets/{shellscript-09fP2hZf.js → shellscript-4d-QvKkY.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellsession-Qgr8xjZF.js → shellsession-DhB16UGy.js} +1 -1
  104. package/dist/assets/web/dist/assets/{soy-DIYeNTuz.js → soy-Cm1xTUBr.js} +1 -1
  105. package/dist/assets/web/dist/assets/{sql-CzLR9msV.js → sql-BsfcBJdu.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sseReplay-BYo2EZKy.js → sseReplay-IzTdD4-3.js} +6 -6
  107. package/dist/assets/web/dist/assets/{stata-zCY1ZzH0.js → stata-Dwat3BSw.js} +1 -1
  108. package/dist/assets/web/dist/assets/{surrealql-CJIjTRV4.js → surrealql-P4rtxzAW.js} +1 -1
  109. package/dist/assets/web/dist/assets/{svelte-C5OTPFZg.js → svelte-DTcsi8S8.js} +1 -1
  110. package/dist/assets/web/dist/assets/{templ-DYj-UICF.js → templ-D-kP0Tg5.js} +1 -1
  111. package/dist/assets/web/dist/assets/{tex-xcie-3Gd.js → tex-D-4vuedK.js} +1 -1
  112. package/dist/assets/web/dist/assets/{ts-tags-CoN86VTt.js → ts-tags-jEEWYxsu.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tsx-n3-No_Br.js → tsx-CFnlrVzT.js} +1 -1
  114. package/dist/assets/web/dist/assets/{twig-DPiJ7Vp1.js → twig-D5rGrPjs.js} +1 -1
  115. package/dist/assets/web/dist/assets/{typescript-BP6HBaOp.js → typescript-nYIQ35or.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typst-Do9ZjsVP.js → typst-3-UX8dna.js} +1 -1
  117. package/dist/assets/web/dist/assets/{vue-CzAeXNRY.js → vue-CLIPIX03.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-html-D7iYmYjd.js → vue-html-Ci9CpkHU.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-vine-DtrMRsJx.js → vue-vine-DL5pEYYk.js} +1 -1
  120. package/dist/assets/web/dist/assets/{xml-DGJHo1c0.js → xml-bO80qikC.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xsl-Q00kvNgW.js → xsl-BPze60Ej.js} +1 -1
  122. package/dist/assets/web/dist/assets/{yaml-F-UGKaiO.js → yaml-BQUSon_o.js} +1 -1
  123. package/dist/cli.js +36177 -34396
  124. package/package.json +1 -1
  125. package/dist/assets/web/dist/assets/CostsPage-CxTKdpzU.js +0 -2
  126. package/dist/assets/web/dist/assets/PendingTurnRow-C-pUgyyX.js +0 -1
  127. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +0 -1
  128. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +0 -1
  129. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +0 -28
  130. package/dist/assets/web/dist/assets/main-DKFCM-sy.css +0 -1
@@ -4,6 +4,8 @@ import {
4
4
  type AnthropicTokens,
5
5
  type ModelPriceTable,
6
6
  } from "./modelPricing.js";
7
+ import { parseModelRef } from "./provider.js";
8
+ import type { RunUsage } from "./runUsage.js";
7
9
  import { systemClock } from "./trace/clock.js";
8
10
  // Spend report: what a group of deployed pieces ("switchboard" = the bot
9
11
  // Worker + its containers, the resident/sandbox/memory Workers) costs per day,
@@ -511,6 +513,9 @@ export interface CostReport {
511
513
  /** The snapshot the report was built from (src/core/costsSnapshot.ts): when it was
512
514
  * taken, by whom, how long the read took. Absent on a report built straight from the sources. */
513
515
  snapshot?: { takenAt: string; takenBy: string; durationMs: number };
516
+ /** Each biller's daily invoice tie-out (item 4d); absent when no provider block is known
517
+ * (a report built without the wiring) or run history is off. */
518
+ billers?: BillerTieOut[];
514
519
  }
515
520
 
516
521
  /** What the report carries beyond the priced rows: the account behind the
@@ -729,6 +734,208 @@ export interface LlmCostSource {
729
734
  fetchDailyCost(range: DateRange): Promise<LlmCostRow[] | null>;
730
735
  }
731
736
 
737
+ // ---- invoices per biller (costs.md item 4d) ---------------------------------------------
738
+
739
+ /** One day of a biller's invoice, as its own billing API reports it. */
740
+ export interface InvoiceDay {
741
+ date: string;
742
+ /** The biller's invoiced dollars for the day (an aggregator's fee + BYOK upstream). */
743
+ usd: number;
744
+ /** An aggregator's own charge (OpenRouter's `usage`) — what the summed `feeUsd` ties against. */
745
+ feeUsd?: number;
746
+ /** An aggregator's BYOK upstream half (`byok_usage_inference`) — what the remainder ties against. */
747
+ byokUsd?: number;
748
+ }
749
+
750
+ /** One invoice source per provider block that names an invoice API and an
751
+ * `invoiceKeyEnv`: the biller's own billing figures, per UTC day. */
752
+ export interface LlmInvoiceSource {
753
+ /** The provider block this invoice bills — the `biller` its `model.turn` rows carry. */
754
+ readonly biller: string;
755
+ fetchInvoice(range: DateRange): Promise<InvoiceDay[]>;
756
+ }
757
+
758
+ /** One day of a biller's tie-out: its invoice beside the summed `model.turn` rows. */
759
+ export interface BillerTieOutDay {
760
+ date: string;
761
+ /** The biller's invoiced dollars; absent when the block names no source. */
762
+ invoiceUsd?: number;
763
+ invoiceFeeUsd?: number;
764
+ invoiceByokUsd?: number;
765
+ /** The summed `model.turn` dollars whose ref names this block. */
766
+ attributedUsd: number;
767
+ /** The summed BYOK fees — the half an aggregator's `usage` ties against. */
768
+ attributedFeeUsd?: number;
769
+ /** `attributedUsd − attributedFeeUsd` — the half `byok_usage_inference` ties against. */
770
+ attributedUpstreamUsd?: number;
771
+ }
772
+
773
+ /** One biller's daily tie-out over the range. */
774
+ export interface BillerTieOut {
775
+ biller: string;
776
+ /** false = the block names no invoice source: it ties out against nothing and the page says "no invoice". */
777
+ invoice: boolean;
778
+ days: BillerTieOutDay[];
779
+ totals: { invoiceUsd: number; attributedUsd: number };
780
+ }
781
+
782
+ /** The block a ref bills to, or undefined for a ref that names none (`unknown`). */
783
+ const billerOfRef = (ref: string): string | undefined => (ref.includes("/") ? parseModelRef(ref).provider : undefined);
784
+
785
+ /**
786
+ * Pure: each biller's invoice compared with the summed `model.turn` rows whose
787
+ * ref names that block, per UTC day of the range. An aggregator's invoice
788
+ * halves (`feeUsd`, `byokUsd`) sit beside the summed fees and the remainder. A
789
+ * model whose `usd` is null or absent (unpriced, or from before the meter row)
790
+ * contributes nothing to `attributedUsd` — an understated side is honest, an
791
+ * invented one is not, so a day the source returned no row for keeps its
792
+ * invoice side absent (the page renders —): the source holds closed days only,
793
+ * and a trailing uninvoiced day beside real spend is not a $0 invoice. A day
794
+ * with neither an invoice row nor a nonzero attributed figure is dropped; a
795
+ * biller without a source (`invoice: false`) ties out against nothing.
796
+ */
797
+ export function buildBillerTieOuts(
798
+ billers: ReadonlyArray<{ name: string; invoice: boolean }>,
799
+ invoices: Readonly<Record<string, InvoiceDay[]>>,
800
+ cells: ReadonlyArray<{ day: string; usage: RunUsage }>,
801
+ range: DateRange,
802
+ ): BillerTieOut[] {
803
+ return billers.map(({ name, invoice }) => {
804
+ const attributed = new Map<string, { usd: number; fee: number; hasFee: boolean }>();
805
+ for (const cell of cells) {
806
+ if (cell.day < range.from || cell.day > range.to) continue;
807
+ for (const [ref, m] of Object.entries(cell.usage.byModel)) {
808
+ if (billerOfRef(ref) !== name) continue;
809
+ const day = attributed.get(cell.day) ?? { usd: 0, fee: 0, hasFee: false };
810
+ if (typeof m.usd === "number") day.usd += m.usd;
811
+ if (m.feeUsd !== undefined) {
812
+ day.fee += m.feeUsd;
813
+ day.hasFee = true;
814
+ }
815
+ attributed.set(cell.day, day);
816
+ }
817
+ }
818
+ const invoiced = new Map<string, InvoiceDay>();
819
+ if (invoice)
820
+ for (const d of invoices[name] ?? []) if (d.date >= range.from && d.date <= range.to) invoiced.set(d.date, d);
821
+ const dates = [...new Set([...attributed.keys(), ...invoiced.keys()])].sort().filter((date) => {
822
+ const a = attributed.get(date);
823
+ return invoiced.has(date) || (a !== undefined && (a.usd !== 0 || a.hasFee));
824
+ });
825
+ const days: BillerTieOutDay[] = dates.map((date) => {
826
+ const a = attributed.get(date);
827
+ const inv = invoiced.get(date);
828
+ return {
829
+ date,
830
+ ...(inv ? { invoiceUsd: inv.usd } : {}),
831
+ ...(inv?.feeUsd !== undefined ? { invoiceFeeUsd: inv.feeUsd } : {}),
832
+ ...(inv?.byokUsd !== undefined ? { invoiceByokUsd: inv.byokUsd } : {}),
833
+ attributedUsd: a?.usd ?? 0,
834
+ ...(a?.hasFee ? { attributedFeeUsd: a.fee, attributedUpstreamUsd: a.usd - a.fee } : {}),
835
+ };
836
+ });
837
+ return {
838
+ biller: name,
839
+ invoice,
840
+ days,
841
+ totals: {
842
+ invoiceUsd: days.reduce((s, d) => s + (d.invoiceUsd ?? 0), 0),
843
+ attributedUsd: days.reduce((s, d) => s + d.attributedUsd, 0),
844
+ },
845
+ };
846
+ });
847
+ }
848
+
849
+ /** OpenRouter's activity endpoint (`GET /api/v1/activity`, a management key —
850
+ * never the inference key): per-day, per-model rows whose `usage` is the
851
+ * aggregator's own charge and `byok_usage_inference` the BYOK upstream cost.
852
+ * Summed per day; the key travels only in the Authorization header and never
853
+ * appears in errors. The endpoint holds roughly the last 30 days, one short of
854
+ * the 31-day snapshot range: the oldest window day may come back with no row,
855
+ * and the tie-out then leaves that day's invoice side absent (rendered —),
856
+ * never an invented $0. */
857
+ export const OPENROUTER_ACTIVITY_URL = "https://openrouter.ai/api/v1/activity";
858
+ export class OpenRouterActivitySource implements LlmInvoiceSource {
859
+ readonly biller: string;
860
+ private readonly key: string;
861
+ private readonly fetchImpl: typeof fetch;
862
+ constructor(opts: { biller: string; managementKey: string; fetchImpl?: typeof fetch }) {
863
+ this.biller = opts.biller;
864
+ this.key = opts.managementKey;
865
+ this.fetchImpl = opts.fetchImpl ?? fetch;
866
+ }
867
+
868
+ async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
869
+ const res = await this.fetchImpl(OPENROUTER_ACTIVITY_URL, {
870
+ headers: { authorization: `Bearer ${this.key}` },
871
+ });
872
+ if (res.status !== 200) throw new Error(`openrouter activity ${res.status}: ${(await res.text()).slice(0, 300)}`);
873
+ const body = (await res.json()) as {
874
+ data?: { date?: string; usage?: number; byok_usage_inference?: number }[];
875
+ };
876
+ const byDay = new Map<string, { fee: number; byok: number }>();
877
+ for (const r of body.data ?? []) {
878
+ const date = str(r.date).slice(0, 10);
879
+ if (!date || date < range.from || date > range.to) continue;
880
+ const d = byDay.get(date) ?? { fee: 0, byok: 0 };
881
+ d.fee += num(r.usage);
882
+ d.byok += num(r.byok_usage_inference);
883
+ byDay.set(date, d);
884
+ }
885
+ return [...byDay]
886
+ .sort(([a], [b]) => (a < b ? -1 : 1))
887
+ .map(([date, d]) => ({ date, usd: d.fee + d.byok, feeUsd: d.fee, byokUsd: d.byok }));
888
+ }
889
+ }
890
+
891
+ /** OpenAI's organization costs (`GET /v1/organization/costs`, an admin key):
892
+ * daily buckets whose results carry `amount.value` dollars. Paginated like the
893
+ * Anthropic report, a non-USD row refused the same way, the key never in errors. */
894
+ export const OPENAI_COSTS_URL = "https://api.openai.com/v1/organization/costs";
895
+ export class OpenAICostsSource implements LlmInvoiceSource {
896
+ readonly biller: string;
897
+ private readonly key: string;
898
+ private readonly fetchImpl: typeof fetch;
899
+ constructor(opts: { biller: string; adminKey: string; fetchImpl?: typeof fetch }) {
900
+ this.biller = opts.biller;
901
+ this.key = opts.adminKey;
902
+ this.fetchImpl = opts.fetchImpl ?? fetch;
903
+ }
904
+
905
+ async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
906
+ const byDay = new Map<string, number>();
907
+ let page: string | null = null;
908
+ for (let i = 0; ; i++) {
909
+ if (i >= MAX_COST_PAGES)
910
+ throw new Error(`openai organization costs: more than ${MAX_COST_PAGES} pages; refusing a truncated total`);
911
+ const url = new URL(OPENAI_COSTS_URL);
912
+ url.searchParams.set("start_time", String(Date.parse(`${range.from}T00:00:00Z`) / 1000));
913
+ url.searchParams.set("end_time", String(Date.parse(`${addDays(range.to, 1)}T00:00:00Z`) / 1000));
914
+ url.searchParams.set("limit", String(MAX_DAYS));
915
+ if (page) url.searchParams.set("page", page);
916
+ const res = await this.fetchImpl(url.toString(), { headers: { authorization: `Bearer ${this.key}` } });
917
+ if (res.status !== 200)
918
+ throw new Error(`openai organization costs ${res.status}: ${(await res.text()).slice(0, 300)}`);
919
+ const body = (await res.json()) as {
920
+ data?: { start_time?: number; results?: { amount?: { value?: number; currency?: string } }[] }[];
921
+ has_more?: boolean;
922
+ next_page?: string | null;
923
+ };
924
+ for (const bucket of body.data ?? []) {
925
+ const date = new Date(num(bucket.start_time) * 1000).toISOString().slice(0, 10);
926
+ for (const r of bucket.results ?? []) {
927
+ const currency = str(r.amount?.currency).toLowerCase();
928
+ if (currency !== "usd") throw new Error(`openai organization costs: unexpected currency ${currency || "?"}`);
929
+ byDay.set(date, (byDay.get(date) ?? 0) + num(r.amount?.value));
930
+ }
931
+ }
932
+ if (!body.has_more || !body.next_page) break;
933
+ page = body.next_page;
934
+ }
935
+ return [...byDay].sort(([a], [b]) => (a < b ? -1 : 1)).map(([date, usd]) => ({ date, usd }));
936
+ }
937
+ }
938
+
732
939
  export class NullLlmCostSource implements LlmCostSource {
733
940
  fetchDailyCost(_range: DateRange): Promise<LlmCostRow[] | null> {
734
941
  return Promise.resolve(null);
@@ -917,14 +1124,26 @@ type UsageReportBody = {
917
1124
  * `estimated` — the page can then show today, and a 1-day range means
918
1125
  * something. Read live when this was written: fourteen hourly buckets for the
919
1126
  * open day, none at `1d`; the cost report's last bucket was the day before. */
920
- export class AnthropicCostReportSource implements LlmCostSource {
1127
+ export class AnthropicCostReportSource implements LlmCostSource, LlmInvoiceSource {
1128
+ /** The provider block this cost report bills; `anthropic` unless a block renames it. */
1129
+ readonly biller: string;
921
1130
  private readonly adminKey: string;
922
1131
  private readonly fetchImpl: typeof fetch;
923
- constructor(opts: { adminKey: string; fetchImpl?: typeof fetch }) {
1132
+ constructor(opts: { adminKey: string; biller?: string; fetchImpl?: typeof fetch }) {
1133
+ this.biller = opts.biller ?? "anthropic";
924
1134
  this.adminKey = opts.adminKey;
925
1135
  this.fetchImpl = opts.fetchImpl ?? fetch;
926
1136
  }
927
1137
 
1138
+ /** The biller's invoice per day: the cost report's closed days summed across
1139
+ * workspaces — the invoice, never the estimate (costs.md item 4d). */
1140
+ async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
1141
+ const report = await this.fetchCostReport(range);
1142
+ const byDay = new Map<string, number>();
1143
+ for (const r of report.rows) byDay.set(r.date, (byDay.get(r.date) ?? 0) + r.amountUsd);
1144
+ return [...byDay].sort(([a], [b]) => (a < b ? -1 : 1)).map(([date, usd]) => ({ date, usd }));
1145
+ }
1146
+
928
1147
  async fetchDailyCost(range: DateRange): Promise<LlmCostRow[]> {
929
1148
  const report = await this.fetchCostReport(range);
930
1149
  // A bucket with no results is still a closed day: track buckets, not rows.
@@ -1,4 +1,4 @@
1
- import type { CloudflareUsage, DateRange, LlmCostRow } from "./costs.js";
1
+ import type { CloudflareUsage, DateRange, InvoiceDay, LlmCostRow } from "./costs.js";
2
2
  import { isRunUsageReport, type RunUsageReport } from "./runUsage.js";
3
3
  import {
4
4
  DEFAULT_STATE_WORKER_TOKEN_ENV,
@@ -46,6 +46,9 @@ export interface CostsSnapshot {
46
46
  usage: CloudflareUsage;
47
47
  /** Anthropic's daily cost rows (the open days estimated); null when no LLM source is configured. */
48
48
  llm: LlmCostRow[] | null;
49
+ /** Each configured biller's invoice days (costs.md item 4d); a biller whose source failed
50
+ * is omitted (warned by name), and the field is absent on a snapshot from before it. */
51
+ invoices?: Record<string, InvoiceDay[]>;
49
52
  /** The run history's per-user usage over the window; null with run history off. */
50
53
  runUsage: RunUsageReport | null;
51
54
  }
@@ -102,6 +105,20 @@ function isLlmRows(v: unknown): v is LlmCostRow[] {
102
105
  );
103
106
  }
104
107
 
108
+ function isInvoiceDays(v: unknown): v is InvoiceDay[] {
109
+ return (
110
+ Array.isArray(v) &&
111
+ v.every((row) => {
112
+ if (typeof row !== "object" || row === null) return false;
113
+ const r = row as Record<string, unknown>;
114
+ if (typeof r.date !== "string" || !ISO_DAY.test(r.date) || !isFiniteNumber(r.usd)) return false;
115
+ return (
116
+ (r.feeUsd === undefined || isFiniteNumber(r.feeUsd)) && (r.byokUsd === undefined || isFiniteNumber(r.byokUsd))
117
+ );
118
+ })
119
+ );
120
+ }
121
+
105
122
  /** The fields the arithmetic reads, present and of the right kind; the rows are the sources'. */
106
123
  export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
107
124
  if (typeof v !== "object" || v === null) return false;
@@ -113,6 +130,10 @@ export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
113
130
  if (typeof usage !== "object" || usage === null) return false;
114
131
  if (!USAGE_DATASETS.every((name) => isDatedRows(usage[name]))) return false;
115
132
  if (s.llm !== null && !isLlmRows(s.llm)) return false;
133
+ if (s.invoices !== undefined) {
134
+ if (typeof s.invoices !== "object" || s.invoices === null || Array.isArray(s.invoices)) return false;
135
+ if (!Object.values(s.invoices).every(isInvoiceDays)) return false;
136
+ }
116
137
  return s.runUsage === null || isRunUsageReport(s.runUsage);
117
138
  }
118
139
 
@@ -0,0 +1,294 @@
1
+ import type { RunEvent, ShipRoundOutcome } from "./runEvents.js";
2
+ import type { UnitEnding } from "./ship/coordinator.js";
3
+
4
+ // The standing fold (record 0065): one pure, node-free reader of a hosted ship
5
+ // parent's `ship_round` and `ship_unit` events, yielding the pipeline's
6
+ // standing — per unit a stage from a closed vocabulary, its latest round, its
7
+ // pull request and the stamp that set the stage. The registry
8
+ // (`appendToBacklog`), the ledger view (`runsService.ts`) and the record
9
+ // assembly (`dispatch/record.ts`) all call it, so the three row sources can
10
+ // never disagree. Total over any event list; it never throws.
11
+
12
+ /** The stage vocabulary (record 0065): what every surface says a unit is in. */
13
+ export type Stage = "coding" | "review" | "fix" | "approved" | "merge-ready" | "merged" | "idle" | "ended";
14
+
15
+ export const STAGES = [
16
+ "coding",
17
+ "review",
18
+ "fix",
19
+ "approved",
20
+ "merge-ready",
21
+ "merged",
22
+ "idle",
23
+ "ended",
24
+ ] as const satisfies readonly Stage[];
25
+
26
+ /** The stages a unit is still open in: what `PipelineSummary.current` lists. */
27
+ const OPEN_STAGES: ReadonlySet<Stage> = new Set(["coding", "review", "fix", "approved", "idle"]);
28
+
29
+ /** One unit's standing: the stage, the latest round index bound to it, the
30
+ * segment count (raised by a `continued` round and by a reopen from idle),
31
+ * when the stage was set, and the latest pull request and thread seen. A
32
+ * closed unit's `detail` names the ending kind the vocabulary folds to
33
+ * `ended`. */
34
+ export interface PipelineUnit {
35
+ unit: string;
36
+ stage: Stage;
37
+ round: number;
38
+ segment: number;
39
+ since: number;
40
+ pr?: number;
41
+ threadKey?: string;
42
+ detail?: string;
43
+ }
44
+
45
+ /** A round of an unnamed unit: a `ship_round` with no companion `ship_unit`
46
+ * at its stamp — the shape of records written before `ship_unit` existed. */
47
+ export interface RoundRow {
48
+ index: number;
49
+ agent: string;
50
+ outcome: string;
51
+ at: number;
52
+ }
53
+
54
+ /** One stage change the fold made — the pipeline page's log row. */
55
+ export interface StandingChange {
56
+ at: number;
57
+ unit: string;
58
+ stage: Stage;
59
+ round?: number;
60
+ detail?: string;
61
+ }
62
+
63
+ /** What `pipelineStandingOf` returns: the units in order of first appearance,
64
+ * the rounds no unit names, and the changes in event order. The plan's unit
65
+ * total is not on the parent's stream and is not returned. */
66
+ export interface PipelineStanding {
67
+ units: PipelineUnit[];
68
+ unnamedRounds: RoundRow[];
69
+ changes: StandingChange[];
70
+ }
71
+
72
+ /** The standing as the row sources carry it (`RunSummary.pipeline`,
73
+ * `RunView.pipeline`, `RunRecord.pipeline`): the open units whole, a count
74
+ * per stage, the units seen — and, for an older record whose rounds name no
75
+ * unit, the newest unnamed round, so its row can still say
76
+ * `round 2 · review` (record 0065, the pipeline row). */
77
+ export interface PipelineSummary {
78
+ current: PipelineUnit[];
79
+ counts: Record<Stage, number>;
80
+ total: number;
81
+ lastRound?: RoundRow;
82
+ }
83
+
84
+ /** What a bound round does to its unit's stage, by outcome (record 0065's
85
+ * mapping). `start` reads the agent and the index (`coding` at round 0, `fix`
86
+ * at any higher index, `review` for the review agent); `continue` is `coding`
87
+ * with the segment count raised; `hold` leaves the stage alone — the ending
88
+ * will say. Pinned to the union: a new outcome fails the build here. */
89
+ export const ROUND_STAGE = {
90
+ started: "start",
91
+ pr_opened: "review",
92
+ completed: "merged",
93
+ approve: "approved",
94
+ request_changes: "fix",
95
+ no_verdict: "hold",
96
+ checks_failed: "fix",
97
+ aborted: "hold",
98
+ stopped: "hold",
99
+ continued: "continue",
100
+ idle: "idle",
101
+ } as const satisfies Record<ShipRoundOutcome, Stage | "start" | "continue" | "hold">;
102
+
103
+ /** What an unbound `ship_unit` ending word does to its unit (record 0065):
104
+ * `merged` and `already_landed` fold to `merged`, `merge_ready` to
105
+ * `merge-ready`, `idle` stays open as `idle`, `continued` is not an ending
106
+ * and holds the stage, and every other kind closes the unit as `ended` with
107
+ * the kind as detail. `blocked` — a unit status the driver posts to
108
+ * `unit-end` for a unit that never started — ends the same way. Pinned to
109
+ * the union: a new ending kind fails the build here. */
110
+ export const ENDING_STAGE = {
111
+ merged: "merged",
112
+ already_landed: "merged",
113
+ merge_ready: "merge-ready",
114
+ merge_refused: "ended",
115
+ round_cap: "ended",
116
+ wall_clock_cap: "ended",
117
+ review_pending: "ended",
118
+ stopped: "ended",
119
+ aborted: "ended",
120
+ continued: "hold",
121
+ no_verdict: "ended",
122
+ interrupted: "ended",
123
+ refused: "ended",
124
+ idle: "idle",
125
+ blocked: "ended",
126
+ } as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
127
+
128
+ type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
129
+
130
+ function isShipEvent(e: RunEvent): e is ShipEvent {
131
+ return e.type === "ship_round" || e.type === "ship_unit";
132
+ }
133
+
134
+ /** Fold a run's events into the pipeline's standing. One pass, `seq` order
135
+ * assumed (every carrier's events are). A `ship_round` binds to the
136
+ * `ship_unit` published next at the same stamp — the round route's own batch
137
+ * contract — and that companion contributes only its pull request and thread
138
+ * key, never a stage; a round with no companion is a round of an unnamed
139
+ * unit. Total: an unknown word holds the stage, and the fold never throws. */
140
+ export function pipelineStandingOf(events: readonly RunEvent[]): PipelineStanding {
141
+ const ship = events.filter(isShipEvent);
142
+ const units = new Map<string, PipelineUnit>();
143
+ const unnamedRounds: RoundRow[] = [];
144
+ const changes: StandingChange[] = [];
145
+
146
+ const setStage = (u: PipelineUnit, stage: Stage, at: number, detail?: string): void => {
147
+ if (u.stage === stage && u.detail === detail) return;
148
+ u.stage = stage;
149
+ u.since = at;
150
+ if (detail !== undefined) u.detail = detail;
151
+ else delete u.detail;
152
+ changes.push({ at, unit: u.unit, stage, round: u.round, ...(detail !== undefined ? { detail } : {}) });
153
+ };
154
+
155
+ /** The unit's row, created at first sight: at `coding` for a plain entry, or
156
+ * already at a bound round's stage for a unit adopted mid-pipeline (a
157
+ * resume at review skips round 0 — its first bound round says so). */
158
+ const enter = (name: string, at: number, stage: Stage, round: number, detail?: string): PipelineUnit => {
159
+ const existing = units.get(name);
160
+ if (existing) return existing;
161
+ const u: PipelineUnit = { unit: name, stage, round, segment: 1, since: at };
162
+ if (detail !== undefined) u.detail = detail;
163
+ units.set(name, u);
164
+ changes.push({ at, unit: name, stage, round, ...(detail !== undefined ? { detail } : {}) });
165
+ return u;
166
+ };
167
+
168
+ const takeFacts = (u: PipelineUnit, e: Extract<ShipEvent, { type: "ship_unit" }>): void => {
169
+ if (e.pr !== undefined) u.pr = e.pr;
170
+ if (e.threadKey !== undefined) u.threadKey = e.threadKey;
171
+ };
172
+
173
+ const consumed = new Set<number>();
174
+ for (let i = 0; i < ship.length; i++) {
175
+ if (consumed.has(i)) continue;
176
+ const e = ship[i]!;
177
+ const at = e.at ?? 0;
178
+ if (e.type === "ship_round") {
179
+ const next = ship[i + 1];
180
+ const companion = next !== undefined && next.type === "ship_unit" && (next.at ?? 0) === at ? next : undefined;
181
+ if (companion === undefined) {
182
+ unnamedRounds.push({ index: e.index, agent: e.agent, outcome: e.outcome, at });
183
+ continue;
184
+ }
185
+ consumed.add(i + 1);
186
+ const mapped = (ROUND_STAGE as Record<string, Stage | "start" | "continue" | "hold">)[e.outcome] ?? "hold";
187
+ const stage: Stage =
188
+ mapped === "start"
189
+ ? e.agent === "review"
190
+ ? "review"
191
+ : e.index >= 1
192
+ ? "fix"
193
+ : "coding"
194
+ : mapped === "continue"
195
+ ? "coding"
196
+ : mapped === "hold"
197
+ ? "coding" // a new unit's first word the mapping holds on: it is in play, and coding is the entry stage
198
+ : mapped;
199
+ const known = units.has(companion.unit);
200
+ const u = enter(companion.unit, at, stage, e.index);
201
+ takeFacts(u, companion); // the companion's pull request and thread only — never its state (record 0065)
202
+ u.round = e.index;
203
+ if (mapped === "continue" && known) u.segment += 1;
204
+ if (mapped !== "hold") setStage(u, stage, at);
205
+ } else {
206
+ const state = e.state;
207
+ if (state === "started") {
208
+ const existing = units.get(e.unit);
209
+ if (existing === undefined) {
210
+ const u = enter(e.unit, at, "coding", 0);
211
+ takeFacts(u, e);
212
+ } else {
213
+ // An idle or closed unit reopens at its next `started` — the next
214
+ // segment; a started while the unit is open holds where it stands.
215
+ if (existing.stage === "idle" || existing.stage === "ended") {
216
+ existing.segment += 1;
217
+ setStage(existing, "coding", at);
218
+ }
219
+ takeFacts(existing, e);
220
+ }
221
+ continue;
222
+ }
223
+ const mapped = (ENDING_STAGE as Record<string, Stage | "hold">)[state];
224
+ const u =
225
+ units.get(e.unit) ??
226
+ // An ending for a unit that never started (`blocked` with no prior,
227
+ // or a route that skipped `unit-start`): the row still enters.
228
+ enter(e.unit, at, mapped !== undefined && mapped !== "hold" ? mapped : "coding", 0, endedDetail(state, mapped));
229
+ takeFacts(u, e);
230
+ // A round-outcome word on an unbound `ship_unit` (a route that skipped
231
+ // its round) and any word the fold does not know hold the stage.
232
+ if (mapped !== undefined && mapped !== "hold") setStage(u, mapped, at, endedDetail(state, mapped));
233
+ }
234
+ }
235
+ return { units: [...units.values()], unnamedRounds, changes };
236
+ }
237
+
238
+ function endedDetail(state: string, mapped: Stage | "hold" | undefined): string | undefined {
239
+ return mapped === "ended" ? state : undefined;
240
+ }
241
+
242
+ /** The standing as the three row sources carry it: the open units whole, the
243
+ * counts per stage (zero-filled — the shape is stable), the units seen, and
244
+ * an older record's newest unnamed round. */
245
+ export function summaryOfStanding(standing: PipelineStanding): PipelineSummary {
246
+ const counts = Object.fromEntries(STAGES.map((s) => [s, 0])) as Record<Stage, number>;
247
+ for (const u of standing.units) counts[u.stage]++;
248
+ const lastRound = standing.unnamedRounds.at(-1);
249
+ return {
250
+ current: standing.units.filter((u) => OPEN_STAGES.has(u.stage)),
251
+ counts,
252
+ total: standing.units.length,
253
+ ...(lastRound !== undefined ? { lastRound } : {}),
254
+ };
255
+ }
256
+
257
+ /** The one carrier helper: the fold's summary for a run's events, or nothing
258
+ * for a run with no ship facts — so a model run's row never grows the field. */
259
+ export function pipelineOfEvents(events: readonly RunEvent[]): PipelineSummary | undefined {
260
+ const standing = pipelineStandingOf(events);
261
+ if (standing.units.length === 0 && standing.unnamedRounds.length === 0) return undefined;
262
+ return summaryOfStanding(standing);
263
+ }
264
+
265
+ const isFinite_ = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
266
+
267
+ function isPipelineUnitShape(v: unknown): v is PipelineUnit {
268
+ if (typeof v !== "object" || v === null) return false;
269
+ const u = v as Record<string, unknown>;
270
+ if (typeof u.unit !== "string" || !STAGES.includes(u.stage as Stage)) return false;
271
+ if (!isFinite_(u.round) || !isFinite_(u.segment) || !isFinite_(u.since)) return false;
272
+ if (u.pr !== undefined && !isFinite_(u.pr)) return false;
273
+ if (u.threadKey !== undefined && typeof u.threadKey !== "string") return false;
274
+ if (u.detail !== undefined && typeof u.detail !== "string") return false;
275
+ return true;
276
+ }
277
+
278
+ /** Structural check on a stored summary (`RunRecord.pipeline`): the shape
279
+ * above, nothing else — an older reader accepts a newer record's field. */
280
+ export function isPipelineSummaryShape(v: unknown): v is PipelineSummary {
281
+ if (typeof v !== "object" || v === null) return false;
282
+ const s = v as Record<string, unknown>;
283
+ if (!Array.isArray(s.current) || !s.current.every(isPipelineUnitShape)) return false;
284
+ if (typeof s.counts !== "object" || s.counts === null) return false;
285
+ if (!Object.values(s.counts).every(isFinite_)) return false;
286
+ if (!isFinite_(s.total)) return false;
287
+ if (s.lastRound !== undefined) {
288
+ if (typeof s.lastRound !== "object" || s.lastRound === null) return false;
289
+ const r = s.lastRound as Record<string, unknown>;
290
+ if (!isFinite_(r.index) || typeof r.agent !== "string" || typeof r.outcome !== "string" || !isFinite_(r.at))
291
+ return false;
292
+ }
293
+ return true;
294
+ }