@frockbot/plugin-billing 0.0.0 → 0.3.20
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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { flockWebDataKey } from "@frockbot/plugin-flock/client/state";
|
|
3
|
+
import { computed, inject, onMounted } from "vue";
|
|
4
|
+
import { formatCostV1, shortModelNameV1 } from "./format.js";
|
|
5
|
+
import { usageStateKey } from "./state.js";
|
|
6
|
+
|
|
7
|
+
const providedUsage = inject(usageStateKey);
|
|
8
|
+
const providedFlock = inject(flockWebDataKey);
|
|
9
|
+
if (!providedUsage || !providedFlock) {
|
|
10
|
+
throw new Error("usage client services were not provided");
|
|
11
|
+
}
|
|
12
|
+
const usage = providedUsage;
|
|
13
|
+
const flock = providedFlock;
|
|
14
|
+
|
|
15
|
+
const maximumDayCost = computed(() =>
|
|
16
|
+
Math.max(1, ...(usage.value.report?.days.map((day) => day.costMicros) ?? [])),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function botName(botId: string): string {
|
|
20
|
+
return flock.value.profiles[botId]?.name ?? botId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function barWidth(costMicros: number): string {
|
|
24
|
+
return `${Math.round((costMicros / maximumDayCost.value) * 100)}%`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onMounted(() => void usage.value.load());
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<section class="usage-card" aria-labelledby="usage-heading">
|
|
32
|
+
<header class="usage-card__header">
|
|
33
|
+
<div>
|
|
34
|
+
<h2 id="usage-heading">Usage</h2>
|
|
35
|
+
<p>This month</p>
|
|
36
|
+
</div>
|
|
37
|
+
<strong class="usage-card__total">
|
|
38
|
+
{{ formatCostV1(usage.report?.currentMonthCostMicros ?? 0) }}
|
|
39
|
+
</strong>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
<p v-if="usage.error" class="usage-card__error" role="alert">
|
|
43
|
+
{{ usage.error }}
|
|
44
|
+
</p>
|
|
45
|
+
<p v-else-if="usage.busy && !usage.loaded" class="usage-card__quiet">
|
|
46
|
+
Loading usage…
|
|
47
|
+
</p>
|
|
48
|
+
<template v-else-if="usage.report">
|
|
49
|
+
<div class="usage-card__columns">
|
|
50
|
+
<section>
|
|
51
|
+
<h3>By Bot</h3>
|
|
52
|
+
<p v-if="usage.report.bots.length === 0" class="usage-card__quiet">
|
|
53
|
+
No spend yet this month.
|
|
54
|
+
</p>
|
|
55
|
+
<ul v-else class="usage-list">
|
|
56
|
+
<li v-for="row in usage.report.bots" :key="row.id">
|
|
57
|
+
<span>{{ botName(row.id) }}</span>
|
|
58
|
+
<strong>{{ formatCostV1(row.costMicros) }}</strong>
|
|
59
|
+
</li>
|
|
60
|
+
</ul>
|
|
61
|
+
</section>
|
|
62
|
+
<section>
|
|
63
|
+
<h3>By model</h3>
|
|
64
|
+
<p v-if="usage.report.models.length === 0" class="usage-card__quiet">
|
|
65
|
+
No model spend yet this month.
|
|
66
|
+
</p>
|
|
67
|
+
<ul v-else class="usage-list">
|
|
68
|
+
<li v-for="row in usage.report.models" :key="row.id">
|
|
69
|
+
<span>{{ shortModelNameV1(row.id) }}</span>
|
|
70
|
+
<strong>{{ formatCostV1(row.costMicros) }}</strong>
|
|
71
|
+
</li>
|
|
72
|
+
</ul>
|
|
73
|
+
</section>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<section>
|
|
77
|
+
<h3>Last 30 days</h3>
|
|
78
|
+
<ol class="usage-days">
|
|
79
|
+
<li v-for="day in usage.report.days" :key="day.day">
|
|
80
|
+
<time :datetime="day.day">{{ day.day.slice(5) }}</time>
|
|
81
|
+
<span class="usage-days__track" aria-hidden="true">
|
|
82
|
+
<span
|
|
83
|
+
class="usage-days__bar"
|
|
84
|
+
:style="{ width: barWidth(day.costMicros) }"
|
|
85
|
+
/>
|
|
86
|
+
</span>
|
|
87
|
+
<span>{{ formatCostV1(day.costMicros) }}</span>
|
|
88
|
+
</li>
|
|
89
|
+
</ol>
|
|
90
|
+
</section>
|
|
91
|
+
<p
|
|
92
|
+
v-if="usage.report.estimatedCalls || usage.report.unknownPriceCalls"
|
|
93
|
+
class="usage-card__quiet"
|
|
94
|
+
>
|
|
95
|
+
Some totals use estimates because exact usage or pricing was not
|
|
96
|
+
available.
|
|
97
|
+
</p>
|
|
98
|
+
</template>
|
|
99
|
+
</section>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<style scoped>
|
|
103
|
+
.usage-card {
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
gap: var(--frock-radius-card);
|
|
107
|
+
padding: var(--frock-radius-card);
|
|
108
|
+
border-radius: var(--frock-radius-card);
|
|
109
|
+
background: var(--frock-surface-subtle);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.usage-card__header,
|
|
113
|
+
.usage-list li,
|
|
114
|
+
.usage-days li {
|
|
115
|
+
display: flex;
|
|
116
|
+
align-items: center;
|
|
117
|
+
justify-content: space-between;
|
|
118
|
+
gap: var(--frock-radius-control);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.usage-card h2,
|
|
122
|
+
.usage-card h3,
|
|
123
|
+
.usage-card p,
|
|
124
|
+
.usage-list,
|
|
125
|
+
.usage-days {
|
|
126
|
+
margin: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.usage-card h2 {
|
|
130
|
+
font-size: var(--frock-text-xl);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.usage-card h3 {
|
|
134
|
+
margin-bottom: var(--frock-radius-control);
|
|
135
|
+
font-size: var(--frock-text-sm);
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: var(--frock-tracking-eyebrow);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.usage-card__header p,
|
|
141
|
+
.usage-card__quiet {
|
|
142
|
+
color: var(--frock-text-muted);
|
|
143
|
+
font-size: var(--frock-text-sm);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.usage-card__total {
|
|
147
|
+
font-size: var(--frock-text-display);
|
|
148
|
+
font-family: var(--frock-font-display);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.usage-card__columns {
|
|
152
|
+
display: grid;
|
|
153
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
154
|
+
gap: var(--frock-radius-card);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.usage-list,
|
|
158
|
+
.usage-days {
|
|
159
|
+
display: flex;
|
|
160
|
+
flex-direction: column;
|
|
161
|
+
gap: calc(var(--frock-radius-control) / 2);
|
|
162
|
+
padding: 0;
|
|
163
|
+
list-style: none;
|
|
164
|
+
font-size: var(--frock-text-sm);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.usage-days time {
|
|
168
|
+
color: var(--frock-text-muted);
|
|
169
|
+
font-family: var(--frock-font-mono);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.usage-days__track {
|
|
173
|
+
flex: 1;
|
|
174
|
+
height: calc(var(--frock-icon-sm) / 2);
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
border-radius: var(--frock-radius-control);
|
|
177
|
+
background: var(--frock-surface-raised);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.usage-days__bar {
|
|
181
|
+
display: block;
|
|
182
|
+
height: 100%;
|
|
183
|
+
border-radius: inherit;
|
|
184
|
+
background: var(--frock-action-primary);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.usage-card__error {
|
|
188
|
+
color: var(--frock-danger-text);
|
|
189
|
+
font-size: var(--frock-text-sm);
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
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);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function shortModelNameV1(value: string): string {
|
|
11
|
+
const slash = value.indexOf("/");
|
|
12
|
+
return slash < 0 ? value : value.slice(slash + 1);
|
|
13
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ClientPluginContext,
|
|
4
|
+
ClientSlotRegistration,
|
|
5
|
+
} from "@frockbot/client-core";
|
|
6
|
+
import { billingClientPlugin } from "./index.js";
|
|
7
|
+
import { formatCostV1, shortModelNameV1 } from "./format.js";
|
|
8
|
+
import { usageStateKey, type UsageClientStateV1 } from "./state.js";
|
|
9
|
+
|
|
10
|
+
const REPORT = {
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
month: "2026-09",
|
|
13
|
+
currentMonthCostMicros: 1_250_000,
|
|
14
|
+
lifetimeCostMicros: 2_000_000,
|
|
15
|
+
currentMonthInputTokens: 100,
|
|
16
|
+
currentMonthOutputTokens: 20,
|
|
17
|
+
currentMonthVoiceSeconds: 30,
|
|
18
|
+
estimatedCalls: 1,
|
|
19
|
+
unknownPriceCalls: 0,
|
|
20
|
+
bots: [
|
|
21
|
+
{
|
|
22
|
+
id: "bot-a",
|
|
23
|
+
costMicros: 1_250_000,
|
|
24
|
+
inputTokens: 100,
|
|
25
|
+
outputTokens: 20,
|
|
26
|
+
voiceSeconds: 0,
|
|
27
|
+
estimatedCalls: 1,
|
|
28
|
+
unknownPriceCalls: 0,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
models: [],
|
|
32
|
+
days: Array.from({ length: 30 }, (_, offset) => ({
|
|
33
|
+
day: `2026-09-${String(offset + 1).padStart(2, "0")}`,
|
|
34
|
+
costMicros: offset,
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function mount(): {
|
|
39
|
+
state: { value: UsageClientStateV1 };
|
|
40
|
+
slots: ClientSlotRegistration[];
|
|
41
|
+
calls: string[];
|
|
42
|
+
} {
|
|
43
|
+
const slots: ClientSlotRegistration[] = [];
|
|
44
|
+
const calls: string[] = [];
|
|
45
|
+
let state: unknown;
|
|
46
|
+
const context: ClientPluginContext = {
|
|
47
|
+
transport: {
|
|
48
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
49
|
+
hostedRequest: (path) => {
|
|
50
|
+
calls.push(path);
|
|
51
|
+
return Promise.resolve(REPORT);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
inject: () => {
|
|
55
|
+
throw new Error("unexpected client provider");
|
|
56
|
+
},
|
|
57
|
+
provide: (key, value) => {
|
|
58
|
+
if (key === usageStateKey) state = value;
|
|
59
|
+
return () => {};
|
|
60
|
+
},
|
|
61
|
+
slot: (registration) => {
|
|
62
|
+
slots.push(registration);
|
|
63
|
+
return () => slots.splice(slots.indexOf(registration), 1);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
billingClientPlugin(context);
|
|
67
|
+
return { state: state as { value: UsageClientStateV1 }, slots, calls };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("billing client contribution", () => {
|
|
71
|
+
test("mounts the account report and per-Bot line in Settings", () => {
|
|
72
|
+
expect(mount().slots.map((slot) => slot.slot)).toEqual([
|
|
73
|
+
"frockbot.user-settings-primary-sections",
|
|
74
|
+
"frockbot.bot-settings-primary-sections",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("loads and decodes the Usage report", async () => {
|
|
79
|
+
const mounted = mount();
|
|
80
|
+
await mounted.state.value.load();
|
|
81
|
+
expect(mounted.calls).toEqual(["/api/usage"]);
|
|
82
|
+
expect(mounted.state.value.report?.currentMonthCostMicros).toBe(1_250_000);
|
|
83
|
+
expect(mounted.state.value.report?.bots[0]?.id).toBe("bot-a");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("formats dollars and compact model names for the rendered view", () => {
|
|
87
|
+
expect(formatCostV1(1_250_000)).toBe("$1.25");
|
|
88
|
+
expect(shortModelNameV1("ollama-cloud/glm-5.3-flash:cloud")).toBe(
|
|
89
|
+
"glm-5.3-flash:cloud",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/// <reference path="../env.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { ClientPlugin } from "@frockbot/client-core";
|
|
4
|
+
import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
|
|
5
|
+
import { ref } from "vue";
|
|
6
|
+
import { decodeUsageReportV1 } from "../shared.js";
|
|
7
|
+
import BotSpendLine from "./BotSpendLine.vue";
|
|
8
|
+
import UsageSection from "./UsageSection.vue";
|
|
9
|
+
import { usageStateKey, type UsageClientStateV1 } from "./state.js";
|
|
10
|
+
|
|
11
|
+
export const billingClientPlugin: ClientPlugin = (ctx) => {
|
|
12
|
+
const state = ref<UsageClientStateV1>({
|
|
13
|
+
loaded: false,
|
|
14
|
+
busy: false,
|
|
15
|
+
async load() {
|
|
16
|
+
if (!ctx.transport.hostedRequest) {
|
|
17
|
+
state.value.error = "Usage is unavailable on this client";
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
state.value.busy = true;
|
|
21
|
+
try {
|
|
22
|
+
state.value.report = decodeUsageReportV1(
|
|
23
|
+
await ctx.transport.hostedRequest("/api/usage"),
|
|
24
|
+
);
|
|
25
|
+
state.value.loaded = true;
|
|
26
|
+
state.value.error = undefined;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
state.value.error =
|
|
29
|
+
error instanceof Error ? error.message : "Could not load usage";
|
|
30
|
+
} finally {
|
|
31
|
+
state.value.busy = false;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
ctx.provide(usageStateKey, state),
|
|
38
|
+
ctx.slot({
|
|
39
|
+
slot: "frockbot.user-settings-primary-sections",
|
|
40
|
+
order: 20,
|
|
41
|
+
component: UsageSection,
|
|
42
|
+
}),
|
|
43
|
+
ctx.slot({
|
|
44
|
+
slot: "frockbot.bot-settings-primary-sections",
|
|
45
|
+
order: 30,
|
|
46
|
+
component: BotSpendLine,
|
|
47
|
+
}),
|
|
48
|
+
];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default billingClientPlugin;
|
|
52
|
+
|
|
53
|
+
export const clientContribution = defineClientContribution<ClientPlugin>({
|
|
54
|
+
specifier: "@frockbot/plugin-billing/client",
|
|
55
|
+
plugin: billingClientPlugin,
|
|
56
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from "vue";
|
|
2
|
+
import type { UsageReportV1 } from "../shared.js";
|
|
3
|
+
|
|
4
|
+
export interface UsageClientStateV1 {
|
|
5
|
+
report?: UsageReportV1;
|
|
6
|
+
loaded: boolean;
|
|
7
|
+
busy: boolean;
|
|
8
|
+
error?: string;
|
|
9
|
+
load(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const usageStateKey: InjectionKey<Ref<UsageClientStateV1>> =
|
|
13
|
+
Symbol("usage-state");
|
package/src/env.d.ts
ADDED
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MODEL_PRICE_TABLE_VERSION_V1,
|
|
4
|
+
modelCostMicrosV1,
|
|
5
|
+
resolveModelPriceV1,
|
|
6
|
+
voiceCostMicrosV1,
|
|
7
|
+
voiceIncrementCostMicrosV1,
|
|
8
|
+
} from "./pricing.js";
|
|
9
|
+
|
|
10
|
+
describe("billing prices", () => {
|
|
11
|
+
test("prices cached and uncached tokens without double-counting reasoning", () => {
|
|
12
|
+
expect(
|
|
13
|
+
modelCostMicrosV1(
|
|
14
|
+
"flock-ai",
|
|
15
|
+
"@frock/deepseek-ai/deepseek-v4-flash-0731",
|
|
16
|
+
{
|
|
17
|
+
inputTokens: 1_000_000,
|
|
18
|
+
cachedInputTokens: 250_000,
|
|
19
|
+
outputTokens: 100_000,
|
|
20
|
+
reasoningTokens: 50_000,
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
).toMatchObject({
|
|
24
|
+
costMicros: 465_500,
|
|
25
|
+
unknown: false,
|
|
26
|
+
priceTableVersion: MODEL_PRICE_TABLE_VERSION_V1,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("normalizes Ollama cloud suffixes", () => {
|
|
31
|
+
expect(
|
|
32
|
+
resolveModelPriceV1("ollama-cloud", "glm-5.3-flash:cloud"),
|
|
33
|
+
).toMatchObject({ unknown: false });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("uses and flags the conservative unknown-model rate", () => {
|
|
37
|
+
expect(
|
|
38
|
+
modelCostMicrosV1("private-provider", "new-model", {
|
|
39
|
+
inputTokens: 10,
|
|
40
|
+
outputTokens: 2,
|
|
41
|
+
}),
|
|
42
|
+
).toMatchObject({ costMicros: 200, unknown: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("prices voice by recorded duration", () => {
|
|
46
|
+
expect(voiceCostMicrosV1(90)).toBe(25_500);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("prices cumulative voice increments without report-frequency drift", () => {
|
|
50
|
+
expect(
|
|
51
|
+
voiceIncrementCostMicrosV1(1, 1) +
|
|
52
|
+
voiceIncrementCostMicrosV1(2, 1) +
|
|
53
|
+
voiceIncrementCostMicrosV1(3, 1),
|
|
54
|
+
).toBe(voiceCostMicrosV1(3));
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { LlmUsageV1 } from "@frockbot/kernel-contracts";
|
|
2
|
+
|
|
3
|
+
/** Immutable price snapshot used by every entry written by this release. */
|
|
4
|
+
export const MODEL_PRICE_TABLE_VERSION_V1 = "2026-09-04";
|
|
5
|
+
|
|
6
|
+
/** Platform markup. One means the User sees provider cost with no markup. */
|
|
7
|
+
export const PLATFORM_COST_MULTIPLIER_V1 = 1;
|
|
8
|
+
|
|
9
|
+
/** A deliberately conservative fallback for a model absent from the table. */
|
|
10
|
+
export const UNKNOWN_MODEL_PRICE_V1 = {
|
|
11
|
+
inputUsdPerMillion: 10,
|
|
12
|
+
cachedInputUsdPerMillion: 10,
|
|
13
|
+
outputUsdPerMillion: 50,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export interface ModelPriceV1 {
|
|
17
|
+
provider: string;
|
|
18
|
+
model: string;
|
|
19
|
+
inputUsdPerMillion: number;
|
|
20
|
+
cachedInputUsdPerMillion?: number;
|
|
21
|
+
outputUsdPerMillion: number;
|
|
22
|
+
sourceUrl: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const OPENAI_SOURCE = "https://developers.openai.com/api/docs/models/compare";
|
|
26
|
+
const CLOUDFLARE_SOURCE =
|
|
27
|
+
"https://developers.cloudflare.com/workers-ai/platform/pricing/";
|
|
28
|
+
const OLLAMA_SOURCE = "https://ollama.com/cloud";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Provider/model prices in dollars per million tokens.
|
|
32
|
+
*
|
|
33
|
+
* Ollama's two DeepSeek models use its published peak prices so a static
|
|
34
|
+
* ledger never understates a request made during the peak window.
|
|
35
|
+
*/
|
|
36
|
+
export const MODEL_PRICE_TABLE_V1: readonly ModelPriceV1[] = [
|
|
37
|
+
{
|
|
38
|
+
provider: "foundation",
|
|
39
|
+
model: "deterministic-v1",
|
|
40
|
+
inputUsdPerMillion: 0,
|
|
41
|
+
cachedInputUsdPerMillion: 0,
|
|
42
|
+
outputUsdPerMillion: 0,
|
|
43
|
+
sourceUrl: "https://github.com/timoconnellaus/frockbot",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
provider: "flock-ai",
|
|
47
|
+
model: "@frock/deepseek-ai/deepseek-v4-flash-0731",
|
|
48
|
+
inputUsdPerMillion: 0.44,
|
|
49
|
+
cachedInputUsdPerMillion: 0.014,
|
|
50
|
+
outputUsdPerMillion: 1.32,
|
|
51
|
+
sourceUrl: CLOUDFLARE_SOURCE,
|
|
52
|
+
},
|
|
53
|
+
...[
|
|
54
|
+
["gpt-6-astra", 10, 1, 50],
|
|
55
|
+
["gpt-5.6", 4, 0.4, 20],
|
|
56
|
+
["gpt-5.6-sol", 4, 0.4, 20],
|
|
57
|
+
["gpt-5.6-terra", 2, 0.2, 12],
|
|
58
|
+
["gpt-5.6-luna", 0.2, 0.02, 1.2],
|
|
59
|
+
].map(([model, input, cached, output]) => ({
|
|
60
|
+
provider: "openai-compatible",
|
|
61
|
+
model: String(model),
|
|
62
|
+
inputUsdPerMillion: Number(input),
|
|
63
|
+
cachedInputUsdPerMillion: Number(cached),
|
|
64
|
+
outputUsdPerMillion: Number(output),
|
|
65
|
+
sourceUrl: OPENAI_SOURCE,
|
|
66
|
+
})),
|
|
67
|
+
...[
|
|
68
|
+
["deepseek-v4-flash", 0.44, 0.014, 1.32],
|
|
69
|
+
["deepseek-v4-pro", 1.32, 0.044, 3.96],
|
|
70
|
+
["gemma4", 0.14, 0.05, 0.4],
|
|
71
|
+
["glm-5.3", 1.4, 0.26, 4.4],
|
|
72
|
+
["glm-5.3-flash", 0.15, 0.03, 0.5],
|
|
73
|
+
["glm-5.2", 1.4, 0.26, 4.4],
|
|
74
|
+
["glm-5.1", 1, 0.2, 3.2],
|
|
75
|
+
["gpt-oss:120b", 0.15, 0.014, 0.6],
|
|
76
|
+
["gpt-oss:20b", 0.07, 0.035, 0.3],
|
|
77
|
+
["kimi-k3", 3, 0.3, 15],
|
|
78
|
+
["kimi-k2.7-code", 0.95, 0.19, 4],
|
|
79
|
+
["kimi-k2.6", 0.95, 0.16, 4],
|
|
80
|
+
["minimax-m3", 0.6, 0.12, 2.4],
|
|
81
|
+
["minimax-m2.7", 0.3, 0.06, 1.2],
|
|
82
|
+
["mistral-large-3", 0.5, 0.5, 1.5],
|
|
83
|
+
["qwen3.5:397b", 0.6, 0.6, 3.6],
|
|
84
|
+
].map(([model, input, cached, output]) => ({
|
|
85
|
+
provider: "ollama-cloud",
|
|
86
|
+
model: String(model),
|
|
87
|
+
inputUsdPerMillion: Number(input),
|
|
88
|
+
cachedInputUsdPerMillion: Number(cached),
|
|
89
|
+
outputUsdPerMillion: Number(output),
|
|
90
|
+
sourceUrl: OLLAMA_SOURCE,
|
|
91
|
+
})),
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
export interface ResolvedModelPriceV1 {
|
|
95
|
+
price: Omit<ModelPriceV1, "provider" | "model" | "sourceUrl">;
|
|
96
|
+
unknown: boolean;
|
|
97
|
+
priceTableVersion: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function canonicalModelV1(provider: string, model: string): string {
|
|
101
|
+
if (provider === "ollama-cloud") return model.replace(/:cloud$/, "");
|
|
102
|
+
if (provider === "flock-ai" && model.startsWith("@flock/")) {
|
|
103
|
+
return `@frock/${model.slice("@flock/".length)}`;
|
|
104
|
+
}
|
|
105
|
+
return model;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveModelPriceV1(
|
|
109
|
+
provider: string,
|
|
110
|
+
model: string,
|
|
111
|
+
): ResolvedModelPriceV1 {
|
|
112
|
+
const canonical = canonicalModelV1(provider, model);
|
|
113
|
+
const found = MODEL_PRICE_TABLE_V1.find(
|
|
114
|
+
(entry) => entry.provider === provider && entry.model === canonical,
|
|
115
|
+
);
|
|
116
|
+
const price = found ?? UNKNOWN_MODEL_PRICE_V1;
|
|
117
|
+
return {
|
|
118
|
+
price: {
|
|
119
|
+
inputUsdPerMillion: price.inputUsdPerMillion,
|
|
120
|
+
...(price.cachedInputUsdPerMillion === undefined
|
|
121
|
+
? {}
|
|
122
|
+
: { cachedInputUsdPerMillion: price.cachedInputUsdPerMillion }),
|
|
123
|
+
outputUsdPerMillion: price.outputUsdPerMillion,
|
|
124
|
+
},
|
|
125
|
+
unknown: found === undefined,
|
|
126
|
+
priceTableVersion: MODEL_PRICE_TABLE_VERSION_V1,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One micro-dollar is one millionth of a dollar. */
|
|
131
|
+
export function modelCostMicrosV1(
|
|
132
|
+
provider: string,
|
|
133
|
+
model: string,
|
|
134
|
+
usage: LlmUsageV1,
|
|
135
|
+
): ResolvedModelPriceV1 & { costMicros: number } {
|
|
136
|
+
const resolved = resolveModelPriceV1(provider, model);
|
|
137
|
+
const cached = Math.min(usage.cachedInputTokens ?? 0, usage.inputTokens);
|
|
138
|
+
const uncached = usage.inputTokens - cached;
|
|
139
|
+
const cost =
|
|
140
|
+
uncached * resolved.price.inputUsdPerMillion +
|
|
141
|
+
cached *
|
|
142
|
+
(resolved.price.cachedInputUsdPerMillion ??
|
|
143
|
+
resolved.price.inputUsdPerMillion) +
|
|
144
|
+
usage.outputTokens * resolved.price.outputUsdPerMillion;
|
|
145
|
+
return {
|
|
146
|
+
...resolved,
|
|
147
|
+
costMicros: Math.round(cost * PLATFORM_COST_MULTIPLIER_V1),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** OpenAI's published realtime transcription price, billed by duration. */
|
|
152
|
+
export const VOICE_USD_PER_MINUTE_V1 = 0.017;
|
|
153
|
+
export const VOICE_PRICE_SOURCE_V1 =
|
|
154
|
+
"https://developers.openai.com/api/docs/models/gpt-live-transcribe";
|
|
155
|
+
|
|
156
|
+
export function voiceCostMicrosV1(seconds: number): number {
|
|
157
|
+
return Math.round(
|
|
158
|
+
(seconds / 60) *
|
|
159
|
+
VOICE_USD_PER_MINUTE_V1 *
|
|
160
|
+
1_000_000 *
|
|
161
|
+
PLATFORM_COST_MULTIPLIER_V1,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Prices one cumulative voice receipt without making the result depend on how
|
|
167
|
+
* often the session reported. The sum of every increment therefore equals the
|
|
168
|
+
* rounded price of the final cumulative duration.
|
|
169
|
+
*/
|
|
170
|
+
export function voiceIncrementCostMicrosV1(
|
|
171
|
+
sessionSeconds: number,
|
|
172
|
+
recordedSeconds: number,
|
|
173
|
+
): number {
|
|
174
|
+
const previousSeconds = sessionSeconds - recordedSeconds;
|
|
175
|
+
return voiceCostMicrosV1(sessionSeconds) - voiceCostMicrosV1(previousSeconds);
|
|
176
|
+
}
|