@frockbot/plugin-billing 0.0.0 → 0.3.21
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/frockbot.json +28 -0
- package/package.json +41 -6
- package/src/backend.ts +57 -0
- package/src/bot.test.ts +178 -0
- package/src/bot.ts +157 -0
- package/src/client/BotSpendLine.vue +49 -0
- package/src/client/UsageSection.vue +191 -0
- package/src/client/format.ts +13 -0
- package/src/client/index.test.ts +92 -0
- package/src/client/index.ts +56 -0
- package/src/client/state.ts +13 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +5 -0
- package/src/manifest.ts +3 -0
- package/src/pricing.test.ts +56 -0
- package/src/pricing.ts +176 -0
- package/src/shared.ts +355 -0
- package/src/store.test.ts +115 -0
- package/src/store.ts +334 -0
- package/src/user.test.ts +84 -0
- package/src/user.ts +145 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 4,
|
|
3
|
+
"id": "billing",
|
|
4
|
+
"displayName": "Usage",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"defaultEnablement": "enabled",
|
|
7
|
+
"compatibility": { "frockbot": ">=0.0.1" },
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"flock": ">=0.0.1",
|
|
10
|
+
"settings": ">=0.0.1",
|
|
11
|
+
"ui-theme": ">=0.0.1"
|
|
12
|
+
},
|
|
13
|
+
"contributions": {
|
|
14
|
+
"backend": [
|
|
15
|
+
{ "entry": "./user", "host": "user" },
|
|
16
|
+
{ "entry": "./backend", "host": "gateway" }
|
|
17
|
+
],
|
|
18
|
+
"client": {
|
|
19
|
+
"entry": "./client",
|
|
20
|
+
"mounts": [
|
|
21
|
+
{ "slot": "frockbot.user-settings-primary-sections", "order": 20 },
|
|
22
|
+
{ "slot": "frockbot.bot-settings-primary-sections", "order": 30 }
|
|
23
|
+
],
|
|
24
|
+
"outlets": []
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"permissions": []
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-billing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.21",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./backend": "./src/backend.ts",
|
|
9
|
+
"./bot": "./src/bot.ts",
|
|
10
|
+
"./client": "./src/client/index.ts",
|
|
11
|
+
"./manifest": "./src/manifest.ts",
|
|
12
|
+
"./package.json": "./package.json",
|
|
13
|
+
"./pricing": "./src/pricing.ts",
|
|
14
|
+
"./shared": "./src/shared.ts",
|
|
15
|
+
"./store": "./src/store.ts",
|
|
16
|
+
"./user": "./src/user.ts"
|
|
17
|
+
},
|
|
18
|
+
"frockbot": {
|
|
19
|
+
"manifest": "./frockbot.json"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "bun test src",
|
|
23
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@frockbot/client-core": "0.3.21",
|
|
27
|
+
"@frockbot/client-ui": "0.3.21",
|
|
28
|
+
"@frockbot/kernel-contracts": "0.3.21",
|
|
29
|
+
"@frockbot/plugin-flock": "0.3.21",
|
|
30
|
+
"@frockbot/plugin-shell": "0.3.21",
|
|
31
|
+
"cordis": "4.0.0-rc.8",
|
|
32
|
+
"vue": "3.5.41"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "1.4.0",
|
|
36
|
+
"@vitejs/plugin-vue": "6.0.8",
|
|
37
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
|
|
38
|
+
"vite": "8.2.2",
|
|
39
|
+
"vue-tsc": "3.3.10"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
6
44
|
"repository": {
|
|
7
45
|
"type": "git",
|
|
8
46
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
47
|
"directory": "packages/plugin-billing"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
48
|
}
|
|
14
49
|
}
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineGatewayContribution } from "@frockbot/kernel-contracts/contributions";
|
|
2
|
+
import type { Plugin } from "cordis";
|
|
3
|
+
import type { UsageReportV1 } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
export interface BillingGatewayHostV1 {
|
|
6
|
+
readUsage(userId: string): Promise<UsageReportV1>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BillingBackendRouteContributionV1 {
|
|
10
|
+
packageId: string;
|
|
11
|
+
route(
|
|
12
|
+
request: Request,
|
|
13
|
+
url: URL,
|
|
14
|
+
context: { userId?: string },
|
|
15
|
+
): Promise<Response | undefined>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createBillingBackendContribution(
|
|
19
|
+
host: BillingGatewayHostV1,
|
|
20
|
+
): BillingBackendRouteContributionV1 {
|
|
21
|
+
return {
|
|
22
|
+
packageId: "billing",
|
|
23
|
+
async route(request, url, context) {
|
|
24
|
+
if (url.pathname !== "/api/usage" || !context.userId) return undefined;
|
|
25
|
+
if (request.method !== "GET") {
|
|
26
|
+
return Response.json({ error: "method not allowed" }, { status: 405 });
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return Response.json(await host.readUsage(context.userId));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return Response.json(
|
|
32
|
+
{
|
|
33
|
+
error: error instanceof Error ? error.message : "usage read failed",
|
|
34
|
+
},
|
|
35
|
+
{ status: 500 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export namespace createBillingBackendContribution {
|
|
43
|
+
export function plugin(
|
|
44
|
+
host: BillingGatewayHostV1,
|
|
45
|
+
lifecycle: { mount(value: BillingBackendRouteContributionV1): () => void },
|
|
46
|
+
): Plugin {
|
|
47
|
+
return () => lifecycle.mount(createBillingBackendContribution(host));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const backendContribution = defineGatewayContribution<
|
|
52
|
+
BillingGatewayHostV1,
|
|
53
|
+
BillingBackendRouteContributionV1
|
|
54
|
+
>({
|
|
55
|
+
specifier: "@frockbot/plugin-billing/backend",
|
|
56
|
+
create: createBillingBackendContribution.plugin,
|
|
57
|
+
});
|
package/src/bot.test.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { UsageOutboxV1, usageEntriesFromTurnV1 } from "./bot.js";
|
|
4
|
+
import type { UsageEntryV1 } from "./shared.js";
|
|
5
|
+
|
|
6
|
+
describe("usage projection", () => {
|
|
7
|
+
test("prices one bounded entry from each model/usage event in the settled turn", () => {
|
|
8
|
+
const events = [
|
|
9
|
+
{
|
|
10
|
+
type: "model/usage",
|
|
11
|
+
seq: 4,
|
|
12
|
+
timestamp: "2026-09-04T01:02:03.000Z",
|
|
13
|
+
turn: 2,
|
|
14
|
+
step: 1,
|
|
15
|
+
requestId: "request-1",
|
|
16
|
+
provider: "ollama-cloud",
|
|
17
|
+
model: "glm-5.3-flash:cloud",
|
|
18
|
+
modelBinding: { connectionId: "ollama-main" },
|
|
19
|
+
inputTokens: 100,
|
|
20
|
+
outputTokens: 20,
|
|
21
|
+
cachedInputTokens: 50,
|
|
22
|
+
reasoningTokens: 5,
|
|
23
|
+
latencyMs: 900,
|
|
24
|
+
estimated: false,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "turn/end",
|
|
28
|
+
seq: 5,
|
|
29
|
+
timestamp: "2026-09-04T01:02:04.000Z",
|
|
30
|
+
turn: 2,
|
|
31
|
+
outcome: "completed",
|
|
32
|
+
},
|
|
33
|
+
] as SessionEvent[];
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
usageEntriesFromTurnV1({
|
|
37
|
+
botId: "bot-a",
|
|
38
|
+
runId: "run-a",
|
|
39
|
+
turn: 2,
|
|
40
|
+
events,
|
|
41
|
+
}),
|
|
42
|
+
).toEqual([
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
entryId: "bot-a:run-a:request-1",
|
|
45
|
+
turnId: "run-a:2",
|
|
46
|
+
bindingId: "ollama-main",
|
|
47
|
+
inputTokens: 100,
|
|
48
|
+
cachedInputTokens: 50,
|
|
49
|
+
costMicros: 19,
|
|
50
|
+
unknownPrice: false,
|
|
51
|
+
}),
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("keeps entries until an idempotent sink accepts them", async () => {
|
|
56
|
+
const values = new Map<string, unknown>();
|
|
57
|
+
const outbox = new UsageOutboxV1({
|
|
58
|
+
get: (key) => Promise.resolve(values.get(key) as never),
|
|
59
|
+
put: (key, value) => {
|
|
60
|
+
values.set(key, value);
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
},
|
|
63
|
+
delete: (key) => Promise.resolve(values.delete(key)),
|
|
64
|
+
});
|
|
65
|
+
const projected = usageEntriesFromTurnV1({
|
|
66
|
+
botId: "bot-a",
|
|
67
|
+
runId: "run-a",
|
|
68
|
+
turn: 2,
|
|
69
|
+
events: [
|
|
70
|
+
{
|
|
71
|
+
type: "model/usage",
|
|
72
|
+
seq: 1,
|
|
73
|
+
timestamp: "2026-09-04T01:02:03.000Z",
|
|
74
|
+
turn: 2,
|
|
75
|
+
step: 1,
|
|
76
|
+
requestId: "request-1",
|
|
77
|
+
provider: "foundation",
|
|
78
|
+
model: "deterministic-v1",
|
|
79
|
+
inputTokens: 10,
|
|
80
|
+
outputTokens: 2,
|
|
81
|
+
latencyMs: 1,
|
|
82
|
+
estimated: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "turn/end",
|
|
86
|
+
seq: 2,
|
|
87
|
+
timestamp: "2026-09-04T01:02:04.000Z",
|
|
88
|
+
turn: 2,
|
|
89
|
+
outcome: "completed",
|
|
90
|
+
},
|
|
91
|
+
] as SessionEvent[],
|
|
92
|
+
});
|
|
93
|
+
await outbox.append(projected);
|
|
94
|
+
await expect(
|
|
95
|
+
outbox.drain({ recordEntries: () => Promise.reject(new Error("away")) }),
|
|
96
|
+
).rejects.toThrow("away");
|
|
97
|
+
expect(await outbox.state()).toMatchObject({ pending: 1 });
|
|
98
|
+
const delivered: unknown[] = [];
|
|
99
|
+
await outbox.drain({
|
|
100
|
+
recordEntries: (entries) => {
|
|
101
|
+
delivered.push(...entries);
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(delivered).toHaveLength(1);
|
|
106
|
+
expect(await outbox.state()).toMatchObject({ pending: 0 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does not project an open Turn and preserves concurrent appends during delivery", async () => {
|
|
110
|
+
expect(
|
|
111
|
+
usageEntriesFromTurnV1({
|
|
112
|
+
botId: "bot-a",
|
|
113
|
+
runId: "run-open",
|
|
114
|
+
turn: 1,
|
|
115
|
+
events: [
|
|
116
|
+
{
|
|
117
|
+
type: "model/usage",
|
|
118
|
+
seq: 1,
|
|
119
|
+
timestamp: "2026-09-04T01:02:03.000Z",
|
|
120
|
+
turn: 1,
|
|
121
|
+
step: 1,
|
|
122
|
+
requestId: "open-request",
|
|
123
|
+
provider: "foundation",
|
|
124
|
+
model: "deterministic-v1",
|
|
125
|
+
inputTokens: 1,
|
|
126
|
+
outputTokens: 1,
|
|
127
|
+
latencyMs: 1,
|
|
128
|
+
estimated: true,
|
|
129
|
+
},
|
|
130
|
+
] as SessionEvent[],
|
|
131
|
+
}),
|
|
132
|
+
).toEqual([]);
|
|
133
|
+
|
|
134
|
+
const values = new Map<string, unknown>();
|
|
135
|
+
const outbox = new UsageOutboxV1({
|
|
136
|
+
get: (key) => Promise.resolve(values.get(key) as never),
|
|
137
|
+
put: (key, value) => {
|
|
138
|
+
values.set(key, value);
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
},
|
|
141
|
+
delete: (key) => Promise.resolve(values.delete(key)),
|
|
142
|
+
});
|
|
143
|
+
const first = {
|
|
144
|
+
schemaVersion: 1,
|
|
145
|
+
entryId: "first",
|
|
146
|
+
kind: "model",
|
|
147
|
+
at: "2026-09-04T01:02:03.000Z",
|
|
148
|
+
provider: "foundation",
|
|
149
|
+
model: "deterministic-v1",
|
|
150
|
+
inputTokens: 1,
|
|
151
|
+
outputTokens: 1,
|
|
152
|
+
cachedInputTokens: 0,
|
|
153
|
+
reasoningTokens: 0,
|
|
154
|
+
voiceSeconds: 0,
|
|
155
|
+
latencyMs: 1,
|
|
156
|
+
estimated: false,
|
|
157
|
+
unknownPrice: false,
|
|
158
|
+
priceTableVersion: "test",
|
|
159
|
+
costMicros: 0,
|
|
160
|
+
} satisfies UsageEntryV1;
|
|
161
|
+
const second = { ...first, entryId: "second" };
|
|
162
|
+
await outbox.append([first]);
|
|
163
|
+
await outbox.drain({
|
|
164
|
+
recordEntries: async () => {
|
|
165
|
+
await outbox.append([second]);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
expect(await outbox.state()).toEqual({ pending: 1, truncated: false });
|
|
169
|
+
const delivered: string[] = [];
|
|
170
|
+
await outbox.drain({
|
|
171
|
+
recordEntries: (entries) => {
|
|
172
|
+
delivered.push(...entries.map((entry) => entry.entryId));
|
|
173
|
+
return Promise.resolve();
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
expect(delivered).toEqual(["second"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { SessionEvent } from "@frockbot/kernel-contracts";
|
|
2
|
+
import { modelCostMicrosV1 } from "./pricing.js";
|
|
3
|
+
import {
|
|
4
|
+
decodeUsageEntryV1,
|
|
5
|
+
USAGE_OUTBOX_MAX_V1,
|
|
6
|
+
type UsageEntryV1,
|
|
7
|
+
} from "./shared.js";
|
|
8
|
+
|
|
9
|
+
export interface UsageSinkV1 {
|
|
10
|
+
recordEntries(entries: readonly UsageEntryV1[]): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function usageEntriesFromTurnV1(input: {
|
|
14
|
+
botId: string;
|
|
15
|
+
runId: string;
|
|
16
|
+
turn: number;
|
|
17
|
+
events: readonly SessionEvent[];
|
|
18
|
+
}): UsageEntryV1[] {
|
|
19
|
+
if (
|
|
20
|
+
!input.events.some(
|
|
21
|
+
(event) => event.type === "turn/end" && event.turn === input.turn,
|
|
22
|
+
)
|
|
23
|
+
) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return input.events.flatMap((event) => {
|
|
27
|
+
if (event.type !== "model/usage" || event.turn !== input.turn) return [];
|
|
28
|
+
const priced = modelCostMicrosV1(event.provider, event.model, event);
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
entryId: `${input.botId}:${input.runId}:${event.requestId}`,
|
|
33
|
+
kind: "model",
|
|
34
|
+
botId: input.botId,
|
|
35
|
+
runId: input.runId,
|
|
36
|
+
turnId: `${input.runId}:${input.turn}`,
|
|
37
|
+
turn: input.turn,
|
|
38
|
+
requestId: event.requestId,
|
|
39
|
+
at: event.timestamp,
|
|
40
|
+
provider: event.provider,
|
|
41
|
+
model: event.model,
|
|
42
|
+
...(event.modelBinding
|
|
43
|
+
? { bindingId: event.modelBinding.connectionId }
|
|
44
|
+
: {}),
|
|
45
|
+
inputTokens: event.inputTokens,
|
|
46
|
+
outputTokens: event.outputTokens,
|
|
47
|
+
cachedInputTokens: event.cachedInputTokens ?? 0,
|
|
48
|
+
reasoningTokens: event.reasoningTokens ?? 0,
|
|
49
|
+
voiceSeconds: 0,
|
|
50
|
+
latencyMs: event.latencyMs,
|
|
51
|
+
estimated: event.estimated,
|
|
52
|
+
unknownPrice: priced.unknown,
|
|
53
|
+
priceTableVersion: priced.priceTableVersion,
|
|
54
|
+
costMicros: priced.costMicros,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const USAGE_OUTBOX_KEY_V1 = "billing:usage-outbox";
|
|
61
|
+
|
|
62
|
+
export interface UsageOutboxStorageV1 {
|
|
63
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
64
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
65
|
+
delete(key: string): Promise<boolean>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface StoredUsageOutboxV1 {
|
|
69
|
+
schemaVersion: 1;
|
|
70
|
+
entries: UsageEntryV1[];
|
|
71
|
+
truncated: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decodeOutboxV1(value: unknown): StoredUsageOutboxV1 {
|
|
75
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
76
|
+
return { schemaVersion: 1, entries: [], truncated: false };
|
|
77
|
+
}
|
|
78
|
+
const candidate = value as Partial<StoredUsageOutboxV1>;
|
|
79
|
+
if (candidate.schemaVersion !== 1 || !Array.isArray(candidate.entries)) {
|
|
80
|
+
return { schemaVersion: 1, entries: [], truncated: true };
|
|
81
|
+
}
|
|
82
|
+
const entries: UsageEntryV1[] = [];
|
|
83
|
+
for (const entry of candidate.entries.slice(-USAGE_OUTBOX_MAX_V1)) {
|
|
84
|
+
try {
|
|
85
|
+
entries.push(decodeUsageEntryV1(entry));
|
|
86
|
+
} catch {
|
|
87
|
+
// A bad derived entry is dropped while the durable model/usage event
|
|
88
|
+
// remains rebuildable; the marker makes the gap visible.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
entries,
|
|
94
|
+
truncated:
|
|
95
|
+
candidate.truncated === true ||
|
|
96
|
+
candidate.entries.length !== entries.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class UsageOutboxV1 {
|
|
101
|
+
private readonly maximum: number;
|
|
102
|
+
|
|
103
|
+
constructor(
|
|
104
|
+
private readonly storage: UsageOutboxStorageV1,
|
|
105
|
+
options: { maximum?: number } = {},
|
|
106
|
+
) {
|
|
107
|
+
this.maximum = options.maximum ?? USAGE_OUTBOX_MAX_V1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async read(): Promise<StoredUsageOutboxV1> {
|
|
111
|
+
return decodeOutboxV1(await this.storage.get<unknown>(USAGE_OUTBOX_KEY_V1));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async write(outbox: StoredUsageOutboxV1): Promise<void> {
|
|
115
|
+
if (outbox.entries.length === 0 && !outbox.truncated) {
|
|
116
|
+
await this.storage.delete(USAGE_OUTBOX_KEY_V1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await this.storage.put(USAGE_OUTBOX_KEY_V1, outbox);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async append(entries: readonly UsageEntryV1[]): Promise<void> {
|
|
123
|
+
if (entries.length === 0) return;
|
|
124
|
+
const stored = await this.read();
|
|
125
|
+
const seen = new Set(stored.entries.map((entry) => entry.entryId));
|
|
126
|
+
for (const candidate of entries) {
|
|
127
|
+
const entry = decodeUsageEntryV1(candidate);
|
|
128
|
+
if (seen.has(entry.entryId)) continue;
|
|
129
|
+
seen.add(entry.entryId);
|
|
130
|
+
stored.entries.push(entry);
|
|
131
|
+
}
|
|
132
|
+
if (stored.entries.length > this.maximum) {
|
|
133
|
+
stored.entries = stored.entries.slice(-this.maximum);
|
|
134
|
+
stored.truncated = true;
|
|
135
|
+
}
|
|
136
|
+
await this.write(stored);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async drain(sink: UsageSinkV1): Promise<void> {
|
|
140
|
+
const stored = await this.read();
|
|
141
|
+
if (stored.entries.length === 0) return;
|
|
142
|
+
await sink.recordEntries(stored.entries);
|
|
143
|
+
const delivered = new Set(stored.entries.map((entry) => entry.entryId));
|
|
144
|
+
const current = await this.read();
|
|
145
|
+
await this.write({
|
|
146
|
+
schemaVersion: 1,
|
|
147
|
+
entries: current.entries.filter((entry) => !delivered.has(entry.entryId)),
|
|
148
|
+
// A successful delivery does not repair an earlier bounded loss.
|
|
149
|
+
truncated: current.truncated || stored.truncated,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async state(): Promise<{ pending: number; truncated: boolean }> {
|
|
154
|
+
const stored = await this.read();
|
|
155
|
+
return { pending: stored.entries.length, truncated: stored.truncated };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
3
|
+
import { computed, inject, onMounted } from "vue";
|
|
4
|
+
import { formatCostV1 } from "./format.js";
|
|
5
|
+
import { usageStateKey } from "./state.js";
|
|
6
|
+
|
|
7
|
+
const providedUsage = inject(usageStateKey);
|
|
8
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
9
|
+
if (!providedUsage || !providedWeb) {
|
|
10
|
+
throw new Error("usage client services were not provided");
|
|
11
|
+
}
|
|
12
|
+
const usage = providedUsage;
|
|
13
|
+
const web = providedWeb;
|
|
14
|
+
const cost = computed(
|
|
15
|
+
() =>
|
|
16
|
+
usage.value.report?.bots.find((row) => row.id === web.value.activeBotId)
|
|
17
|
+
?.costMicros ?? 0,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
onMounted(() => {
|
|
21
|
+
if (!usage.value.loaded && !usage.value.busy) void usage.value.load();
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<p class="bot-spend">
|
|
27
|
+
<span>This month's spend</span>
|
|
28
|
+
<strong>{{ formatCostV1(cost) }}</strong>
|
|
29
|
+
</p>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
.bot-spend {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
gap: var(--frock-radius-control);
|
|
38
|
+
margin: 0;
|
|
39
|
+
padding: var(--frock-radius-control) var(--frock-radius-card);
|
|
40
|
+
border-radius: var(--frock-radius-control);
|
|
41
|
+
color: var(--frock-text-muted);
|
|
42
|
+
background: var(--frock-surface-subtle);
|
|
43
|
+
font-size: var(--frock-text-sm);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.bot-spend strong {
|
|
47
|
+
color: var(--frock-text);
|
|
48
|
+
}
|
|
49
|
+
</style>
|