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/budget.mjs
ADDED
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ STOP AT A DOLLAR, NOT AT A COUNTER ──────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Today the loop stops because `round > maxRounds`. That number is arbitrary —
|
|
5
|
+
* it is not a thing the user has an opinion about, it does not correspond to
|
|
6
|
+
* anything they can feel, and it is the reason `plan-ledger.mjs` had to be
|
|
7
|
+
* written at all: the agent kept driving into a wall it could not see and
|
|
8
|
+
* losing the LAST deliverable every single time.
|
|
9
|
+
*
|
|
10
|
+
* The constraint that is real is MONEY. "Keep going until the job is done or
|
|
11
|
+
* you have spent fifty cents" is a sentence a user can actually reason about,
|
|
12
|
+
* and nobody in this category sells it. This module is that sentence.
|
|
13
|
+
*
|
|
14
|
+
* ── ⭐ THE ONE DECISION THAT MAKES IT SAFE ───────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* NEVER START A ROUND YOU CANNOT AFFORD TO FINISH.
|
|
17
|
+
*
|
|
18
|
+
* The naive governor stops when `spent >= limit`. That guarantees an overshoot,
|
|
19
|
+
* because the round that crosses the line has already been paid for by the time
|
|
20
|
+
* anyone can look. So the check here is on the PROJECTION: refuse when
|
|
21
|
+
* `spent + projectedNextRound` would cross the line, while there is still room
|
|
22
|
+
* to have been wrong.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ Overshooting a stated budget is the one behaviour that makes a user never
|
|
25
|
+
* trust the flag again. Stopping a few percent short costs them nothing they
|
|
26
|
+
* will ever notice. Every trade-off below is resolved in that direction, the
|
|
27
|
+
* same way `stuck.mjs` resolves its own asymmetry.
|
|
28
|
+
*
|
|
29
|
+
* ── ⚠️ ROUNDS ARE NOT UNIFORM, SO A FLAT AVERAGE IS A BUG ────────────────────
|
|
30
|
+
*
|
|
31
|
+
* Cost per round GROWS, because every round carries the whole history back into
|
|
32
|
+
* the prompt. A mean over all rounds is therefore dragged down by the cheap
|
|
33
|
+
* early ones, under-projects the expensive late ones, and overshoots exactly
|
|
34
|
+
* when it matters most — at the end of a long run, where the rounds are most
|
|
35
|
+
* expensive and the remaining budget is thinnest.
|
|
36
|
+
*
|
|
37
|
+
* So the projection is a LINEAR EXTRAPOLATION over a sliding window of the most
|
|
38
|
+
* recent rounds (`trendWindow`, default 3):
|
|
39
|
+
*
|
|
40
|
+
* slope = (last − first) / (n − 1) across the window
|
|
41
|
+
* trend = last + slope
|
|
42
|
+
* raw = max(trend, peak-in-window) never project below a recent peak
|
|
43
|
+
* usd = raw × safetyFactor the figure decisions are made on
|
|
44
|
+
*
|
|
45
|
+
* Two clamps, each with a reason:
|
|
46
|
+
* · `max(…, peak)` — a dip must not be read as a downward trend and used to
|
|
47
|
+
* justify one more round, and one cheap round after an expensive one must
|
|
48
|
+
* not reset the estimate. The window slides, so this forgets on its own
|
|
49
|
+
* after `trendWindow` rounds; it is conservatism with an expiry date.
|
|
50
|
+
* · `safetyFactor` — 10% of headroom, because a straight line cannot see a
|
|
51
|
+
* curve coming.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ THERE WAS A THIRD CLAMP AND MUTATION TESTING PROVED IT WAS DEAD CODE.
|
|
54
|
+
* `trend = last + max(0, slope)` was written here first, with a confident
|
|
55
|
+
* comment about never extrapolating downward. Deleting the `max(0, …)` changed
|
|
56
|
+
* ZERO test outcomes, and the algebra says why it always will: `slope < 0` means
|
|
57
|
+
* `last < first ≤ peak`, so `trend = last + slope < last ≤ peak` and the peak
|
|
58
|
+
* clamp had already decided the answer. A line that cannot fire, sitting under a
|
|
59
|
+
* comment claiming it is load-bearing, is the exact failure this package has
|
|
60
|
+
* been bitten by seven times — so it is gone, and the peak clamp is documented
|
|
61
|
+
* as the thing that actually enforces the rule.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ AND THE HONEST LIMIT OF THAT: a cost curve growing faster than ~1.4x PER
|
|
64
|
+
* ROUND can still cross the line, because no linear extrapolation catches an
|
|
65
|
+
* exponent. The overshoot is bounded by ONE round's cost — never unbounded, and
|
|
66
|
+
* `reserveUsd` exists as the second line of defence for anyone who cares. The
|
|
67
|
+
* test suite pins that bound rather than pretending the guarantee is absolute.
|
|
68
|
+
*
|
|
69
|
+
* ── ⚠️⚠️ AND THE FAILURE THAT WOULD BE WORST OF ALL: UNKNOWN PRICED AS FREE ──
|
|
70
|
+
*
|
|
71
|
+
* OpenRouter usually returns `usage.cost`. Not always. `aggregateUsage` in
|
|
72
|
+
* turn.mjs already handles a null usage by skipping it — correct for a summary
|
|
73
|
+
* line, catastrophic for a governor: a run whose provider stops reporting cost
|
|
74
|
+
* would spend `spent += 0` forever and bill the user for the privilege. That is
|
|
75
|
+
* the worst available failure in this file, so unknown is NEVER zero:
|
|
76
|
+
*
|
|
77
|
+
* 1. a real `cost` (or `costUsd`) → believed, even when it is 0.0
|
|
78
|
+
* 2. no cost but a token count → priced at `usdPerMillionTokens`
|
|
79
|
+
* 3. nothing at all → charged the CURRENT PROJECTION
|
|
80
|
+
*
|
|
81
|
+
* and cases 2 and 3 mark the whole total as an ESTIMATE, which `report()` says
|
|
82
|
+
* out loud with the count of blind rounds. A number the user might act on must
|
|
83
|
+
* never hide that it was guessed.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ CASE 1 INCLUDES ZERO ON PURPOSE. Free-tier and fully-cached completions
|
|
86
|
+
* really do cost $0.00, and "helpfully" replacing that with an estimate is a
|
|
87
|
+
* check that fails correct work — the mistake this repo has made four times in
|
|
88
|
+
* one day. An explicit zero is data; an absent field is not.
|
|
89
|
+
*
|
|
90
|
+
* ── ⚠️ THE FLOOR ────────────────────────────────────────────────────────────
|
|
91
|
+
* A budget too small to fit one round must fail BEFORE the first request, with
|
|
92
|
+
* a message naming both numbers. Starting, spending, and then stopping with
|
|
93
|
+
* nothing to show is strictly worse than refusing. `canContinue()` returns
|
|
94
|
+
* `reason: 'too-small'` for that, distinct from `'would-exceed'` (ran out) and
|
|
95
|
+
* `'limit-reached'` (nothing left at all) — three different mistakes deserve
|
|
96
|
+
* three different words.
|
|
97
|
+
*
|
|
98
|
+
* ── ⚠️⚠️ AND THE THIRD FAILURE, FOUND 2026-08-14: A WHOLE CATEGORY MISSING ──
|
|
99
|
+
*
|
|
100
|
+
* Everything above governs MODEL tokens. Every GPU dollar — `generate_image`,
|
|
101
|
+
* `see_page`, `speak`, `transcribe`, `make_document`, `read_document`,
|
|
102
|
+
* `read_table` — was outside the book entirely, so `--budget`, `--fleet-budget`
|
|
103
|
+
* and `acuvo spend` were three features priced off a number that excluded the
|
|
104
|
+
* most expensive verb in the package. See the meter below: it charges the same
|
|
105
|
+
* ledger, against the same ceiling, and labels every dollar of it an ESTIMATE
|
|
106
|
+
* because nobody bills us per call.
|
|
107
|
+
*
|
|
108
|
+
* ── PURE ABOUT THE THINGS THAT MATTER ───────────────────────────────────────
|
|
109
|
+
* No network, no model, no fs, no ambient time. Data in, data out, plus an
|
|
110
|
+
* injected clock — which is why the whole thing is provable for $0.00, and why
|
|
111
|
+
* the "$0.01 is a lot when testing" rule is not even slightly strained by it.
|
|
112
|
+
* ⚠️ The GPU meter below IS module-level state, because a GPU verb has no
|
|
113
|
+
* handle on the run's governor to charge; `resetSpendMeter()` is its seam.
|
|
114
|
+
*
|
|
115
|
+
* WIRING: see the note at the bottom of this file. It is ten lines in
|
|
116
|
+
* `runSession` plus one flag.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The seed used before a single round has been observed.
|
|
121
|
+
*
|
|
122
|
+
* MEASURED, not guessed: `turn.mjs` records a real round at 1,036 tokens and
|
|
123
|
+
* $0.000231. This sits ~2x above it, because the seed's only job is to answer
|
|
124
|
+
* "can this budget afford ANYTHING" and a seed that under-estimates lets a
|
|
125
|
+
* hopeless budget start. It is replaced by real data the moment round 1 lands.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_FIRST_ROUND_USD = 0.0005;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ── ⭐⭐ THE CEILING IS ON BY DEFAULT, AND THAT IS THE PRODUCT ───────────────
|
|
131
|
+
*
|
|
132
|
+
* This governor existed, worked, and was UNREACHABLE: `budgetUsd` defaulted to
|
|
133
|
+
* null, so the one behaviour no competitor offers — a hard cap enforced BEFORE
|
|
134
|
+
* the round, not an alert after it — required typing a flag nobody knew about.
|
|
135
|
+
* A differentiator behind an unknown flag is not a differentiator.
|
|
136
|
+
*
|
|
137
|
+
* ── WHY $0.02 AND NOT A ROUNDER NUMBER ──────────────────────────────────────
|
|
138
|
+
* Measured on this package: a task costs $0.0008–$0.003, a full three-rung
|
|
139
|
+
* escalation $0.0035, and a real round $0.000231. $0.02 is ~7–25x a whole task
|
|
140
|
+
* and ~85x a round. So on ordinary work it never fires, and on a runaway it
|
|
141
|
+
* costs two cents to find out.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ A CEILING IS A BLAST RADIUS, NOT A TARGET, and it must not become a check
|
|
144
|
+
* that fails correct work. Two guards make that true: the loop already stops
|
|
145
|
+
* itself long before this (`verified`, `no-tool-calls` — nothing in the shipped
|
|
146
|
+
* bench has ever consumed its round budget), and when this DOES fire on a limit
|
|
147
|
+
* the user never chose, the message says so and names the flag that raises it.
|
|
148
|
+
* A wall with no way over it is the failure this repo keeps paying for.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ AND IT IS NOT A SPEND COMMITMENT. Nothing here makes a run cost more; it
|
|
151
|
+
* can only make one stop earlier.
|
|
152
|
+
*
|
|
153
|
+
* ── ⚠️⚠️ READ THIS BEFORE CHANGING THE NUMBER (added 2026-08-14) ────────────
|
|
154
|
+
*
|
|
155
|
+
* The measurements above are MODEL measurements, and until today they were the
|
|
156
|
+
* only spend the ceiling could see. Now that GPU time is charged too, one
|
|
157
|
+
* number in this comment is quietly out of date: **a cold image render is
|
|
158
|
+
* $0.0398 — twice this ceiling on its own.** So a default-budget run that calls
|
|
159
|
+
* `generate_image` once will now stop straight after it.
|
|
160
|
+
*
|
|
161
|
+
* ⭐ THAT IS THE CEILING WORKING, NOT A REGRESSION. The work genuinely cost
|
|
162
|
+
* four cents; before today it reported $0.002 and carried on, which is the
|
|
163
|
+
* defect. The stop message says it is the default, not a number the user chose,
|
|
164
|
+
* and names the flag that raises it.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ BUT IT IS A REAL PRODUCT DECISION AND IT IS NOT MINE TO MAKE. Raising this
|
|
167
|
+
* to accommodate imagery is a deliberate change to what "the default blast
|
|
168
|
+
* radius" means, and it should be made by someone looking at the whole product,
|
|
169
|
+
* not smuggled in beside a metering fix. It is named here so the next person to
|
|
170
|
+
* meet it knows it was seen rather than missed.
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* ── ⭐⭐⭐ RAISED $0.02 → $0.25 ON 2026-08-19 — THE DECISION NAMED ABOVE ─────
|
|
174
|
+
*
|
|
175
|
+
* The paragraph above says this is *"a real product decision and it is not
|
|
176
|
+
* mine to make… it should be made by someone looking at the whole product."*
|
|
177
|
+
* Roman made it: *"we need to get it insanely good fast… it's not capable and
|
|
178
|
+
* it shouldn't take this long."*
|
|
179
|
+
*
|
|
180
|
+
* ⚠️ THE SIZE OF THE CHANGE IS SMALLER THAN IT LOOKS. Two measured live runs
|
|
181
|
+
* the same day cost **$0.0039 and $0.0019** — so the old default was not a
|
|
182
|
+
* budget, it was a hard stop roughly five tasks wide.
|
|
183
|
+
*
|
|
184
|
+
* ⭐⭐ AND THE NUMBER IS MATCHED TO `DEFAULT_MAX_ROUNDS`, NOT PICKED. I first
|
|
185
|
+
* set this to $0.25 and `test/budget-default-ceiling.test.mjs` refused it —
|
|
186
|
+
* correctly. It asserts BOTH bounds: clear the dearest observed task several
|
|
187
|
+
* times over, **and** stay cheap enough that a runaway is cheap to discover.
|
|
188
|
+
*
|
|
189
|
+
* The arithmetic the guard forced: 24 rounds at roughly $0.001–$0.003 a round
|
|
190
|
+
* (rounds get dearer as context grows) is **$0.03–$0.06**. So $0.05 is the
|
|
191
|
+
* budget that actually backs 24 rounds. $0.25 would have been an allowance for
|
|
192
|
+
* ~100 rounds against a hard cap of 64 — money that could only ever be spent by
|
|
193
|
+
* a loop going nowhere.
|
|
194
|
+
*
|
|
195
|
+
* ⚠️ THIS IS THE BLAST RADIUS AND THAT IS WHY IT IS BOUNDED. Rounds are nearly
|
|
196
|
+
* free to raise because the loop self-stops at `verified`; money is not,
|
|
197
|
+
* because a loop going nowhere spends its whole allowance.
|
|
198
|
+
*
|
|
199
|
+
* ⭐ Overridable per run (`--budget`), and the stop message names the flag — a
|
|
200
|
+
* user who hits it is told it was a default, not a choice they made.
|
|
201
|
+
*/
|
|
202
|
+
export const DEFAULT_BUDGET_USD = 0.05;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* ── ⚠️⚠️ THE CEILING WAS PER TURN, AND IT IS SOLD AS PER RUN ────────────────
|
|
206
|
+
*
|
|
207
|
+
* `--help` and README call `--budget` the ceiling for the run. In an interactive
|
|
208
|
+
* session it was handed out FRESH ON EVERY TURN: `runChat` loops calling
|
|
209
|
+
* `oneTurn`, and `oneTurn` passed `opts.budgetUsd` unmodified each time. A
|
|
210
|
+
* forty-turn conversation therefore permitted forty times the number the user
|
|
211
|
+
* agreed to — $0.80 against a stated $0.02.
|
|
212
|
+
*
|
|
213
|
+
* ⭐ The one-shot path was always correct (one turn, nothing to accumulate), and
|
|
214
|
+
* the RESUME path already got this right — it subtracts the prior run's spend
|
|
215
|
+
* before starting. This is that same subtraction, applied to the turn loop, and
|
|
216
|
+
* kept in one pure function so the two paths cannot drift into two answers.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ RETURNS null FOR "NO CEILING", because that is what an absent budget means
|
|
219
|
+
* everywhere else in this file. Do not coerce it to 0 — a 0 budget can never
|
|
220
|
+
* start a round, so the two must never be confused.
|
|
221
|
+
*
|
|
222
|
+
* @param {number|null} limitUsd the ceiling for the whole session
|
|
223
|
+
* @param {number} spentUsd what previous turns already cost
|
|
224
|
+
* @returns {{ ok: boolean, remainingUsd: number|null, message?: string }}
|
|
225
|
+
*/
|
|
226
|
+
/**
|
|
227
|
+
* ── ⭐⭐ THE WAY OUT, AND WHY IT CANNOT BE ONE SENTENCE ──────────────────────
|
|
228
|
+
*
|
|
229
|
+
* "A refusal that does not say what to type is just an obstacle" is this
|
|
230
|
+
* package's rule and the reason its refusals are its best-written part. But the
|
|
231
|
+
* advice has to be TRUE, and there are now three different ceilings wearing the
|
|
232
|
+
* same message.
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ THE ONE THAT WAS WRONG: a ceiling set by `.acuvo/policy.json` told the user
|
|
235
|
+
* to "raise it with --budget" — which cannot work, because `costBudget` takes
|
|
236
|
+
* the MINIMUM of the policy's number and the user's. So the refusal handed out a
|
|
237
|
+
* remedy that is guaranteed to fail, and the user would have tried it, watched
|
|
238
|
+
* nothing change, and concluded the tool was broken rather than governed.
|
|
239
|
+
*
|
|
240
|
+
* ⭐ An admin cap that a user could type their way past would not be a cap. The
|
|
241
|
+
* honest sentence says who set it and where it lives.
|
|
242
|
+
*
|
|
243
|
+
* @param {{ limitUsd: number, limitIsDefault?: boolean, limitSource?: string|null }} o
|
|
244
|
+
*/
|
|
245
|
+
export function budgetWayOut({ limitUsd, limitIsDefault = false, limitSource = null }) {
|
|
246
|
+
if (limitSource === 'policy') {
|
|
247
|
+
return ` This ceiling comes from .acuvo/policy.json, so --budget cannot raise it —`
|
|
248
|
+
+ ` change maxCostUsd there, or ask whoever set the policy.`;
|
|
249
|
+
}
|
|
250
|
+
if (limitIsDefault) {
|
|
251
|
+
return ` This is the default ceiling of ${formatUsd(limitUsd)}, not one you set —`
|
|
252
|
+
+ ` raise it with --budget (e.g. --budget 0.50) or remove it with --budget none.`;
|
|
253
|
+
}
|
|
254
|
+
return ' Raise it with --budget if the job needs more.';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function remainingForTurn(limitUsd, spentUsd = 0, { limitIsDefault = false, limitSource = null } = {}) {
|
|
258
|
+
if (limitUsd === null || limitUsd === undefined) return { ok: true, remainingUsd: null };
|
|
259
|
+
const spent = Number.isFinite(spentUsd) && spentUsd > 0 ? spentUsd : 0;
|
|
260
|
+
const left = limitUsd - spent;
|
|
261
|
+
if (left <= USD_EPSILON) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
remainingUsd: 0,
|
|
265
|
+
message: `this session has spent ${formatUsd(spent)} of its ${formatUsd(limitUsd)} budget, so there is `
|
|
266
|
+
+ `nothing left for another turn.${budgetWayOut({ limitUsd, limitIsDefault, limitSource })}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { ok: true, remainingUsd: left };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* ── ⭐⭐ THE CEILING WAS DISCOVERED AT ROUND 6 AND IT IS KNOWABLE AT ROUND 1 ──
|
|
274
|
+
*
|
|
275
|
+
* `canContinue()` above is a per-round question: *can I afford the NEXT round?*
|
|
276
|
+
* It is the right question to STOP on and the wrong one to PLAN on, because the
|
|
277
|
+
* first time it says no is the first time the user hears anything at all. On the
|
|
278
|
+
* default ceiling that lands around round 6 — five rounds of money already
|
|
279
|
+
* spent, the work half-done, and the remedy (`--budget 0.50`) only useful if you
|
|
280
|
+
* start again from nothing.
|
|
281
|
+
*
|
|
282
|
+
* ⭐ The moment the arithmetic becomes possible is the END OF ROUND 1: one real
|
|
283
|
+
* round has been priced, so `projectNext()` has data instead of a seed, and
|
|
284
|
+
* `maxRounds` is known. Multiplying the two is the whole forecast. Everything
|
|
285
|
+
* this function needs already existed; nobody was asking.
|
|
286
|
+
*
|
|
287
|
+
* ── ⚠️⚠️ IT FORECASTS. IT MUST NOT REFUSE. ──────────────────────────────────
|
|
288
|
+
*
|
|
289
|
+
* The tempting shape is "if the run cannot afford all its rounds, stop now" —
|
|
290
|
+
* i.e. hoist the existing `would-exceed` refusal to round 1. That would be a
|
|
291
|
+
* check that fails correct work, which this package has paid for four times in
|
|
292
|
+
* one day. `maxRounds` is a CEILING, not a plan: the loop already stops itself
|
|
293
|
+
* on `verified` and `no-tool-calls`, and nothing in the shipped bench has ever
|
|
294
|
+
* consumed its round budget. Refusing a run that would have finished in three
|
|
295
|
+
* rounds, because it could not have afforded sixteen, breaks the common case to
|
|
296
|
+
* warn about the rare one.
|
|
297
|
+
*
|
|
298
|
+
* ⭐ So the STOP stays exactly where it was — `canContinue()`, unchanged, same
|
|
299
|
+
* constant, same reasons — and this adds only the SENTENCE, at the point where
|
|
300
|
+
* hearing it is still actionable: after one round the user has spent a fraction
|
|
301
|
+
* of a cent and can restart with a real ceiling having lost nothing.
|
|
302
|
+
*
|
|
303
|
+
* ── ⚠️ THE NUMBER IS A FLOOR AND THE MESSAGE SAYS SO ────────────────────────
|
|
304
|
+
*
|
|
305
|
+
* `projectedUsd × roundsRemaining` assumes every remaining round costs what the
|
|
306
|
+
* next one is projected to cost. The header of this file establishes the
|
|
307
|
+
* opposite — cost per round GROWS, because every round carries the whole history
|
|
308
|
+
* back into the prompt. So the total is an UNDER-estimate, and the direction is
|
|
309
|
+
* the safe one for a warning (we cannot cry wolf) and the unsafe one for a
|
|
310
|
+
* refusal (we would under-warn). That asymmetry is a second reason this is not
|
|
311
|
+
* a stop: a number good enough to caution with is not good enough to act on.
|
|
312
|
+
*
|
|
313
|
+
* ⚠️ AND IT REUSES `budgetWayOut`. Three ceilings already wear different
|
|
314
|
+
* remedies (default / explicit / policy), and a fourth sentence written here
|
|
315
|
+
* would be the fourth place for that to drift — the exact failure recorded
|
|
316
|
+
* against `FLEET_STOP_REASONS` below.
|
|
317
|
+
*
|
|
318
|
+
* @param {object} o
|
|
319
|
+
* @param {number} o.remainingUsd what is left AFTER the reserve, right now
|
|
320
|
+
* @param {number} o.projectedUsd what the next round is projected to cost
|
|
321
|
+
* @param {number|null} o.limitUsd the stated ceiling, for the sentence
|
|
322
|
+
* @param {number} o.spentUsd the run's total so far
|
|
323
|
+
* @param {number} o.roundsUsed rounds already priced
|
|
324
|
+
* @param {number} o.maxRounds the round ceiling this run was given
|
|
325
|
+
* @returns {{ ok: boolean, reason: string, roundsAffordable: number, roundsRemaining: number,
|
|
326
|
+
* stopsAtRound: number|null, projectedTotalUsd: number, message: string }}
|
|
327
|
+
*/
|
|
328
|
+
export function forecastRun({
|
|
329
|
+
remainingUsd,
|
|
330
|
+
projectedUsd,
|
|
331
|
+
limitUsd = null,
|
|
332
|
+
spentUsd = 0,
|
|
333
|
+
roundsUsed = 0,
|
|
334
|
+
maxRounds = 0,
|
|
335
|
+
limitIsDefault = false,
|
|
336
|
+
limitSource = null,
|
|
337
|
+
} = {}) {
|
|
338
|
+
const none = (reason, message) => ({
|
|
339
|
+
ok: true, reason, roundsAffordable: Infinity, roundsRemaining: 0,
|
|
340
|
+
stopsAtRound: null, projectedTotalUsd: isNum(spentUsd) ? spentUsd : 0, message,
|
|
341
|
+
});
|
|
342
|
+
if (limitUsd === null || limitUsd === undefined || !Number.isFinite(remainingUsd)) {
|
|
343
|
+
return none('no-budget-set', 'no budget limit set, so there is nothing to forecast against.');
|
|
344
|
+
}
|
|
345
|
+
const roundsRemaining = Math.max(0, Math.floor(maxRounds) - Math.floor(roundsUsed));
|
|
346
|
+
if (roundsRemaining === 0) return none('fits', 'no rounds remain, so there is nothing left to buy.');
|
|
347
|
+
/**
|
|
348
|
+
* ⚠️ A ZERO OR NEGATIVE PROJECTION CANNOT BE DIVIDED BY, and it is reachable:
|
|
349
|
+
* a fully-cached or free-tier round really does report $0.00, which the header
|
|
350
|
+
* of this file insists on believing. "Every round is free" is a legitimate
|
|
351
|
+
* state, and the honest forecast for it is that the money never runs out.
|
|
352
|
+
*/
|
|
353
|
+
if (!isNum(projectedUsd) || projectedUsd <= 0) {
|
|
354
|
+
return none('fits', 'the last round cost nothing, so no total can be projected from it yet.');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const roundsAffordable = Math.max(0, Math.floor((remainingUsd + USD_EPSILON) / projectedUsd));
|
|
358
|
+
const projectedTotalUsd = spentUsd + (projectedUsd * roundsRemaining);
|
|
359
|
+
if (roundsAffordable >= roundsRemaining) {
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
reason: 'fits',
|
|
363
|
+
roundsAffordable,
|
|
364
|
+
roundsRemaining,
|
|
365
|
+
stopsAtRound: null,
|
|
366
|
+
projectedTotalUsd,
|
|
367
|
+
message: `at ~${formatUsd(projectedUsd)} a round, all ${roundsRemaining} remaining round`
|
|
368
|
+
+ `${roundsRemaining === 1 ? '' : 's'} fit inside the ${formatUsd(limitUsd)} ceiling.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* ⚠️ `+ 1` BECAUSE THE ROUND THAT IS REFUSED IS THE ONE AFTER THE LAST
|
|
374
|
+
* AFFORDABLE ONE. Naming the last round that WORKS would send the user looking
|
|
375
|
+
* for a stop message on a round that succeeded.
|
|
376
|
+
*/
|
|
377
|
+
const stopsAtRound = Math.floor(roundsUsed) + roundsAffordable + 1;
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
reason: 'will-run-out',
|
|
381
|
+
roundsAffordable,
|
|
382
|
+
roundsRemaining,
|
|
383
|
+
stopsAtRound,
|
|
384
|
+
projectedTotalUsd,
|
|
385
|
+
message: `at ~${formatUsd(projectedUsd)} a round this run needs at least ${formatUsd(projectedTotalUsd)} to use all `
|
|
386
|
+
+ `${Math.floor(maxRounds)} of its rounds, and the ceiling is ${formatUsd(limitUsd)} — on this trend it stops around `
|
|
387
|
+
+ `round ${stopsAtRound} of ${Math.floor(maxRounds)}. That total is a FLOOR: rounds get dearer as the transcript grows. `
|
|
388
|
+
+ `It may well finish sooner and never reach the wall.${budgetWayOut({ limitUsd, limitIsDefault, limitSource })}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The fallback price when the provider reports tokens but no cost.
|
|
394
|
+
*
|
|
395
|
+
* Same measurement, inverted: 1,036 tokens for $0.000231 is $0.223 per million.
|
|
396
|
+
* Rounded UP to $0.30, because over-pricing an unknown stops the run slightly
|
|
397
|
+
* early and under-pricing it lets the run overshoot — and only one of those two
|
|
398
|
+
* shows up on a bill.
|
|
399
|
+
*
|
|
400
|
+
* ── ⚠️⚠️ THAT MEASUREMENT WAS A COLD ROUND, AND EVERY REAL SESSION IS NOT ────
|
|
401
|
+
*
|
|
402
|
+
* Corrected 2026-08-21 against a real end-to-end run: a 5-round bug-fix task
|
|
403
|
+
* reported **87,814 tokens at 80% cache** and was priced at $0.0263 by this
|
|
404
|
+
* constant. The console's own measured rate for the same model —
|
|
405
|
+
* `FLASH_COST_MICROS_PER_MILLION`, derived from real builds — is **$0.0357 per
|
|
406
|
+
* million blended**, and **$0.117 cold**. So $0.30 is ~2.6x the COLD rate and
|
|
407
|
+
* ~8x the rate an actual cached session pays. That run really cost about
|
|
408
|
+
* **$0.003**, not $0.026.
|
|
409
|
+
*
|
|
410
|
+
* ⚠️ THE HARM IS NOT COSMETIC. This constant governs `canContinue()`, so a user
|
|
411
|
+
* with the default ceiling was being stopped after roughly an eighth of the work
|
|
412
|
+
* they had paid for, and every cost line the CLI printed overstated by the same
|
|
413
|
+
* factor. "Over-pricing an unknown stops the run slightly early" was true at
|
|
414
|
+
* 1.3x; it is a different claim at 8x.
|
|
415
|
+
*
|
|
416
|
+
* ⭐ SET TO THE COLD RATE, NOT THE BLENDED ONE. The safe direction still
|
|
417
|
+
* matters: this fires exactly when the provider went silent, and a silent
|
|
418
|
+
* provider is also the case where a session might genuinely be uncached (round 1
|
|
419
|
+
* is ALWAYS 0% cache, and a fresh session is all round ones). Pricing the
|
|
420
|
+
* unknown at the worst REAL rate keeps the governor honest without inventing a
|
|
421
|
+
* 2.6x penalty on top of it.
|
|
422
|
+
*/
|
|
423
|
+
export const DEFAULT_USD_PER_MILLION_TOKENS = 0.30;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* ── ⭐⭐ THE PER-MILLION RATES, BY WHAT THE TOKEN ACTUALLY IS ────────────────
|
|
427
|
+
*
|
|
428
|
+
* DeepSeek Flash prices input, output and cached input differently, which is the
|
|
429
|
+
* whole reason a single blended constant could not be right. Cached input is
|
|
430
|
+
* roughly a tenth of fresh input — that discount IS the business model, and
|
|
431
|
+
* pricing it at the fresh rate is what made a cached session look 8x dearer than
|
|
432
|
+
* it was.
|
|
433
|
+
*
|
|
434
|
+
* ⚠️ Exported so a test can hold them and so nobody re-derives them inline.
|
|
435
|
+
*/
|
|
436
|
+
export const RATE_USD_PER_MILLION = Object.freeze({
|
|
437
|
+
input: 0.14,
|
|
438
|
+
output: 0.28,
|
|
439
|
+
cachedInput: 0.014,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Price one round from the token SPLIT, or null when the usage object does not
|
|
444
|
+
* carry one.
|
|
445
|
+
*
|
|
446
|
+
* ⚠️ ACCEPTS BOTH VOCABULARIES. OpenRouter says `prompt_tokens` /
|
|
447
|
+
* `completion_tokens` and nests the cache read under
|
|
448
|
+
* `prompt_tokens_details.cached_tokens`; DeepSeek's own API says
|
|
449
|
+
* `prompt_cache_hit_tokens` at the top level. A reader that knows one spelling
|
|
450
|
+
* silently prices the other at zero cache — which is the same 8x error wearing
|
|
451
|
+
* a different hat.
|
|
452
|
+
*
|
|
453
|
+
* ⚠️ AND CACHED IS CLAMPED TO PROMPT. A provider reporting more cached tokens
|
|
454
|
+
* than prompt tokens would otherwise produce a NEGATIVE fresh-input term and
|
|
455
|
+
* under-bill; clamping keeps the arithmetic honest against a bad payload.
|
|
456
|
+
*/
|
|
457
|
+
export function priceFromSplit(usage) {
|
|
458
|
+
const u = usage && typeof usage === 'object' ? usage : {};
|
|
459
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
|
|
460
|
+
|
|
461
|
+
const prompt = n(u.promptTokens) ?? n(u.prompt_tokens);
|
|
462
|
+
const completion = n(u.completionTokens) ?? n(u.completion_tokens);
|
|
463
|
+
if (prompt === null || completion === null) return null;
|
|
464
|
+
|
|
465
|
+
const details = u.prompt_tokens_details && typeof u.prompt_tokens_details === 'object'
|
|
466
|
+
? u.prompt_tokens_details
|
|
467
|
+
: {};
|
|
468
|
+
const cachedRaw = n(u.cachedTokens)
|
|
469
|
+
?? n(u.cached_tokens)
|
|
470
|
+
?? n(details.cached_tokens)
|
|
471
|
+
?? n(u.prompt_cache_hit_tokens)
|
|
472
|
+
?? 0;
|
|
473
|
+
const cached = Math.min(cachedRaw, prompt);
|
|
474
|
+
const fresh = prompt - cached;
|
|
475
|
+
|
|
476
|
+
return (fresh / 1e6) * RATE_USD_PER_MILLION.input
|
|
477
|
+
+ (cached / 1e6) * RATE_USD_PER_MILLION.cachedInput
|
|
478
|
+
+ (completion / 1e6) * RATE_USD_PER_MILLION.output;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** How many recent rounds the trend is drawn through. */
|
|
482
|
+
export const DEFAULT_TREND_WINDOW = 3;
|
|
483
|
+
|
|
484
|
+
/** Headroom on every projection. A straight line cannot see a curve coming. */
|
|
485
|
+
export const DEFAULT_SAFETY_FACTOR = 1.1;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* A trillionth of a cent. Exists so that `0.001 + 0.001 + 0.001 > 0.003`
|
|
489
|
+
* (which is TRUE in IEEE 754 by 5e-19) cannot refuse a round that exactly fits.
|
|
490
|
+
* Float noise must never be the thing that stops a paid run.
|
|
491
|
+
*/
|
|
492
|
+
export const USD_EPSILON = 1e-12;
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* ⭐ THE FLEET STOPS LIVE HERE, NOT IN `fleet-budget.mjs`, TO KILL A CYCLE.
|
|
496
|
+
*
|
|
497
|
+
* `fleet-budget.mjs` already imports `formatUsd` and `USD_EPSILON` from this
|
|
498
|
+
* file. Declaring the reason strings there and importing them back would make
|
|
499
|
+
* the two modules import each other, and a circular ESM import of a `const` is
|
|
500
|
+
* a live binding that is briefly `undefined` — a fault that shows up as an
|
|
501
|
+
* empty reason string, not as an error. One direction only: fleet imports
|
|
502
|
+
* budget, never the reverse.
|
|
503
|
+
*
|
|
504
|
+
* ⚠️ AND THEY ARE SPREAD INTO `BUDGET_REASONS` RATHER THAN RETYPED THERE.
|
|
505
|
+
* `escalate.mjs` builds its own stop list as `BUDGET_REASONS` minus the two
|
|
506
|
+
* that are not stops, so a fleet stop is automatically routed as the budget
|
|
507
|
+
* stop it is. Retyping the strings in a second place is exactly how three of
|
|
508
|
+
* four stop-reasons once ended up naming constants that did not exist, with
|
|
509
|
+
* every test green.
|
|
510
|
+
*/
|
|
511
|
+
export const FLEET_STOP_REASONS = ['fleet-limit-reached', 'fleet-would-exceed'];
|
|
512
|
+
|
|
513
|
+
/** Every verdict `canContinue()` can return, so a caller can switch on it. */
|
|
514
|
+
export const BUDGET_REASONS = ['ok', 'no-budget-set', 'too-small', 'would-exceed', 'limit-reached', ...FLEET_STOP_REASONS];
|
|
515
|
+
|
|
516
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Money, printed so it never lies.
|
|
520
|
+
*
|
|
521
|
+
* ⚠️ FOUR DECIMALS IS NOT ENOUGH HERE. A round costs $0.000231; at four
|
|
522
|
+
* decimals it prints as `$0.0002`, and at a tenth of that it prints as
|
|
523
|
+
* `$0.0000` — a confident zero for money that was really spent. So the
|
|
524
|
+
* precision grows until at least two significant digits survive, and an amount
|
|
525
|
+
* too small even for that is printed as `<$0.00000001` rather than as nothing.
|
|
526
|
+
*/
|
|
527
|
+
export function formatUsd(v) {
|
|
528
|
+
if (!isNum(v)) return '$?';
|
|
529
|
+
if (v === 0) return '$0.0000';
|
|
530
|
+
const a = Math.abs(v);
|
|
531
|
+
const digits = a >= 0.0001 ? 4 : Math.min(8, Math.max(4, Math.ceil(-Math.log10(a)) + 1));
|
|
532
|
+
const text = a.toFixed(digits);
|
|
533
|
+
if (Number(text) === 0) return '<$0.00000001';
|
|
534
|
+
return `${v < 0 ? '-' : ''}$${text}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Reads what a human types after `--budget`.
|
|
539
|
+
*
|
|
540
|
+
* Lives here rather than in `cli-args.mjs` so the flag and the governor cannot
|
|
541
|
+
* drift apart, and so wiring is an import instead of a parser.
|
|
542
|
+
*/
|
|
543
|
+
export function parseBudgetUsd(raw) {
|
|
544
|
+
const help = 'a budget must be a dollar amount — try --budget 0.50 or --budget 25c';
|
|
545
|
+
if (typeof raw !== 'string') return { ok: false, message: help };
|
|
546
|
+
const s = raw.trim().toLowerCase().replace(/[,_\s]/g, '');
|
|
547
|
+
const m = /^\$?(\d+(?:\.\d+)?|\.\d+)(c|¢|usd)?$/.exec(s);
|
|
548
|
+
if (!m) return { ok: false, message: `${help} (could not read "${raw}")` };
|
|
549
|
+
let usd = Number(m[1]);
|
|
550
|
+
if (m[2] === 'c' || m[2] === '¢') usd /= 100;
|
|
551
|
+
if (!isNum(usd) || usd <= 0) return { ok: false, message: `${help} — a budget of $0 can never start a round` };
|
|
552
|
+
return { ok: true, usd };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
556
|
+
* ⭐⭐ THE HALF OF THE BILL THE GOVERNOR COULD NOT SEE
|
|
557
|
+
* ──────────────────────────────────────────────────────────────────────────── */
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* ── ⚠️⚠️ EVERY GPU DOLLAR WAS INVISIBLE, AND THREE FEATURES PRICED OFF IT ────
|
|
561
|
+
*
|
|
562
|
+
* MEASURED 2026-08-14, before this block existed:
|
|
563
|
+
*
|
|
564
|
+
* for f in imagegen media vision; do grep -n costUsd lib/$f.mjs; done
|
|
565
|
+
* imagegen.mjs → (no output)
|
|
566
|
+
* media.mjs → (no output)
|
|
567
|
+
* vision.mjs → 261 only
|
|
568
|
+
* grep -n "budget.record" lib/tools.mjs
|
|
569
|
+
* → 1275, 1276, 1277 (delegate) and 1482 (read_image). Both MODEL calls.
|
|
570
|
+
*
|
|
571
|
+
* So `generate_image`, `see_page`, `speak`, `transcribe`, `make_document`,
|
|
572
|
+
* `read_document` and `read_table` each spun a metered Modal container and
|
|
573
|
+
* charged the ledger NOTHING. Three of the four things this package is sold on
|
|
574
|
+
* — the pre-run ceiling (`--budget`), the fleet ceiling (`--fleet-budget`) and
|
|
575
|
+
* `acuvo spend` — all read one number, and that number was model tokens only.
|
|
576
|
+
*
|
|
577
|
+
* ⭐ THE FIX IS ONE BOOK, NOT A SECOND ONE. A charge recorded here is drained
|
|
578
|
+
* into whichever `createBudget` instance next asks a question, so it counts
|
|
579
|
+
* against the SAME ceiling as a model round and lands in the SAME audit record.
|
|
580
|
+
* A second ledger with its own cap would have reproduced the exact defect it
|
|
581
|
+
* was written to close, one level along.
|
|
582
|
+
*
|
|
583
|
+
* ── ⚠️⚠️ AND EVERY DOLLAR IN IT IS AN ESTIMATE. IT MUST SAY SO. ─────────────
|
|
584
|
+
*
|
|
585
|
+
* Modal does not bill us back per request. There is no invoice line to read, so
|
|
586
|
+
* a GPU cost here is WALL-CLOCK SECONDS WE TIMED × A PUBLISHED PRICE, and that
|
|
587
|
+
* is a different kind of fact from `usage.cost` off a completion. Replacing a
|
|
588
|
+
* known-incomplete number with a confidently-wrong one is worse than the leak,
|
|
589
|
+
* so `estimated` is true for every entry, `basis` says how it was derived, and
|
|
590
|
+
* `report()` / the audit record / `acuvo spend` all repeat it out loud.
|
|
591
|
+
*
|
|
592
|
+
* ── ⚠️ MODULE-LEVEL STATE, IN A FILE WHOSE HEADER SAYS "PURE" ────────────────
|
|
593
|
+
* That header is about NETWORK, MODEL, FS and AMBIENT TIME, and all four still
|
|
594
|
+
* hold. This is a process-wide sink, and it is here because it had to be: the
|
|
595
|
+
* dispatcher hands `budget` to exactly two call sites (`tools.mjs:1275`, `:1482`)
|
|
596
|
+
* and neither is a GPU verb, so a GPU verb has no handle on the run's governor
|
|
597
|
+
* to charge. `resetSpendMeter()` is the seam that keeps it out of other tests.
|
|
598
|
+
*/
|
|
599
|
+
const meter = {
|
|
600
|
+
/** @type {Array<{kind: string, verb: string, usd: number, seconds: number, billedSeconds: number, basis: string, at: number}>} */
|
|
601
|
+
charges: [],
|
|
602
|
+
/** How many charges a budget instance has already taken. See `drainCharges`. */
|
|
603
|
+
claimed: 0,
|
|
604
|
+
/** Endpoints already paid a cold start this process — see COLD_START_SECONDS. */
|
|
605
|
+
warm: new Set(),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* ── ⭐ THE PRICE TABLE, AND THE ARITHMETIC THAT ANCHORS IT ───────────────────
|
|
610
|
+
*
|
|
611
|
+
* `$0.000306/s` is Modal's A10G at $1.10/hour. It is not a guess: it is the
|
|
612
|
+
* number that reproduces the one real measurement we have.
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ THE CODEBASE QUOTED $0.003 AN IMAGE IN THREE PLACES AND IT WAS WRONG BY
|
|
615
|
+
* 13x. $0.003 is 10 warm render-seconds and nothing else — it silently assumed
|
|
616
|
+
* the container was already up and would never scale down. A cold, isolated
|
|
617
|
+
* render actually bills ~130 seconds:
|
|
618
|
+
*
|
|
619
|
+
* 60s container boot + 4.5GB of weights pulled (billed)
|
|
620
|
+
* 10s the render itself (measured: 8.8–10.5s warm)
|
|
621
|
+
* 60s Modal's default scaledown window after the
|
|
622
|
+
* last request, before the container dies (billed)
|
|
623
|
+
* ───
|
|
624
|
+
* 130s × $0.000306/s = $0.0398 ← the true cost of one image
|
|
625
|
+
*
|
|
626
|
+
* ⭐ WHICH IS WHY THE COLD START IS CHARGED ONCE PER ENDPOINT PER PROCESS. The
|
|
627
|
+
* second image in the same run really is ~$0.003, because the boot and the
|
|
628
|
+
* scaledown tail are already bought. Charging every call at $0.0398 would be a
|
|
629
|
+
* check that fails correct work — this repo's most expensive recurring mistake.
|
|
630
|
+
*/
|
|
631
|
+
export const USD_PER_SECOND = Object.freeze({
|
|
632
|
+
/** A10G. MEASURED anchor — see the arithmetic above. */
|
|
633
|
+
gpu: 0.000306,
|
|
634
|
+
/**
|
|
635
|
+
* A small Modal CPU container: 2 cores × $0.0000131/core-s + 4 GiB ×
|
|
636
|
+
* $0.00000222/GiB-s. ⚠️ THE CONTAINER SHAPE IS AN ASSUMPTION, not a
|
|
637
|
+
* measurement — a Playwright box may well be bigger. It is ~9x cheaper than
|
|
638
|
+
* the GPU rate, so mis-classing a service the wrong way matters, which is why
|
|
639
|
+
* `SERVICE_CLASS` below marks which entries are measured and which are read
|
|
640
|
+
* off what the service obviously does.
|
|
641
|
+
*/
|
|
642
|
+
cpu: 0.0000351,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
/** Billed before the first request of the process lands. */
|
|
646
|
+
export const COLD_START_SECONDS = Object.freeze({ gpu: 60, cpu: 20 });
|
|
647
|
+
|
|
648
|
+
/** Modal keeps a container alive after the last request, and bills for it. */
|
|
649
|
+
export const SCALEDOWN_WINDOW_SECONDS = 60;
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Which class of machine each verb runs on.
|
|
653
|
+
*
|
|
654
|
+
* ⭐ `measured` MEANS WE HAVE RUN IT AND TIMED IT. Everything else is read off
|
|
655
|
+
* what the service does (a Playwright screenshot is not GPU work) and is an
|
|
656
|
+
* assumption that someone with an invoice should correct.
|
|
657
|
+
*
|
|
658
|
+
* ⚠️ `edit_image` AND `expand_image` ARE DELIBERATELY ABSENT. They live in
|
|
659
|
+
* `lib/image-edit.mjs`, which this change does not touch, so they are still
|
|
660
|
+
* uncharged. Listing them here would make the table claim a coverage the wiring
|
|
661
|
+
* does not have — the "built but unreached" defect this package has shipped
|
|
662
|
+
* repeatedly. They are the remaining work, named rather than papered over.
|
|
663
|
+
*/
|
|
664
|
+
export const SERVICE_CLASS = Object.freeze({
|
|
665
|
+
generate_image: { class: 'gpu', why: 'measured: SSD-1B on an A10G, 8.8–10.5s warm' },
|
|
666
|
+
speak: { class: 'gpu', why: 'assumed: Kokoro TTS on the shared GPU image' },
|
|
667
|
+
transcribe: { class: 'gpu', why: 'assumed: faster-whisper on the shared GPU image' },
|
|
668
|
+
read_document: { class: 'gpu', why: 'assumed: OCR falls back to a GPU container' },
|
|
669
|
+
read_table: { class: 'gpu', why: 'assumed: Table Transformer is a GPU model' },
|
|
670
|
+
see_page: { class: 'cpu', why: 'assumed: a headless-browser render is CPU work' },
|
|
671
|
+
make_document: { class: 'cpu', why: 'assumed: HTML→PDF is a headless-browser job' },
|
|
672
|
+
read_image: { class: 'model', why: 'a vision completion, not a container — priced per token' },
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
/** The class used when a verb is not in the table. GPU: expensive-side default. */
|
|
676
|
+
const UNKNOWN_CLASS = 'gpu';
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* What one call costs, given how long it actually took.
|
|
680
|
+
*
|
|
681
|
+
* @param {object} args
|
|
682
|
+
* @param {string} args.verb the tool the user sees, e.g. 'see_page'
|
|
683
|
+
* @param {number} args.seconds MEASURED wall time of the request
|
|
684
|
+
* @param {boolean} [args.cold] is this the first call to the endpoint?
|
|
685
|
+
* @returns {{usd: number, billedSeconds: number, class: string, basis: string}}
|
|
686
|
+
*/
|
|
687
|
+
export function priceGpuCall({ verb, seconds, cold = true }) {
|
|
688
|
+
const entry = SERVICE_CLASS[verb];
|
|
689
|
+
const klass = entry?.class && entry.class !== 'model' ? entry.class : UNKNOWN_CLASS;
|
|
690
|
+
const rate = USD_PER_SECOND[klass] ?? USD_PER_SECOND.gpu;
|
|
691
|
+
const wall = isNum(seconds) && seconds > 0 ? seconds : 0;
|
|
692
|
+
const overhead = cold ? (COLD_START_SECONDS[klass] ?? 0) + SCALEDOWN_WINDOW_SECONDS : 0;
|
|
693
|
+
const billedSeconds = wall + overhead;
|
|
694
|
+
return {
|
|
695
|
+
usd: billedSeconds * rate,
|
|
696
|
+
billedSeconds,
|
|
697
|
+
class: klass,
|
|
698
|
+
/**
|
|
699
|
+
* ⚠️ THE STRING A HUMAN WILL READ WHEN THEY QUERY THE NUMBER. It has to
|
|
700
|
+
* carry both halves: the seconds are real, the dollars are a price table.
|
|
701
|
+
*/
|
|
702
|
+
basis: cold
|
|
703
|
+
? `${wall.toFixed(1)}s measured + ${overhead}s cold start & scaledown, at the published ${klass} rate`
|
|
704
|
+
: `${wall.toFixed(1)}s measured on a warm container, at the published ${klass} rate`,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Charge one GPU call to the ledger. Returns the entry, or null when nothing
|
|
710
|
+
* was charged.
|
|
711
|
+
*
|
|
712
|
+
* ⚠️ NOTHING IS RECORDED FOR A FREE PROVIDER. Pollinations and Perchance are
|
|
713
|
+
* somebody else's machines and cost us $0.00, and writing a zero-dollar entry
|
|
714
|
+
* would make a run that spent no money of ours grow a "GPU spend" section in
|
|
715
|
+
* its audit record and its summary. `test/gpu-spend-is-metered.test.mjs` pins
|
|
716
|
+
* that a no-GPU run reports byte-for-byte what it reported before this change.
|
|
717
|
+
*
|
|
718
|
+
* @param {{verb: string, seconds: number, endpoint?: string, free?: boolean}} args
|
|
719
|
+
*/
|
|
720
|
+
export function chargeGpu({ verb, seconds, endpoint = verb, free = false }) {
|
|
721
|
+
if (free) return null;
|
|
722
|
+
const cold = !meter.warm.has(endpoint);
|
|
723
|
+
meter.warm.add(endpoint);
|
|
724
|
+
const priced = priceGpuCall({ verb, seconds, cold });
|
|
725
|
+
if (!(priced.usd > 0)) return null;
|
|
726
|
+
/**
|
|
727
|
+
* ⚠️ NO TIMESTAMP ON THE ENTRY, AND THAT IS THE TEST'S DOING. The first draft
|
|
728
|
+
* carried an `at` field stamped from the wall clock, and
|
|
729
|
+
* `budget.test.mjs:434` failed it outright — *"the wall clock must never be
|
|
730
|
+
* CALLED inside the logic"*. ⚠️ That test greps this file for the call, so
|
|
731
|
+
* the call cannot even be WRITTEN in a comment here. The guard is right and
|
|
732
|
+
* worth more than the field: this file's determinism is why the whole
|
|
733
|
+
* governor is provable for $0.00. The audit record already stamps the run,
|
|
734
|
+
* and the ORDER of these entries is the only sequencing anything reads.
|
|
735
|
+
*/
|
|
736
|
+
const entry = {
|
|
737
|
+
kind: 'gpu',
|
|
738
|
+
verb,
|
|
739
|
+
usd: priced.usd,
|
|
740
|
+
seconds: isNum(seconds) && seconds > 0 ? seconds : 0,
|
|
741
|
+
billedSeconds: priced.billedSeconds,
|
|
742
|
+
basis: priced.basis,
|
|
743
|
+
};
|
|
744
|
+
meter.charges.push(entry);
|
|
745
|
+
return { ...entry };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* ⭐ THE SAME BOOK FOR THE OTHER "UNKNOWN PRICED AS FREE" CASE.
|
|
750
|
+
*
|
|
751
|
+
* `read_image` returns `costUsd` and `tools.mjs:1481` charges it only when it
|
|
752
|
+
* is `> 0`. OpenRouter does not always report a cost — and when it does not,
|
|
753
|
+
* `vision.mjs` returned `costUsd: null`, the guard skipped, and a paid vision
|
|
754
|
+
* call was free to the governor. Same defect, different provider. It goes in
|
|
755
|
+
* the same ledger, tagged as an estimate, rather than being smuggled into
|
|
756
|
+
* `costUsd` where it would masquerade as a reported figure.
|
|
757
|
+
*/
|
|
758
|
+
export function chargeEstimate({ kind, verb, usd, basis }) {
|
|
759
|
+
if (!isNum(usd) || usd <= 0) return null;
|
|
760
|
+
const entry = { kind: String(kind || 'estimate'), verb: String(verb || 'unknown'), usd, seconds: 0, billedSeconds: 0, basis: String(basis || 'estimated') };
|
|
761
|
+
meter.charges.push(entry);
|
|
762
|
+
return { ...entry };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Everything charged this process. A copy — the ledger is not editable outside. */
|
|
766
|
+
export function gpuSpend() {
|
|
767
|
+
const calls = meter.charges.map((c) => ({ ...c }));
|
|
768
|
+
return {
|
|
769
|
+
usd: calls.reduce((n, c) => n + c.usd, 0),
|
|
770
|
+
calls,
|
|
771
|
+
/** ⚠️ ALWAYS TRUE WHEN THERE IS ANYTHING HERE. Nothing in this book is billed. */
|
|
772
|
+
estimated: calls.length > 0,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Hand every unclaimed charge to the caller, exactly once.
|
|
778
|
+
*
|
|
779
|
+
* ⭐ CLAIMED, NOT READ. `--parallel` and `--best-of` run several sessions in ONE
|
|
780
|
+
* process, so a shared running total would be counted N times over — an
|
|
781
|
+
* over-charge is still a wrong number, and it would stop correct work. Handing
|
|
782
|
+
* each charge to exactly one governor keeps the sum across every instance equal
|
|
783
|
+
* to the ledger, which is the property that makes "one book" true.
|
|
784
|
+
*/
|
|
785
|
+
function drainCharges() {
|
|
786
|
+
const out = meter.charges.slice(meter.claimed);
|
|
787
|
+
meter.claimed = meter.charges.length;
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Test seam. The process-wide ledger must not leak between test files. */
|
|
792
|
+
export function resetSpendMeter() {
|
|
793
|
+
meter.charges.length = 0;
|
|
794
|
+
meter.claimed = 0;
|
|
795
|
+
meter.warm.clear();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function requirePositive(name, value) {
|
|
799
|
+
if (!isNum(value) || value <= 0) {
|
|
800
|
+
throw new RangeError(`budget: ${name} must be a number greater than 0 (got ${JSON.stringify(value)})`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* @typedef {{ round: number, costUsd: number, tokens: number, source: 'reported'|'tokens'|'projected', estimated: boolean, at: number }} BudgetRound
|
|
806
|
+
* @typedef {{ usd: number, raw: number, basis: 'seed'|'last'|'trend'|'peak', window: number, slope: number, peak: number, safetyFactor: number }} Projection
|
|
807
|
+
* @typedef {{ ok: boolean, reason: string, message: string, spentUsd: number, remainingUsd: number, projectedUsd: number, estimated: boolean, rounds: number, limitUsd: number|null, reserveUsd: number, elapsedMs: number }} Verdict
|
|
808
|
+
*/
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* @param {object} [options]
|
|
812
|
+
* @param {number|null} [options.limitUsd] hard ceiling. Omit/null = no ceiling.
|
|
813
|
+
* @param {number} [options.reserveUsd] held back from the ceiling as headroom.
|
|
814
|
+
* @param {number} [options.firstRoundUsd] the seed, before any round is observed.
|
|
815
|
+
* @param {number} [options.usdPerMillionTokens] price for cost-less rounds.
|
|
816
|
+
* @param {number} [options.trendWindow] rounds the trend is drawn through.
|
|
817
|
+
* @param {number} [options.safetyFactor] margin on every projection (>= 1).
|
|
818
|
+
* @param {() => number} [options.clock] injected. Nothing here reads the wall.
|
|
819
|
+
*/
|
|
820
|
+
export function createBudget({
|
|
821
|
+
limitUsd = null,
|
|
822
|
+
/**
|
|
823
|
+
* ⚠️ TRUE WHEN THE USER NEVER TYPED `--budget`. It changes nothing about
|
|
824
|
+
* enforcement and everything about the sentence: stopping on a number someone
|
|
825
|
+
* chose is a result; stopping on a number they have never seen is a mystery
|
|
826
|
+
* unless the message admits where it came from and how to raise it.
|
|
827
|
+
*/
|
|
828
|
+
limitIsDefault = false,
|
|
829
|
+
/**
|
|
830
|
+
* ⚠️ WHERE THE CEILING CAME FROM, because it decides what advice is TRUE.
|
|
831
|
+
* `'policy'` means `.acuvo/policy.json` set it, and `--budget` cannot raise it
|
|
832
|
+
* — `costBudget` takes the minimum of the two, so telling the user to raise it
|
|
833
|
+
* would be a remedy guaranteed to fail. Null keeps the previous wording
|
|
834
|
+
* exactly, so every existing caller is unchanged.
|
|
835
|
+
*
|
|
836
|
+
* ⚠️ This was referenced by `budgetWayOut` below before it was declared here —
|
|
837
|
+
* ESM is strict, so that is a ReferenceError the first time a refusal fires,
|
|
838
|
+
* on the path a user only reaches when they are ALREADY having a bad time.
|
|
839
|
+
* `node --check` passes it happily; only running the branch finds it.
|
|
840
|
+
*/
|
|
841
|
+
limitSource = null,
|
|
842
|
+
/**
|
|
843
|
+
* ── ⭐ THE FLEET GATE — ONE SEAM, NOT FOUR CALL SITES ──────────────────────
|
|
844
|
+
*
|
|
845
|
+
* `canContinue()` is consulted from four places (`turn.mjs` ×3,
|
|
846
|
+
* `escalate.mjs` ×1). Adding a parallel fleet check beside each one is how a
|
|
847
|
+
* capability ends up built and only partly connected — the single most common
|
|
848
|
+
* defect in this package's history. Composing it HERE means every existing
|
|
849
|
+
* caller gains the fleet ceiling without being touched, and a future fifth
|
|
850
|
+
* caller cannot forget it.
|
|
851
|
+
*
|
|
852
|
+
* A function, not a number, because the fleet's spend changes underneath a
|
|
853
|
+
* long run: six other terminals finish work while this one is thinking, and a
|
|
854
|
+
* value captured at construction would be stale by the second round.
|
|
855
|
+
*
|
|
856
|
+
* ⚠️ Called with the projection for the round being considered, and this
|
|
857
|
+
* run's own spend so far — which the shared ledger cannot know, because this
|
|
858
|
+
* run does not write its audit record until it ends.
|
|
859
|
+
*
|
|
860
|
+
* @type {null | ((args: {projectedUsd: number, thisRunUsd: number}) => {ok: boolean, reason: string, message: string})}
|
|
861
|
+
*/
|
|
862
|
+
fleetGate = null,
|
|
863
|
+
reserveUsd = 0,
|
|
864
|
+
firstRoundUsd = DEFAULT_FIRST_ROUND_USD,
|
|
865
|
+
usdPerMillionTokens = DEFAULT_USD_PER_MILLION_TOKENS,
|
|
866
|
+
trendWindow = DEFAULT_TREND_WINDOW,
|
|
867
|
+
safetyFactor = DEFAULT_SAFETY_FACTOR,
|
|
868
|
+
clock = Date.now,
|
|
869
|
+
} = {}) {
|
|
870
|
+
/**
|
|
871
|
+
* ⚠️ MALFORMED INPUT THROWS, AND IT THROWS HERE. `--budget banana` is a typo
|
|
872
|
+
* in a command that is about to spend money; the only safe moment to notice
|
|
873
|
+
* is before the first request. Contrast with 'too-small' below, which is a
|
|
874
|
+
* legitimate budget that simply cannot buy anything — that is a verdict, not
|
|
875
|
+
* a crash, because the user did nothing wrong except be optimistic.
|
|
876
|
+
*/
|
|
877
|
+
const unlimited = limitUsd === null || limitUsd === undefined;
|
|
878
|
+
if (!unlimited) requirePositive('limitUsd', limitUsd);
|
|
879
|
+
if (!isNum(reserveUsd) || reserveUsd < 0) {
|
|
880
|
+
throw new RangeError(`budget: reserveUsd must be a number >= 0 (got ${JSON.stringify(reserveUsd)})`);
|
|
881
|
+
}
|
|
882
|
+
if (!unlimited && reserveUsd >= limitUsd) {
|
|
883
|
+
throw new RangeError(`budget: reserveUsd (${reserveUsd}) must be less than limitUsd (${limitUsd}) — otherwise nothing can ever run`);
|
|
884
|
+
}
|
|
885
|
+
requirePositive('firstRoundUsd', firstRoundUsd);
|
|
886
|
+
requirePositive('usdPerMillionTokens', usdPerMillionTokens);
|
|
887
|
+
if (!isNum(trendWindow) || !Number.isInteger(trendWindow) || trendWindow < 1) {
|
|
888
|
+
throw new RangeError(`budget: trendWindow must be an integer >= 1 (got ${JSON.stringify(trendWindow)})`);
|
|
889
|
+
}
|
|
890
|
+
if (!isNum(safetyFactor) || safetyFactor < 1) {
|
|
891
|
+
throw new RangeError(`budget: safetyFactor must be a number >= 1 (got ${JSON.stringify(safetyFactor)})`);
|
|
892
|
+
}
|
|
893
|
+
if (typeof clock !== 'function') {
|
|
894
|
+
throw new RangeError('budget: clock must be a function returning milliseconds');
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const effectiveLimit = unlimited ? Infinity : limitUsd - reserveUsd;
|
|
898
|
+
const startedAt = clock();
|
|
899
|
+
|
|
900
|
+
/** @type {BudgetRound[]} */
|
|
901
|
+
const rounds = [];
|
|
902
|
+
let spentUsd = 0;
|
|
903
|
+
let totalTokens = 0;
|
|
904
|
+
let estimatedRounds = 0;
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* ── ⭐⭐ GPU DOLLARS COUNT AGAINST THE CEILING, AND NOT AGAINST THE TREND ───
|
|
908
|
+
*
|
|
909
|
+
* They are held in their own accumulator rather than pushed into `rounds`,
|
|
910
|
+
* and that separation is load-bearing in both directions:
|
|
911
|
+
*
|
|
912
|
+
* · `spentUsd + gpuUsd` is what every ceiling comparison uses, so one image
|
|
913
|
+
* render is a real dollar against `--budget` for the first time.
|
|
914
|
+
* · `projectNext()` still reads ONLY `rounds`, so the projection keeps
|
|
915
|
+
* meaning "what the next MODEL round will cost". Feeding a $0.0398 render
|
|
916
|
+
* into a three-round trend would project the next round at four cents and
|
|
917
|
+
* stop a run that could comfortably afford to finish — a guard that fails
|
|
918
|
+
* correct work, which is worse than the leak it closes.
|
|
919
|
+
*/
|
|
920
|
+
let gpuUsd = 0;
|
|
921
|
+
let gpuCalls = 0;
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* ⚠️ CALLED AT THE TOP OF EVERY PUBLIC METHOD, INCLUDING THE READ-ONLY ONES.
|
|
925
|
+
* A GPU verb called on the last round of a session would otherwise never be
|
|
926
|
+
* claimed by anyone — `canContinue()` is not asked again — and would vanish
|
|
927
|
+
* from the summary and the audit record. `report()`, `stats()` and `toJSON()`
|
|
928
|
+
* all run at the end of a run, so draining there is what makes the last call
|
|
929
|
+
* of a session countable.
|
|
930
|
+
*/
|
|
931
|
+
function syncGpu() {
|
|
932
|
+
for (const c of drainCharges()) {
|
|
933
|
+
gpuUsd += c.usd;
|
|
934
|
+
gpuCalls += 1;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The one number: model rounds plus estimated GPU time. */
|
|
939
|
+
const totalSpent = () => spentUsd + gpuUsd;
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* ⭐ THE HONESTY CLAUSE, AND IT IS EMPTY WHEN THERE IS NOTHING TO SAY.
|
|
943
|
+
*
|
|
944
|
+
* ⚠️ RETURNING `''` ON A ZERO-GPU RUN IS A REQUIREMENT, NOT A TIDINESS
|
|
945
|
+
* CHOICE. Every stop message, summary line and audit record on a run that
|
|
946
|
+
* never touched a GPU has to come out byte-for-byte as it did before this
|
|
947
|
+
* change, or "we added GPU metering" silently becomes "we changed the output
|
|
948
|
+
* of every run in the product". `test/gpu-spend-is-metered.test.mjs` pins it.
|
|
949
|
+
*/
|
|
950
|
+
function gpuClause() {
|
|
951
|
+
if (!(gpuUsd > 0)) return '';
|
|
952
|
+
return ` (${formatUsd(gpuUsd)} of that is ESTIMATED GPU time across ${gpuCalls} call${gpuCalls === 1 ? '' : 's'}, priced from a table rather than billed)`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** @returns {Projection} */
|
|
956
|
+
function projectNext() {
|
|
957
|
+
if (rounds.length === 0) {
|
|
958
|
+
return { usd: firstRoundUsd * safetyFactor, raw: firstRoundUsd, basis: 'seed', window: 0, slope: 0, peak: 0, safetyFactor };
|
|
959
|
+
}
|
|
960
|
+
const window = rounds.slice(-trendWindow);
|
|
961
|
+
const costs = window.map((r) => r.costUsd);
|
|
962
|
+
const first = costs[0];
|
|
963
|
+
const last = costs[costs.length - 1];
|
|
964
|
+
const peak = Math.max(...costs);
|
|
965
|
+
const slope = costs.length >= 2 ? (last - first) / (costs.length - 1) : 0;
|
|
966
|
+
// No `Math.max(0, slope)` here — see the header. A negative slope always
|
|
967
|
+
// implies `peak > trend`, so the peak clamp on the next line is the only
|
|
968
|
+
// thing that has ever enforced "never project downward".
|
|
969
|
+
const trend = last + slope;
|
|
970
|
+
const raw = Math.max(trend, peak);
|
|
971
|
+
const basis = costs.length < 2 ? 'last' : (raw > trend ? 'peak' : 'trend');
|
|
972
|
+
return { usd: raw * safetyFactor, raw, basis, window: costs.length, slope, peak, safetyFactor };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Accepts either this module's own shape (`{ costUsd, tokens }`) or the
|
|
977
|
+
* OpenRouter usage object turn.mjs already has (`{ cost, total_tokens }`),
|
|
978
|
+
* so the wiring is `budget.record(reply.usage)` and nothing else.
|
|
979
|
+
*/
|
|
980
|
+
function record(entry) {
|
|
981
|
+
syncGpu();
|
|
982
|
+
const e = entry && typeof entry === 'object' ? entry : {};
|
|
983
|
+
const rawCost = isNum(e.costUsd) ? e.costUsd : e.cost;
|
|
984
|
+
const rawTokens = isNum(e.tokens) ? e.tokens : e.total_tokens;
|
|
985
|
+
const tokens = isNum(rawTokens) && rawTokens >= 0 ? rawTokens : 0;
|
|
986
|
+
|
|
987
|
+
let costUsd;
|
|
988
|
+
/** @type {'reported'|'tokens'|'projected'} */
|
|
989
|
+
let source;
|
|
990
|
+
if (isNum(rawCost) && rawCost >= 0) {
|
|
991
|
+
costUsd = rawCost;
|
|
992
|
+
source = 'reported';
|
|
993
|
+
} else if (tokens > 0) {
|
|
994
|
+
/**
|
|
995
|
+
* ── ⭐⭐⭐ PRICE THE MIX, NOT A BLEND ─────────────────────────────────
|
|
996
|
+
*
|
|
997
|
+
* A flat `$/M` cannot be right for two rounds with different shapes, and
|
|
998
|
+
* that is exactly why two honest measurements of this model appeared to
|
|
999
|
+
* disagree by 2x. Reconciled 2026-08-21 against real rates
|
|
1000
|
+
* (input $0.14/M · output $0.28/M · cached input ~10% of input):
|
|
1001
|
+
*
|
|
1002
|
+
* 1,036 tok, cold, output-heavy -> $0.231/M (this file measured 0.223 ✓)
|
|
1003
|
+
* a build round, prompt-heavy -> $0.154/M
|
|
1004
|
+
* the same round at 80% cache -> $0.063/M (the console's blend ✓)
|
|
1005
|
+
*
|
|
1006
|
+
* ⭐ SAME RATES, DIFFERENT MIXES. Neither measurement was wrong; the
|
|
1007
|
+
* constant was, because one number cannot describe both. So when the
|
|
1008
|
+
* usage object tells us the split — and DeepSeek always does, even when
|
|
1009
|
+
* it reports no cost — price it properly and stop guessing.
|
|
1010
|
+
*
|
|
1011
|
+
* ⚠️ FALLS BACK, NEVER THROWS. A usage object without the split is priced
|
|
1012
|
+
* exactly as before, so this can only ever be more accurate than the
|
|
1013
|
+
* constant, never less.
|
|
1014
|
+
*/
|
|
1015
|
+
const priced = priceFromSplit(e);
|
|
1016
|
+
if (priced !== null) {
|
|
1017
|
+
costUsd = priced;
|
|
1018
|
+
source = 'tokens';
|
|
1019
|
+
} else {
|
|
1020
|
+
costUsd = (tokens / 1e6) * usdPerMillionTokens;
|
|
1021
|
+
source = 'tokens';
|
|
1022
|
+
}
|
|
1023
|
+
} else {
|
|
1024
|
+
// Nothing to go on. Charge what we were about to bet this round would
|
|
1025
|
+
// cost — the one option that is neither free nor invented.
|
|
1026
|
+
costUsd = projectNext().raw;
|
|
1027
|
+
source = 'projected';
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const rec = {
|
|
1031
|
+
round: rounds.length + 1,
|
|
1032
|
+
costUsd,
|
|
1033
|
+
tokens,
|
|
1034
|
+
source,
|
|
1035
|
+
estimated: source !== 'reported',
|
|
1036
|
+
at: clock(),
|
|
1037
|
+
};
|
|
1038
|
+
rounds.push(rec);
|
|
1039
|
+
spentUsd += costUsd;
|
|
1040
|
+
totalTokens += tokens;
|
|
1041
|
+
if (rec.estimated) estimatedRounds += 1;
|
|
1042
|
+
return { ...rec };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** @returns {Verdict} */
|
|
1046
|
+
function canContinue() {
|
|
1047
|
+
syncGpu();
|
|
1048
|
+
const projection = projectNext();
|
|
1049
|
+
const elapsedMs = clock() - startedAt;
|
|
1050
|
+
const base = {
|
|
1051
|
+
/**
|
|
1052
|
+
* ⚠️ THIS IS NOW THE TOTAL, NOT THE MODEL TOTAL. Every caller that reads
|
|
1053
|
+
* `spentUsd` — `escalate.mjs`, the fleet gate below, the stop messages —
|
|
1054
|
+
* is asking "how much has this run cost", and answering with the model
|
|
1055
|
+
* half was the whole defect. The split is available under `modelUsd` /
|
|
1056
|
+
* `gpuUsd` for anyone who needs it.
|
|
1057
|
+
*/
|
|
1058
|
+
spentUsd: totalSpent(),
|
|
1059
|
+
modelUsd: spentUsd,
|
|
1060
|
+
gpuUsd,
|
|
1061
|
+
gpuCalls,
|
|
1062
|
+
projectedUsd: projection.usd,
|
|
1063
|
+
/**
|
|
1064
|
+
* ⭐ GPU SPEND MAKES THE TOTAL AN ESTIMATE, FULL STOP. There is no
|
|
1065
|
+
* invoice behind it, so a run that rendered an image can never report an
|
|
1066
|
+
* exact figure, however well the provider reported its tokens.
|
|
1067
|
+
*/
|
|
1068
|
+
estimated: estimatedRounds > 0 || gpuCalls > 0,
|
|
1069
|
+
rounds: rounds.length,
|
|
1070
|
+
limitUsd: unlimited ? null : limitUsd,
|
|
1071
|
+
reserveUsd,
|
|
1072
|
+
elapsedMs,
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* ── ⭐ THE FLEET IS ASKED FIRST, AND IT CAN ONLY EVER REFUSE ─────────────
|
|
1077
|
+
*
|
|
1078
|
+
* Two properties, both deliberate:
|
|
1079
|
+
*
|
|
1080
|
+
* 1. **It runs BEFORE the `unlimited` branch.** A terminal started with
|
|
1081
|
+
* `--budget none` has no per-run ceiling at all, which is exactly the
|
|
1082
|
+
* worker that can drain a fleet ceiling on its own. Checking after the
|
|
1083
|
+
* unlimited early-return would have left the one case that most needs
|
|
1084
|
+
* the fleet gate as the one case it never sees.
|
|
1085
|
+
*
|
|
1086
|
+
* 2. **It never turns a refusal into an allow.** The only outcome consulted
|
|
1087
|
+
* here is `ok === false`; a happy fleet falls through to the per-run
|
|
1088
|
+
* logic completely unchanged. So every existing behaviour is preserved
|
|
1089
|
+
* exactly, and the new layer is a no-op for anyone who has not asked for
|
|
1090
|
+
* it — which is the bar for adding anything to a path that spends money.
|
|
1091
|
+
*
|
|
1092
|
+
* ⚠️ Precedence is fleet-first ON PURPOSE. When both ceilings are spent,
|
|
1093
|
+
* "raise --budget" is the wrong advice: the per-run ceiling is not what is
|
|
1094
|
+
* stopping you and lifting it changes nothing. The binding constraint has
|
|
1095
|
+
* to be the one that gets named.
|
|
1096
|
+
*
|
|
1097
|
+
* ⚠️ `remainingUsd` becomes the FLEET's remaining here, because
|
|
1098
|
+
* `escalate.mjs` sizes its next rung from that number. Handing it the
|
|
1099
|
+
* per-run figure while the fleet is empty would have it buy a rung the
|
|
1100
|
+
* fleet cannot pay for.
|
|
1101
|
+
*/
|
|
1102
|
+
if (fleetGate) {
|
|
1103
|
+
// ⭐ The fleet is told the TOTAL. A workspace ceiling that priced only
|
|
1104
|
+
// model tokens was the same hole one level up — seven terminals each
|
|
1105
|
+
// rendering images could not cross a fleet cap they were not charged to.
|
|
1106
|
+
const fleet = fleetGate({ projectedUsd: projection.usd, thisRunUsd: totalSpent() });
|
|
1107
|
+
if (fleet && fleet.ok === false) {
|
|
1108
|
+
return {
|
|
1109
|
+
ok: false,
|
|
1110
|
+
reason: fleet.reason,
|
|
1111
|
+
message: fleet.message,
|
|
1112
|
+
remainingUsd: Math.max(0, fleet.fleetRemainingUsd ?? 0),
|
|
1113
|
+
fleetSpentUsd: fleet.fleetSpentUsd,
|
|
1114
|
+
...base,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (unlimited) {
|
|
1120
|
+
return {
|
|
1121
|
+
ok: true,
|
|
1122
|
+
reason: 'no-budget-set',
|
|
1123
|
+
message: `no budget limit set — ${formatUsd(totalSpent())} spent so far`,
|
|
1124
|
+
remainingUsd: Infinity,
|
|
1125
|
+
...base,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const remainingUsd = effectiveLimit - totalSpent();
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* ⭐ A REFUSAL THAT DOES NOT SAY WHAT TO TYPE IS JUST AN OBSTACLE. That rule
|
|
1133
|
+
* is why this package's refusals are its best-written part, and it applies
|
|
1134
|
+
* hardest here — because the ceiling is now ON BY DEFAULT, so the first
|
|
1135
|
+
* person to meet it will not have chosen the number.
|
|
1136
|
+
*/
|
|
1137
|
+
const wayOut = budgetWayOut({ limitUsd, limitIsDefault, limitSource });
|
|
1138
|
+
|
|
1139
|
+
if (remainingUsd <= USD_EPSILON) {
|
|
1140
|
+
return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
reason: 'limit-reached',
|
|
1143
|
+
message: `budget spent: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} after ${rounds.length} round${rounds.length === 1 ? '' : 's'}${gpuClause()}.${wayOut}`,
|
|
1144
|
+
remainingUsd: Math.max(0, remainingUsd),
|
|
1145
|
+
...base,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (totalSpent() + projection.usd > effectiveLimit + USD_EPSILON) {
|
|
1150
|
+
/**
|
|
1151
|
+
* ⚠️ `rounds.length === 0` STILL MEANS "NOTHING WAS STARTED", and a GPU
|
|
1152
|
+
* charge with no rounds behind it cannot happen — a tool call only exists
|
|
1153
|
+
* inside a round. Left as it was rather than switched to a total-spend
|
|
1154
|
+
* test, because 'too-small' is the preflight verdict and preflight runs
|
|
1155
|
+
* before anything has been charged at all.
|
|
1156
|
+
*/
|
|
1157
|
+
const tooSmall = rounds.length === 0;
|
|
1158
|
+
return {
|
|
1159
|
+
ok: false,
|
|
1160
|
+
reason: tooSmall ? 'too-small' : 'would-exceed',
|
|
1161
|
+
message: tooSmall
|
|
1162
|
+
? `budget of ${formatUsd(limitUsd)} cannot cover even one round (projected ~${formatUsd(projection.usd)}). Nothing was started — raise it or drop the flag.`
|
|
1163
|
+
: `stopping on budget after ${rounds.length} round${rounds.length === 1 ? '' : 's'}: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} spent${gpuClause()} and the next round is projected at ~${formatUsd(projection.usd)}, which would cross the line.${wayOut}`,
|
|
1164
|
+
remainingUsd,
|
|
1165
|
+
...base,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
ok: true,
|
|
1171
|
+
reason: 'ok',
|
|
1172
|
+
message: `${formatUsd(remainingUsd)} of budget left, next round projected at ~${formatUsd(projection.usd)}`,
|
|
1173
|
+
remainingUsd,
|
|
1174
|
+
...base,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* ⭐ THE FORECAST, ASKED WITH THE STATE THIS CLOSURE ALREADY HOLDS.
|
|
1180
|
+
*
|
|
1181
|
+
* ⚠️ IT DRAINS THE GPU METER FIRST, like every other public method here. A run
|
|
1182
|
+
* that rendered an image in round 1 has spent four cents; forecasting from the
|
|
1183
|
+
* model half alone would produce a confident "this fits" about a run that is
|
|
1184
|
+
* already twice over its ceiling — the same "unknown priced as free" failure
|
|
1185
|
+
* this file exists to refuse, one level along.
|
|
1186
|
+
*
|
|
1187
|
+
* ⚠️ AND IT IS A READ. Nothing here records, stops, or changes a verdict; the
|
|
1188
|
+
* only thing that stops a run is still `canContinue()`.
|
|
1189
|
+
*/
|
|
1190
|
+
function forecast(maxRounds) {
|
|
1191
|
+
syncGpu();
|
|
1192
|
+
return forecastRun({
|
|
1193
|
+
remainingUsd: unlimited ? Infinity : effectiveLimit - totalSpent(),
|
|
1194
|
+
projectedUsd: projectNext().usd,
|
|
1195
|
+
limitUsd: unlimited ? null : limitUsd,
|
|
1196
|
+
spentUsd: totalSpent(),
|
|
1197
|
+
roundsUsed: rounds.length,
|
|
1198
|
+
maxRounds: Number.isFinite(maxRounds) ? maxRounds : 0,
|
|
1199
|
+
limitIsDefault,
|
|
1200
|
+
limitSource,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function stats() {
|
|
1205
|
+
syncGpu();
|
|
1206
|
+
return {
|
|
1207
|
+
rounds: rounds.length,
|
|
1208
|
+
spentUsd: totalSpent(),
|
|
1209
|
+
modelUsd: spentUsd,
|
|
1210
|
+
gpuUsd,
|
|
1211
|
+
gpuCalls,
|
|
1212
|
+
totalTokens,
|
|
1213
|
+
estimated: estimatedRounds > 0 || gpuCalls > 0,
|
|
1214
|
+
estimatedRounds,
|
|
1215
|
+
limitUsd: unlimited ? null : limitUsd,
|
|
1216
|
+
reserveUsd,
|
|
1217
|
+
remainingUsd: unlimited ? Infinity : effectiveLimit - totalSpent(),
|
|
1218
|
+
projectedUsd: projectNext().usd,
|
|
1219
|
+
startedAt,
|
|
1220
|
+
elapsedMs: clock() - startedAt,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* One line, and it must be true. `~` marks the projection; the estimate
|
|
1226
|
+
* clause appears only when a round really did come back without a price.
|
|
1227
|
+
*/
|
|
1228
|
+
function report() {
|
|
1229
|
+
syncGpu();
|
|
1230
|
+
const n = rounds.length;
|
|
1231
|
+
const roundWord = `${n} round${n === 1 ? '' : 's'}`;
|
|
1232
|
+
/**
|
|
1233
|
+
* ⭐ THE GPU CLAUSE COMES FIRST BECAUSE IT IS THE BIGGER SURPRISE. A run
|
|
1234
|
+
* that spent $0.0002 on tokens and $0.0398 on one image render is 99% GPU,
|
|
1235
|
+
* and a summary that mentions only rounds is describing the 1%.
|
|
1236
|
+
*/
|
|
1237
|
+
const gpu = gpuUsd > 0
|
|
1238
|
+
? ` · ⚠ ${formatUsd(gpuUsd)} of that is GPU time on ${gpuCalls} call${gpuCalls === 1 ? '' : 's'}, ESTIMATED from a price table (we are not billed per call)`
|
|
1239
|
+
: '';
|
|
1240
|
+
const tail = estimatedRounds > 0
|
|
1241
|
+
? ` · ⚠ ${estimatedRounds} of ${n} rounds reported no cost, so the total is an estimate`
|
|
1242
|
+
: '';
|
|
1243
|
+
if (unlimited) {
|
|
1244
|
+
return `budget: ${formatUsd(totalSpent())} spent · ${roundWord} · no limit set${gpu}${tail}`;
|
|
1245
|
+
}
|
|
1246
|
+
const projected = projectNext().usd;
|
|
1247
|
+
const remaining = Math.max(0, effectiveLimit - totalSpent());
|
|
1248
|
+
return `budget: ${formatUsd(totalSpent())} of ${formatUsd(limitUsd)} spent · ${roundWord} · next ~${formatUsd(projected)} · ${formatUsd(remaining)} left${gpu}${tail}`;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function toJSON() {
|
|
1252
|
+
const s = stats();
|
|
1253
|
+
return {
|
|
1254
|
+
limitUsd: s.limitUsd,
|
|
1255
|
+
reserveUsd: s.reserveUsd,
|
|
1256
|
+
spentUsd: s.spentUsd,
|
|
1257
|
+
remainingUsd: s.remainingUsd,
|
|
1258
|
+
projectedUsd: s.projectedUsd,
|
|
1259
|
+
totalTokens: s.totalTokens,
|
|
1260
|
+
rounds: s.rounds,
|
|
1261
|
+
/**
|
|
1262
|
+
* ⚠️ CONDITIONAL, AND IN THE MIDDLE OF THE OBJECT ON PURPOSE — the two
|
|
1263
|
+
* GPU keys sit next to the number they explain, and are absent entirely
|
|
1264
|
+
* when there is no GPU spend, so `--json` on an ordinary run is unchanged
|
|
1265
|
+
* byte for byte. A consumer's schema does not move because we added a
|
|
1266
|
+
* capability it never used.
|
|
1267
|
+
*/
|
|
1268
|
+
...(s.gpuUsd > 0 ? { gpuUsd: s.gpuUsd, gpuCalls: s.gpuCalls } : {}),
|
|
1269
|
+
estimated: s.estimated,
|
|
1270
|
+
estimatedRounds: s.estimatedRounds,
|
|
1271
|
+
elapsedMs: s.elapsedMs,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** A copy. The ledger is not editable from outside. */
|
|
1276
|
+
const history = () => rounds.map((r) => ({ ...r }));
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* ⭐ `fleetGate` is exposed so a DELEGATED helper can inherit it. Without
|
|
1280
|
+
* that, seven terminals could each delegate their way around the
|
|
1281
|
+
* workspace-wide ceiling — the parent obeys the fleet cap and the child it
|
|
1282
|
+
* spawns never hears about it.
|
|
1283
|
+
*/
|
|
1284
|
+
return { record, projectNext, canContinue, forecast, report, stats, toJSON, history, fleetGate };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* ── ⭐ HOW TO WIRE THIS (the whole point of the module) ──────────────────────
|
|
1289
|
+
*
|
|
1290
|
+
* 1. THE FLAG — in `lib/cli-args.mjs`, beside `--max-rounds`:
|
|
1291
|
+
*
|
|
1292
|
+
* import { parseBudgetUsd } from './budget.mjs';
|
|
1293
|
+
* // ...
|
|
1294
|
+
* case '--budget': {
|
|
1295
|
+
* const parsed = parseBudgetUsd(argv[++i]);
|
|
1296
|
+
* if (!parsed.ok) return { ok: false, error: parsed.message };
|
|
1297
|
+
* out.budgetUsd = parsed.usd;
|
|
1298
|
+
* break;
|
|
1299
|
+
* }
|
|
1300
|
+
*
|
|
1301
|
+
* 2. THE GOVERNOR — in `lib/turn.mjs`, at the top:
|
|
1302
|
+
*
|
|
1303
|
+
* import { createBudget } from './budget.mjs';
|
|
1304
|
+
*
|
|
1305
|
+
* beside `const rounds = []` in `runSession` (add `budgetUsd` to the options
|
|
1306
|
+
* destructure, defaulting to `null`):
|
|
1307
|
+
*
|
|
1308
|
+
* const budget = createBudget({ limitUsd: budgetUsd });
|
|
1309
|
+
* const preflight = budget.canContinue();
|
|
1310
|
+
* if (!preflight.ok) return { ok: false, stage: 'budget', stoppedBecause: preflight.reason, message: preflight.message };
|
|
1311
|
+
*
|
|
1312
|
+
* at the top of the `for (let round = ...)` body:
|
|
1313
|
+
*
|
|
1314
|
+
* const affordable = budget.canContinue();
|
|
1315
|
+
* if (!affordable.ok) { stoppedBecause = affordable.reason; onEvent({ type: 'budget-stop', ...affordable }); break; }
|
|
1316
|
+
*
|
|
1317
|
+
* and immediately after each existing `rounds.push({ round, ... })`:
|
|
1318
|
+
*
|
|
1319
|
+
* budget.record(reply.usage);
|
|
1320
|
+
*
|
|
1321
|
+
* 3. THE LINE — wherever the summary is printed, add `budget.report()`.
|
|
1322
|
+
*
|
|
1323
|
+
* ⚠️ THE PREFLIGHT CALL IS NOT OPTIONAL. Without it a budget of $0.000001 opens
|
|
1324
|
+
* a connection, spends a round, and then discovers it could never have afforded
|
|
1325
|
+
* one — the exact "start and stop having spent money for nothing" this module
|
|
1326
|
+
* was written to prevent. It is two lines.
|
|
1327
|
+
*
|
|
1328
|
+
* ⚠️ AND `budget.record(reply.usage)` MUST RUN ON EVERY ROUND, INCLUDING THE
|
|
1329
|
+
* FAILED ONES. `runSession` pushes `{ round, error, usage: null }` on a
|
|
1330
|
+
* transport failure; skipping those makes a provider that errors after billing
|
|
1331
|
+
* look free, which is precisely the "unknown priced as zero" trap. Passing the
|
|
1332
|
+
* null is correct and intended — the governor charges it the projection.
|
|
1333
|
+
*
|
|
1334
|
+
* ⭐ `maxRounds` DOES NOT HAVE TO DIE FOR THIS TO SHIP. The two coexist: leave
|
|
1335
|
+
* the counter as a very high backstop and let money be the real wall. Removing
|
|
1336
|
+
* the counter in the same change would make an unaffordable-budget bug and a
|
|
1337
|
+
* runaway-loop bug indistinguishable, and `stuck.mjs` is the module that earns
|
|
1338
|
+
* the right to remove it, not this one.
|
|
1339
|
+
*/
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* ── ⭐ WHAT ONE `--best-of` ATTEMPT MAY SPEND ───────────────────────────────
|
|
1343
|
+
*
|
|
1344
|
+
* Pure, and separately named so the reasoning is reviewable rather than buried
|
|
1345
|
+
* in a call site: this decides whether a user's stated ceiling is honoured or
|
|
1346
|
+
* multiplied.
|
|
1347
|
+
*
|
|
1348
|
+
* ⚠️ IT EXISTS BECAUSE BOTH FAN-OUT PATHS HAD NO CEILING AT ALL. `runSession`
|
|
1349
|
+
* defaults `budgetUsd = null`, and null is UNLIMITED — so `--best-of N` ran N
|
|
1350
|
+
* full sessions with no wall, and an explicit `--budget` was accepted without
|
|
1351
|
+
* complaint and silently discarded. Taking a user's instruction about money and
|
|
1352
|
+
* dropping it is a different and worse failure than never offering the feature.
|
|
1353
|
+
*
|
|
1354
|
+
* ⭐ AN EXPLICIT `--budget` IS A TOTAL. `--best-of 5 --budget 0.05` means "spend
|
|
1355
|
+
* at most five cents", never "spend up to twenty-five" — so it is divided across
|
|
1356
|
+
* the attempts. That is the only reading that cannot surprise someone reading an
|
|
1357
|
+
* invoice, and the alternative is indefensible.
|
|
1358
|
+
*
|
|
1359
|
+
* ⚠️ THE DEFAULT IS NOT DIVIDED. `DEFAULT_BUDGET_USD` is a per-run blast radius
|
|
1360
|
+
* that nobody chose, and splitting an unchosen number into fifths would starve
|
|
1361
|
+
* each attempt at four tenths of a cent — turning a safety net into a feature
|
|
1362
|
+
* that silently stops working at N > 2. A ceiling the user DID choose is a
|
|
1363
|
+
* promise; one they did not is a guard rail, and they behave differently on
|
|
1364
|
+
* purpose.
|
|
1365
|
+
*
|
|
1366
|
+
* @param {{ bestOf?: number, budgetUsd?: number | null, budgetExplicit?: boolean }} opts
|
|
1367
|
+
* @returns {number} always finite and > 0 — null would mean unlimited
|
|
1368
|
+
*/
|
|
1369
|
+
export function bestOfAttemptBudget(opts) {
|
|
1370
|
+
const n = Math.max(1, Number(opts?.bestOf) || 1);
|
|
1371
|
+
if (opts?.budgetExplicit === true && Number.isFinite(opts?.budgetUsd) && opts.budgetUsd > 0) {
|
|
1372
|
+
return opts.budgetUsd / n;
|
|
1373
|
+
}
|
|
1374
|
+
return DEFAULT_BUDGET_USD;
|
|
1375
|
+
}
|