@frockbot/plugin-billing 0.3.38 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-billing",
3
- "version": "0.3.38",
3
+ "version": "0.3.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,11 +23,11 @@
23
23
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
24
24
  },
25
25
  "dependencies": {
26
- "@frockbot/client-core": "0.3.38",
27
- "@frockbot/client-ui": "0.3.38",
28
- "@frockbot/kernel-contracts": "0.3.38",
29
- "@frockbot/plugin-flock": "0.3.38",
30
- "@frockbot/plugin-shell": "0.3.38",
26
+ "@frockbot/client-core": "0.3.40",
27
+ "@frockbot/client-ui": "0.3.40",
28
+ "@frockbot/kernel-contracts": "0.3.40",
29
+ "@frockbot/plugin-flock": "0.3.40",
30
+ "@frockbot/plugin-shell": "0.3.40",
31
31
  "cordis": "4.0.0-rc.8",
32
32
  "vue": "3.5.41"
33
33
  },
package/src/bot.test.ts CHANGED
@@ -52,6 +52,81 @@ describe("usage projection", () => {
52
52
  ]);
53
53
  });
54
54
 
55
+ test("prices two short platform-model turns in cents, not dollars", () => {
56
+ /*
57
+ * The defect this pins. A Bot on the platform default runs `@frock/auto`,
58
+ * which was absent from the price table, so every Turn a User has ever had
59
+ * was priced at the unknown-model fallback of $10 per million input
60
+ * tokens. The token counts here are the real shape of a two-sentence Turn:
61
+ * the transport reports nothing, so the loop estimates from the normalized
62
+ * request, and that request carries the system prompt and every tool
63
+ * schema whatever the person typed. Two of them read as "$0.50".
64
+ */
65
+ const turnEvents = (turn: number, requestId: string): SessionEvent[] =>
66
+ [
67
+ {
68
+ type: "model/usage",
69
+ seq: turn * 10,
70
+ timestamp: `2026-09-05T0${turn}:00:00.000Z`,
71
+ turn,
72
+ step: 1,
73
+ requestId,
74
+ provider: "flock-ai",
75
+ model: "@frock/auto",
76
+ modelBinding: { connectionId: "flock-ai-ambient" },
77
+ inputTokens: 24_000,
78
+ outputTokens: 40,
79
+ cachedInputTokens: 0,
80
+ reasoningTokens: 0,
81
+ latencyMs: 1_200,
82
+ estimated: true,
83
+ },
84
+ {
85
+ type: "turn/end",
86
+ seq: turn * 10 + 1,
87
+ timestamp: `2026-09-05T0${turn}:00:01.000Z`,
88
+ turn,
89
+ outcome: "completed",
90
+ },
91
+ ] as SessionEvent[];
92
+
93
+ const entries = [
94
+ ...usageEntriesFromTurnV1({
95
+ botId: "bot-a",
96
+ runId: "run-a",
97
+ turn: 1,
98
+ events: turnEvents(1, "request-1"),
99
+ }),
100
+ ...usageEntriesFromTurnV1({
101
+ botId: "bot-a",
102
+ runId: "run-a",
103
+ turn: 2,
104
+ events: turnEvents(2, "request-2"),
105
+ }),
106
+ ];
107
+
108
+ // 24,000 input at $0.44/M plus 40 output at $1.32/M, per Turn.
109
+ expect(entries.map((entry) => entry.costMicros)).toEqual([10_613, 10_613]);
110
+ // Priced, not guessed: the total is two cents rather than fifty.
111
+ expect(entries.every((entry) => entry.unknownPrice)).toBe(false);
112
+ expect(entries.reduce((total, entry) => total + entry.costMicros, 0)).toBe(
113
+ 21_226,
114
+ );
115
+ // The legacy spelling of the same model is the same price, not a fallback.
116
+ expect(
117
+ usageEntriesFromTurnV1({
118
+ botId: "bot-a",
119
+ runId: "run-b",
120
+ turn: 1,
121
+ events: turnEvents(1, "request-3").map((event) =>
122
+ event.type === "model/usage"
123
+ ? { ...event, model: "@flock/auto" }
124
+ : event,
125
+ ) as SessionEvent[],
126
+ })[0],
127
+ ).toMatchObject({ costMicros: 10_613, unknownPrice: false });
128
+ });
129
+
55
130
  test("keeps entries until an idempotent sink accepts them", async () => {
56
131
  const values = new Map<string, unknown>();
57
132
  const outbox = new UsageOutboxV1({
@@ -1,10 +1,28 @@
1
+ /** One cent, in the micro-dollars the ledger records. */
2
+ const CENT_IN_MICROS_V1 = 10_000;
3
+
4
+ const USD_V1 = new Intl.NumberFormat("en", {
5
+ style: "currency",
6
+ currency: "USD",
7
+ minimumFractionDigits: 2,
8
+ maximumFractionDigits: 2,
9
+ });
10
+
11
+ /**
12
+ * Money, to the cent — except when rounding to the cent would say nothing
13
+ * happened.
14
+ *
15
+ * The ledger counts micro-dollars and a short Turn costs a few hundred of
16
+ * them, so two cents' worth of conversation used to render as "$0.00" beside
17
+ * a list of Bots that had plainly run. "<$0.01" is the honest reading of a
18
+ * real, sub-cent amount; exactly zero still reads as zero, because that is a
19
+ * different fact. Totals are never floored to build a bigger number: every
20
+ * figure here is the sum of what was recorded.
21
+ */
1
22
  export function formatCostV1(costMicros: number): string {
2
- return new Intl.NumberFormat("en", {
3
- style: "currency",
4
- currency: "USD",
5
- minimumFractionDigits: 2,
6
- maximumFractionDigits: 2,
7
- }).format(costMicros / 1_000_000);
23
+ if (costMicros > 0 && costMicros < CENT_IN_MICROS_V1) return "<$0.01";
24
+ if (costMicros < 0 && costMicros > -CENT_IN_MICROS_V1) return ">-$0.01";
25
+ return USD_V1.format(costMicros / 1_000_000);
8
26
  }
9
27
 
10
28
  export function shortModelNameV1(value: string): string {
@@ -83,6 +83,17 @@ describe("billing client contribution", () => {
83
83
  expect(mounted.state.value.report?.bots[0]?.id).toBe("bot-a");
84
84
  });
85
85
 
86
+ test("says a real sub-cent amount rather than rounding it to nothing", () => {
87
+ // Two short platform-model Turns: real spend, well under a cent.
88
+ expect(formatCostV1(21_226)).toBe("$0.02");
89
+ expect(formatCostV1(10_613)).toBe("$0.01");
90
+ expect(formatCostV1(300)).toBe("<$0.01");
91
+ expect(formatCostV1(9_999)).toBe("<$0.01");
92
+ // Nothing spent is a different fact from a little spent.
93
+ expect(formatCostV1(0)).toBe("$0.00");
94
+ expect(formatCostV1(10_000)).toBe("$0.01");
95
+ });
96
+
86
97
  test("formats dollars and compact model names for the rendered view", () => {
87
98
  expect(formatCostV1(1_250_000)).toBe("$1.25");
88
99
  expect(shortModelNameV1("ollama-cloud/glm-5.3-flash:cloud")).toBe(
package/src/pricing.ts CHANGED
@@ -50,6 +50,25 @@ export const MODEL_PRICE_TABLE_V1: readonly ModelPriceV1[] = [
50
50
  outputUsdPerMillion: 1.32,
51
51
  sourceUrl: CLOUDFLARE_SOURCE,
52
52
  },
53
+ /*
54
+ * Auto is what almost every Bot runs on, and it was absent from this table:
55
+ * every platform-model Turn was priced at the unknown-model fallback, which
56
+ * is $10/$50 per million — twenty-three times what the model it routes to
57
+ * actually costs. Two short Turns read as fifty cents.
58
+ *
59
+ * Auto is a Cloudflare AI Gateway dynamic route over Frock AI's text
60
+ * models, and Frock AI publishes exactly one, so the route's price is that
61
+ * model's price. A second text model would make this an approximation and
62
+ * the honest fix then is to record the model the route chose.
63
+ */
64
+ {
65
+ provider: "flock-ai",
66
+ model: "@frock/auto",
67
+ inputUsdPerMillion: 0.44,
68
+ cachedInputUsdPerMillion: 0.014,
69
+ outputUsdPerMillion: 1.32,
70
+ sourceUrl: CLOUDFLARE_SOURCE,
71
+ },
53
72
  ...[
54
73
  ["gpt-6-astra", 10, 1, 50],
55
74
  ["gpt-5.6", 4, 0.4, 20],