acuvo-code 0.2.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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE PLAN MATH, IN CODE, FROM MEASURED PRICES ────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Everything about what a user gets for their money lived in conversations and
|
|
5
|
+
* spreadsheets. That is fine until somebody has to ENFORCE it, and then the
|
|
6
|
+
* question "has this account run out?" has no function to call. This module is
|
|
7
|
+
* the one place that answers it, and every number in it is either a real
|
|
8
|
+
* OpenRouter endpoint price read on 2026-08-15 or a figure from our own bench.
|
|
9
|
+
*
|
|
10
|
+
* ── ⚠️⚠️ THE ONE FACT THAT DECIDES THE BUSINESS ─────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* **The cache rate IS the margin.** Not a contributor to it — the whole of it.
|
|
13
|
+
* Blended cost per million flash tokens, measured:
|
|
14
|
+
*
|
|
15
|
+
* cache 0% $0.0686 cache 85% $0.0228
|
|
16
|
+
* cache 65% $0.0336 cache 95% $0.0174
|
|
17
|
+
*
|
|
18
|
+
* A 95M-token plan costs $3.19 at 65% cache and $1.66 at 95%. On $29 AUD that
|
|
19
|
+
* is the difference between an 83% margin and a 91% one, from the same product
|
|
20
|
+
* doing the same work. Which is why `PROVIDER_PIN_BY_MODEL` and compaction
|
|
21
|
+
* discipline are not housekeeping — they are the P&L.
|
|
22
|
+
*
|
|
23
|
+
* ── ⭐⭐ AND THE ROUTING RULE THAT FALLS OUT OF IT ──────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* Pro's premium over flash COLLAPSES as the cache warms:
|
|
26
|
+
*
|
|
27
|
+
* cache 0% 6.40x cache 95% 1.86x
|
|
28
|
+
* cache 65% 4.78x cache 98% 1.23x
|
|
29
|
+
*
|
|
30
|
+
* So the honest rule is NOT "use the cheap model" — it is **"a long warm
|
|
31
|
+
* session can afford the strong model; a short cold one cannot."** That is the
|
|
32
|
+
* opposite of escalate-on-failure, which reaches for pro exactly when the
|
|
33
|
+
* context is coldest, and it is measured rather than reasoned.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-million prices, USD, from OpenRouter's per-model endpoint feed on
|
|
38
|
+
* 2026-08-15, for the endpoint each model is PINNED to.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ THE PINNED ENDPOINT, NOT THE MODEL PAGE. The model page shows a headline;
|
|
41
|
+
* a model is served by many providers at wildly different prices. Pro was
|
|
42
|
+
* unpinned and landing on GMICloud at $1.218/M in and $0.1015/M cache-read —
|
|
43
|
+
* 2.8x and 28x DeepSeek's own endpoint. Quoting the page would have hidden
|
|
44
|
+
* that entirely. See `PROVIDER_PIN_BY_MODEL` in model.mjs.
|
|
45
|
+
*/
|
|
46
|
+
export const MODEL_PRICES = Object.freeze({
|
|
47
|
+
'deepseek/deepseek-v4-flash-0731': Object.freeze({ in: 0.068, out: 0.137, cacheRead: 0.0137, provider: 'StreamLake' }),
|
|
48
|
+
'deepseek/deepseek-v4-pro-0813': Object.freeze({ in: 0.435, out: 0.870, cacheRead: 0.0036, provider: 'DeepSeek' }),
|
|
49
|
+
'qwen/qwen3.7-flash': Object.freeze({ in: 0.030, out: 0.130, cacheRead: 0.0060, provider: 'Alibaba' }),
|
|
50
|
+
'z-ai/glm-4.6': Object.freeze({ in: 0.430, out: 1.750, cacheRead: 0.0800, provider: 'Venice' }),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ⚠️ MEASURED, AND SMALLER THAN ANYONE GUESSES. A real 3-round run: 33,544
|
|
55
|
+
* tokens total, 33,258 of them prompt — so **output is 0.9% of the tokens.**
|
|
56
|
+
* That is why cache rate dominates everything: 99% of what we pay for is input,
|
|
57
|
+
* and input is the only half that can be cached. It is also why "make the model
|
|
58
|
+
* write less" is a much weaker lever than it sounds.
|
|
59
|
+
*/
|
|
60
|
+
export const OUTPUT_TOKEN_SHARE = 0.009;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Blended cost per million tokens at a given cache hit rate.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} model
|
|
66
|
+
* @param {number} cacheRate 0..1
|
|
67
|
+
* @returns {number|null} USD per million, or null for a model we have not priced
|
|
68
|
+
*/
|
|
69
|
+
export function costPerMillion(model, cacheRate = 0) {
|
|
70
|
+
const p = MODEL_PRICES[String(model ?? '')];
|
|
71
|
+
if (!p) return null;
|
|
72
|
+
const c = Math.min(1, Math.max(0, Number(cacheRate) || 0));
|
|
73
|
+
const inputCost = c * p.cacheRead + (1 - c) * p.in;
|
|
74
|
+
return (1 - OUTPUT_TOKEN_SHARE) * inputCost + OUTPUT_TOKEN_SHARE * p.out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ── ⭐ THE PLANS ────────────────────────────────────────────────────────────
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ SEPARATE ALLOWANCES PER MODEL, NOT ONE POOLED NUMBER, and the reason is
|
|
81
|
+
* arithmetic rather than taste. Pro costs between 1.23x and 6.40x flash
|
|
82
|
+
* depending on cache, so a single "N tokens" pool would be worth wildly
|
|
83
|
+
* different amounts of money depending on which model spent it — and the user
|
|
84
|
+
* could not predict their own limit. Claude splits Opus from Sonnet for exactly
|
|
85
|
+
* this reason. Two numbers a user can read beat one number that lies.
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ AND THE PRO ALLOWANCE IS THE RISK. At 95% cache the whole $29 AUD plan
|
|
88
|
+
* costs us $1.98 (89% margin); at 65% it costs $4.80 (74%). The plan is sound
|
|
89
|
+
* BECAUSE the cache holds, which makes the pin a revenue control.
|
|
90
|
+
*/
|
|
91
|
+
/** AUD→USD, deliberately conservative — a plan must not depend on a good day. */
|
|
92
|
+
export const AUD_USD = 0.645;
|
|
93
|
+
|
|
94
|
+
const FLASH = 'deepseek/deepseek-v4-flash-0731';
|
|
95
|
+
const PRO = 'deepseek/deepseek-v4-pro-0813';
|
|
96
|
+
/**
|
|
97
|
+
* ── ⭐⭐⭐ THE THIRD MODEL, ADDED 2026-08-16, AND IT IS THE MARGIN LEVER ─────
|
|
98
|
+
*
|
|
99
|
+
* Every tier below used to grant FLASH and PRO only, which quietly assumed the
|
|
100
|
+
* build model also does the READING. It does not, and it should not: the locked
|
|
101
|
+
* role split is *"qwen3.7-flash — interpreting, review, vision, chat. NEVER
|
|
102
|
+
* building."* Qwen's blended rate is **3.2x cheaper than flash**, and roughly
|
|
103
|
+
* 58% of an agent run's tokens are reading rather than writing.
|
|
104
|
+
*
|
|
105
|
+
* ⭐ SO NAMING IT MADE EVERY TIER BIGGER *AND* MORE PROFITABLE. Starter went
|
|
106
|
+
* from 95M tokens to **152M** while its margin at a poor (70%) cache month rose
|
|
107
|
+
* — because the volume that was being priced at flash rates was never flash
|
|
108
|
+
* work in the first place. Measured 2026-08-16.
|
|
109
|
+
*/
|
|
110
|
+
const QWEN = 'qwen/qwen3.7-flash';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ── ⚠️⚠️ ONE LADDER, AND IT IS THE SHIPPED ONE ─────────────────────────────
|
|
114
|
+
*
|
|
115
|
+
* This file first invented its own tiers — free/code/pro/max at 5M/95M/200M/
|
|
116
|
+
* 500M — while `console/lib/plan-catalog.ts` was already SELLING
|
|
117
|
+
* free/starter/growth/scale at 4M/95M/330M/1000M. Two ladders, both
|
|
118
|
+
* plausible, already disagreeing on three of four rungs. A price the CLI
|
|
119
|
+
* quotes and a price the customer is charged must be the same number, and
|
|
120
|
+
* the console is the one with customers on it.
|
|
121
|
+
*
|
|
122
|
+
* ⭐ So these mirror the catalogue exactly, and
|
|
123
|
+
* `console/lib/plan-drift.test.ts` FAILS if either side moves. Change one,
|
|
124
|
+
* the test names the other — which is what makes the numbers safe to change
|
|
125
|
+
* at will rather than merely easy to change in one place and forget in the
|
|
126
|
+
* other.
|
|
127
|
+
*
|
|
128
|
+
* ── ✅ THE CONFESSED DEFECT IS FIXED (2026-08-16) ───────────────────────────
|
|
129
|
+
*
|
|
130
|
+
* This paragraph used to read: *"⚠️ Growth and Scale sit below 80% on a poor
|
|
131
|
+
* cache month. Their FLASH allowances were already generous, so any pro on top
|
|
132
|
+
* is squeezed — a pricing decision to revisit deliberately, recorded rather
|
|
133
|
+
* than hidden."* It quoted 95/85/75/65% cache margins of
|
|
134
|
+
* `starter 90/86/82/79 · growth 87/82/77/72 · scale 85/80/75/69`.
|
|
135
|
+
*
|
|
136
|
+
* ⭐ THE FIX WAS NOT SMALLER ALLOWANCES — IT WAS NAMING THE THIRD MODEL. Those
|
|
137
|
+
* tiers granted FLASH and PRO only, which priced the READING half of every run
|
|
138
|
+
* at build-model rates. Qwen is 3.2x cheaper and does ~58% of the volume, so
|
|
139
|
+
* adding it made every tier **larger and more profitable at once**:
|
|
140
|
+
*
|
|
141
|
+
* starter 95M -> 152M growth 349M -> 415M scale 1036M -> 1046M
|
|
142
|
+
*
|
|
143
|
+
* All three now clear **80% at a poor 70% cache month** and sit at ~87.7% at
|
|
144
|
+
* the 90% floor the gateway engineers. ⚠️ At a total cache failure (0%) they
|
|
145
|
+
* are 52–53% — thin, never a loss. Holding 80% at 0% would mean granting ~60M
|
|
146
|
+
* on Starter, which is a worse product than the risk justifies: the floor is
|
|
147
|
+
* something we BUILD (shared prompt head + pinned provider + multi-tenant
|
|
148
|
+
* traffic), not something we hope for.
|
|
149
|
+
*/
|
|
150
|
+
export const PLANS = Object.freeze({
|
|
151
|
+
free: Object.freeze({
|
|
152
|
+
id: 'free', label: 'Free', priceUsd: 0, currency: 'AUD', priceLocal: 0,
|
|
153
|
+
tokens: Object.freeze({ [QWEN]: 6_000_000, [FLASH]: 4_000_000, [PRO]: 0 }),
|
|
154
|
+
includesReview: true,
|
|
155
|
+
}),
|
|
156
|
+
/**
|
|
157
|
+
* ── ⭐ RESIZED 2026-08-16 — BIGGER *AND* MORE PROFITABLE ────────────────────
|
|
158
|
+
*
|
|
159
|
+
* Was `flash 95M + pro 5M`. Now `qwen 88M + flash 59M + pro 5M = 152M`, sized
|
|
160
|
+
* so 80% margin holds at a **poor 70% cache month**, on this file's own
|
|
161
|
+
* measured prices and its measured 0.9% output share.
|
|
162
|
+
*
|
|
163
|
+
* @70% cache $3.78 79.8%
|
|
164
|
+
* @90% cache $2.30 87.7% ← the floor the gateway engineers
|
|
165
|
+
* @0% cache $8.96 52.1% ← total cache failure: thin, never a loss
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ 0% is the honest worst case and it is NOT 80%. Holding 80% with no cache
|
|
168
|
+
* at all would mean granting ~60M tokens, which is a worse product than the
|
|
169
|
+
* risk justifies — the floor is something we build (shared prompt head +
|
|
170
|
+
* pinned provider + multi-tenant traffic), not something we hope for.
|
|
171
|
+
*/
|
|
172
|
+
starter: Object.freeze({
|
|
173
|
+
id: 'starter', label: 'Starter', priceUsd: 29 * AUD_USD, currency: 'AUD', priceLocal: 29,
|
|
174
|
+
tokens: Object.freeze({ [QWEN]: 88_000_000, [FLASH]: 59_000_000, [PRO]: 5_000_000 }),
|
|
175
|
+
includesReview: true,
|
|
176
|
+
}),
|
|
177
|
+
/**
|
|
178
|
+
* ⚠️ THIS FIXES A DEFECT THIS FILE ALREADY CONFESSED. Its own header said:
|
|
179
|
+
* *"Growth and Scale sit below 80% on a poor cache month … a pricing decision
|
|
180
|
+
* to revisit deliberately, recorded rather than hidden."* This is that revisit.
|
|
181
|
+
* Was `flash 330M + pro 19M`; now 415M total and 80.1% at the same poor month.
|
|
182
|
+
*/
|
|
183
|
+
growth: Object.freeze({
|
|
184
|
+
id: 'growth', label: 'Growth', priceUsd: 79 * AUD_USD, currency: 'AUD', priceLocal: 79,
|
|
185
|
+
// ⭐ Re-split 2026-08-18 to hold STARTER'S MODEL BLEND, which
|
|
186
|
+
// `acuvo-gateway/lib/topups.mjs` requires — it prices one blended top-up
|
|
187
|
+
// across the whole ladder and refuses when the mixes drift apart.
|
|
188
|
+
tokens: Object.freeze({ [QWEN]: 240_000_000, [FLASH]: 161_000_000, [PRO]: 14_000_000 }),
|
|
189
|
+
includesReview: true,
|
|
190
|
+
}),
|
|
191
|
+
scale: Object.freeze({
|
|
192
|
+
id: 'scale', label: 'Scale', priceUsd: 199 * AUD_USD, currency: 'AUD', priceLocal: 199,
|
|
193
|
+
tokens: Object.freeze({ [QWEN]: 606_000_000, [FLASH]: 406_000_000, [PRO]: 34_000_000 }),
|
|
194
|
+
includesReview: true,
|
|
195
|
+
}),
|
|
196
|
+
/**
|
|
197
|
+
* ⚠️ UNMETERED, NOT UNLIMITED-BY-OVERSIGHT. `null` means the allowance is
|
|
198
|
+
* negotiated rather than absent, and `planGate` treats a null as "no ceiling
|
|
199
|
+
* to enforce here" rather than as zero.
|
|
200
|
+
*/
|
|
201
|
+
enterprise: Object.freeze({
|
|
202
|
+
id: 'enterprise', label: 'Enterprise', priceUsd: 1000 * AUD_USD, currency: 'AUD', priceLocal: 1000,
|
|
203
|
+
tokens: Object.freeze({ [QWEN]: null, [FLASH]: null, [PRO]: null }),
|
|
204
|
+
includesReview: true,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* ── ⚠️⚠️ TWO STRUCTURES THAT LOOK BETTER AND MEASURE WORSE ──────────────────
|
|
210
|
+
*
|
|
211
|
+
* Recorded so nobody re-proposes them from first principles, because both are
|
|
212
|
+
* the obvious idea and both are refuted by the price table above.
|
|
213
|
+
*
|
|
214
|
+
* **A pooled allowance with pro billed at a multiplier.** Elegant, and the
|
|
215
|
+
* multiplier is NOT A CONSTANT: pro is 1.9x flash at 95% cache, 3.3x at 85%
|
|
216
|
+
* and 4.8x at 65%. Any published figure is therefore wrong most of the time —
|
|
217
|
+
* either we overcharge on warm sessions or we lose money on cold ones, and the
|
|
218
|
+
* user cannot predict their own limit either way. Two honest numbers beat one
|
|
219
|
+
* that lies.
|
|
220
|
+
*
|
|
221
|
+
* **Unlimited flash, metered pro.** Measured against real usage: a 100-task/day
|
|
222
|
+
* user leaves an 88% margin, 200/day leaves 75%, and 400/day leaves **51%**.
|
|
223
|
+
* "Unlimited" needs a fair-use cap to survive its top percentile, and a cap is
|
|
224
|
+
* an allowance with worse manners.
|
|
225
|
+
*/
|
|
226
|
+
export const REJECTED_STRUCTURES = Object.freeze(['pooled-with-multiplier', 'unlimited-flash']);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What a plan costs us, and what margin it leaves, at a given cache rate.
|
|
230
|
+
*
|
|
231
|
+
* ⚠️ IT TAKES THE CACHE RATE BECAUSE THERE IS NO SINGLE ANSWER. A margin quoted
|
|
232
|
+
* without one is a number somebody chose. `acuvo spend` reports the real rate
|
|
233
|
+
* for a workspace; feed that in and this tells the truth about that account.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} plan
|
|
236
|
+
* @param {number} cacheRate
|
|
237
|
+
*/
|
|
238
|
+
export function planEconomics(plan, cacheRate = 0.95) {
|
|
239
|
+
let cogsUsd = 0;
|
|
240
|
+
const unpriced = [];
|
|
241
|
+
/**
|
|
242
|
+
* ⚠️⚠️ AN UNMETERED ALLOWANCE MAKES THE MARGIN UNKNOWABLE, NOT 100%.
|
|
243
|
+
* `null` means "negotiated, no ceiling", and skipping it as if it were zero
|
|
244
|
+
* reported Enterprise at a 100% margin — a plan whose customers use real
|
|
245
|
+
* tokens, costed at nothing. That is the exact shape of lie this file spends
|
|
246
|
+
* its comments guarding against, committed inside the file that guards it.
|
|
247
|
+
*/
|
|
248
|
+
const unmetered = Object.entries(plan?.tokens ?? {})
|
|
249
|
+
.filter(([, t]) => t === null)
|
|
250
|
+
.map(([m]) => m);
|
|
251
|
+
for (const [model, tokens] of Object.entries(plan?.tokens ?? {})) {
|
|
252
|
+
if (tokens === null || !tokens) continue;
|
|
253
|
+
const per = costPerMillion(model, cacheRate);
|
|
254
|
+
if (per === null) { unpriced.push(model); continue; }
|
|
255
|
+
cogsUsd += (tokens / 1e6) * per;
|
|
256
|
+
}
|
|
257
|
+
const revenueUsd = Number(plan?.priceUsd ?? 0);
|
|
258
|
+
return {
|
|
259
|
+
cacheRate,
|
|
260
|
+
cogsUsd,
|
|
261
|
+
revenueUsd,
|
|
262
|
+
/** null rather than a made-up 100% for a free plan — it has no margin. */
|
|
263
|
+
/**
|
|
264
|
+
* null when there is no revenue (free) OR no ceiling (enterprise). Both are
|
|
265
|
+
* "we cannot state a margin", and inventing one for either is worse than
|
|
266
|
+
* saying so.
|
|
267
|
+
*/
|
|
268
|
+
marginPct: revenueUsd > 0 && unmetered.length === 0 ? ((revenueUsd - cogsUsd) / revenueUsd) * 100 : null,
|
|
269
|
+
unmetered,
|
|
270
|
+
profitUsd: revenueUsd - cogsUsd,
|
|
271
|
+
/** ⚠️ Named, never silently skipped — an unpriced model understates COGS. */
|
|
272
|
+
unpriced,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* ── ⚠️ THE CACHE RATE AT WHICH A PLAN STOPS PAYING ─────────────────────────
|
|
278
|
+
*
|
|
279
|
+
* The single most useful number for pricing a plan, and it cannot be reasoned
|
|
280
|
+
* to — it has to be solved for. Below this rate the plan earns less than the
|
|
281
|
+
* target margin, which is the alarm the pin and the compaction rules exist to
|
|
282
|
+
* keep from ringing.
|
|
283
|
+
*
|
|
284
|
+
* @returns {{ rate: number|null, note: string }}
|
|
285
|
+
*/
|
|
286
|
+
export function breakEvenCacheRate(plan, targetMarginPct = 80) {
|
|
287
|
+
// Monotonic in cacheRate, so a coarse scan is exact enough to act on and has
|
|
288
|
+
// no solver to get wrong.
|
|
289
|
+
for (let r = 0; r <= 1.0001; r += 0.01) {
|
|
290
|
+
const e = planEconomics(plan, Math.min(1, r));
|
|
291
|
+
if (e.marginPct !== null && e.marginPct >= targetMarginPct) {
|
|
292
|
+
return { rate: Math.min(1, r), note: `at or above ${(r * 100).toFixed(0)}% cache this plan clears ${targetMarginPct}%` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { rate: null, note: `this plan never reaches ${targetMarginPct}% margin, at any cache rate` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* How much of a plan's allowance is left.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} plan
|
|
302
|
+
* @param {Record<string, number>} usedByModel tokens already spent, per model
|
|
303
|
+
*/
|
|
304
|
+
export function allowanceRemaining(plan, usedByModel = {}) {
|
|
305
|
+
const out = {};
|
|
306
|
+
for (const [model, granted] of Object.entries(plan?.tokens ?? {})) {
|
|
307
|
+
const used = Number(usedByModel?.[model]) || 0;
|
|
308
|
+
if (granted === null) {
|
|
309
|
+
// ⚠️ Unmetered is AVAILABLE and never exhausted. Treating null as 0 would
|
|
310
|
+
// tell an enterprise customer their negotiated capacity had run out.
|
|
311
|
+
out[model] = { granted: null, used, remaining: null, exhausted: false, available: true };
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
out[model] = {
|
|
315
|
+
granted,
|
|
316
|
+
used,
|
|
317
|
+
remaining: Math.max(0, granted - used),
|
|
318
|
+
exhausted: used >= granted,
|
|
319
|
+
/** ⚠️ A plan that grants 0 of a model is NOT "exhausted" — it never had any. */
|
|
320
|
+
available: granted > 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** One screen for a human, and every figure is computed here rather than quoted. */
|
|
327
|
+
export function formatPlan(plan, cacheRate = 0.95) {
|
|
328
|
+
const e = planEconomics(plan, cacheRate);
|
|
329
|
+
const price = plan.currency === 'AUD' ? `$${plan.priceLocal} AUD (~$${plan.priceUsd.toFixed(2)} USD)` : `$${plan.priceUsd} USD`;
|
|
330
|
+
const lines = [`${plan.label} — ${plan.priceUsd === 0 ? 'free' : price}`];
|
|
331
|
+
for (const [model, tokens] of Object.entries(plan.tokens)) {
|
|
332
|
+
if (!tokens) continue;
|
|
333
|
+
const per = costPerMillion(model, cacheRate);
|
|
334
|
+
lines.push(` ${(tokens / 1e6).toFixed(0)}M ${model}${per === null ? ' (unpriced)' : ` costs us $${((tokens / 1e6) * per).toFixed(2)} at ${(cacheRate * 100).toFixed(0)}% cache`}`);
|
|
335
|
+
}
|
|
336
|
+
if (e.marginPct !== null) {
|
|
337
|
+
lines.push(` total COGS $${e.cogsUsd.toFixed(2)} · margin ${e.marginPct.toFixed(0)}%`);
|
|
338
|
+
const be = breakEvenCacheRate(plan, 80);
|
|
339
|
+
lines.push(` ${be.note}`);
|
|
340
|
+
}
|
|
341
|
+
return lines;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* ── ⚠️⚠️ AN ALLOWANCE NOTHING ENFORCES IS A NUMBER ON A PRICING PAGE ────────
|
|
346
|
+
*
|
|
347
|
+
* `allowanceRemaining` shipped and was called by nobody, which is this
|
|
348
|
+
* package's signature defect wearing a business hat: the plan was expressible,
|
|
349
|
+
* auditable and completely unenforced. A limit that does not stop a run is
|
|
350
|
+
* marketing.
|
|
351
|
+
*
|
|
352
|
+
* ⚠️ IT REFUSES BEFORE THE SPEND, NOT AFTER. `budget.mjs` already states the
|
|
353
|
+
* rule — "it never spends money to discover it had none" — and the same applies
|
|
354
|
+
* one layer up: discovering an exhausted allowance by exhausting it further is
|
|
355
|
+
* the failure this gate exists to prevent.
|
|
356
|
+
*
|
|
357
|
+
* ⚠️ AND EVERY REFUSAL NAMES THE WAY OUT. "Limit reached" is an obstacle; "you
|
|
358
|
+
* have used 95M of 95M Acuvo Flash this period — Acuvo Pro grants 200M" is a
|
|
359
|
+
* decision. A gate that cannot be acted on just moves the user to a competitor
|
|
360
|
+
* with a clearer error message.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} args
|
|
363
|
+
* @param {object} args.plan
|
|
364
|
+
* @param {Record<string, number>} args.usedByModel tokens already spent, per PROVIDER id
|
|
365
|
+
* @param {string} args.model the provider id about to run
|
|
366
|
+
* @param {number} [args.projectedTokens] what this run is expected to add
|
|
367
|
+
* @param {object[]} [args.ladder] plans to suggest upgrading to
|
|
368
|
+
*/
|
|
369
|
+
export function planGate({ plan, usedByModel = {}, model, projectedTokens = 0, ladder = Object.values(PLANS) } = {}) {
|
|
370
|
+
const id = String(model ?? '');
|
|
371
|
+
const rawGranted = plan?.tokens?.[id];
|
|
372
|
+
if (rawGranted === null) {
|
|
373
|
+
// Unmetered: there is nothing to enforce, and saying "allowed" is the truth.
|
|
374
|
+
return { allowed: true, reason: 'unmetered', remaining: null, message: null };
|
|
375
|
+
}
|
|
376
|
+
const granted = Number(rawGranted ?? 0);
|
|
377
|
+
const used = Number(usedByModel?.[id]) || 0;
|
|
378
|
+
const remaining = Math.max(0, granted - used);
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* ⭐ THE UPGRADE THAT WOULD ACTUALLY HELP, found rather than hardcoded: the
|
|
382
|
+
* cheapest plan in the ladder that grants more of THIS model than the current
|
|
383
|
+
* one. Naming a tier that does not solve the problem is worse than naming none.
|
|
384
|
+
*/
|
|
385
|
+
const upgrade = ladder
|
|
386
|
+
.filter((p) => Number(p?.tokens?.[id] ?? 0) > granted)
|
|
387
|
+
.sort((a, b) => a.priceUsd - b.priceUsd)[0] ?? null;
|
|
388
|
+
const upgradeNote = upgrade
|
|
389
|
+
? ` ${upgrade.label} grants ${(upgrade.tokens[id] / 1e6).toFixed(0)}M.`
|
|
390
|
+
: '';
|
|
391
|
+
|
|
392
|
+
if (granted <= 0) {
|
|
393
|
+
return {
|
|
394
|
+
allowed: false,
|
|
395
|
+
reason: 'not-on-plan',
|
|
396
|
+
remaining: 0,
|
|
397
|
+
message: `${labelFor(id)} is not included in ${plan?.label ?? 'this plan'}.${upgradeNote}`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (remaining <= 0) {
|
|
401
|
+
return {
|
|
402
|
+
allowed: false,
|
|
403
|
+
reason: 'exhausted',
|
|
404
|
+
remaining: 0,
|
|
405
|
+
message: `${plan?.label ?? 'this plan'} has used all ${(granted / 1e6).toFixed(0)}M ${labelFor(id)} tokens for this period.${upgradeNote}`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* ⚠️ A PROJECTION THAT WOULD CROSS THE LINE STOPS THE RUN, matching
|
|
410
|
+
* `budget.mjs`'s "stop when the NEXT round would cross it". Starting a run
|
|
411
|
+
* that cannot finish spends the remainder and delivers nothing, which is the
|
|
412
|
+
* worst of both outcomes.
|
|
413
|
+
*/
|
|
414
|
+
if (projectedTokens > 0 && projectedTokens > remaining) {
|
|
415
|
+
return {
|
|
416
|
+
allowed: false,
|
|
417
|
+
reason: 'would-exceed',
|
|
418
|
+
remaining,
|
|
419
|
+
message: `this run is projected at ${(projectedTokens / 1e6).toFixed(1)}M ${labelFor(id)} tokens and only ${(remaining / 1e6).toFixed(1)}M remain on ${plan?.label ?? 'this plan'}.${upgradeNote}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return { allowed: true, reason: 'ok', remaining, message: null };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Acuvo's name for a provider id, without importing the catalogue into the math. */
|
|
426
|
+
function labelFor(id) {
|
|
427
|
+
if (id === 'deepseek/deepseek-v4-flash-0731') return 'Acuvo Flash';
|
|
428
|
+
if (id === 'deepseek/deepseek-v4-pro-0813') return 'Acuvo Pro';
|
|
429
|
+
return id;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Tokens used per PROVIDER id, from audit records.
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ THE MODEL THAT ANSWERED, NOT THE ONE REQUESTED. A run that fell back to a
|
|
436
|
+
* different model spent tokens on the model that actually served it, and
|
|
437
|
+
* charging the requested one would bill an allowance that was never touched.
|
|
438
|
+
*
|
|
439
|
+
* ⚠️ AND A RECORD WITH NO TOKEN COUNT IS COUNTED AS UNKNOWN, never as zero —
|
|
440
|
+
* silently free usage is how an allowance stops meaning anything.
|
|
441
|
+
*/
|
|
442
|
+
export function usageByModel(records = []) {
|
|
443
|
+
const byModel = {};
|
|
444
|
+
let unknown = 0;
|
|
445
|
+
for (const rec of records) {
|
|
446
|
+
const run = rec?.run;
|
|
447
|
+
if (!run) continue;
|
|
448
|
+
const id = run.model?.answered ?? run.model?.requested ?? null;
|
|
449
|
+
/**
|
|
450
|
+
* ⚠️⚠️ CHECKED BEFORE COERCION, AND MY FIRST VERSION WAS NOT. `Number(null)`
|
|
451
|
+
* is `0`, and `Number.isFinite(0)` is TRUE — so a run that recorded no token
|
|
452
|
+
* count was silently counted as ZERO USAGE rather than as unknown, which is
|
|
453
|
+
* precisely the "silently free usage" this function's comment forbids. The
|
|
454
|
+
* test caught it; the code and its own documentation disagreed.
|
|
455
|
+
*/
|
|
456
|
+
const tokens = typeof run.tokens === 'number' && Number.isFinite(run.tokens) ? run.tokens : null;
|
|
457
|
+
if (!id || tokens === null) { unknown += 1; continue; }
|
|
458
|
+
byModel[id] = (byModel[id] ?? 0) + tokens;
|
|
459
|
+
}
|
|
460
|
+
return { byModel, unknown };
|
|
461
|
+
}
|