segreant 0.1.0 → 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/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- package/pricing/models.json +1366 -60
package/LICENSING.md
CHANGED
|
@@ -20,9 +20,9 @@ No account, key, payment or permission is needed for any of that.
|
|
|
20
20
|
1. **Offer Segreant as a service.** You may not run it, or a modified version
|
|
21
21
|
of it, as a hosted or managed service for other people or other companies.
|
|
22
22
|
Running it for your own organization is fine.
|
|
23
|
-
2. **Sell Segreant itself.** You may not sell, rent or
|
|
24
|
-
Segreant or a modified version of it, including a copy
|
|
25
|
-
another name.
|
|
23
|
+
2. **Sell Segreant itself.** You may not sell, rent, sublicense or otherwise
|
|
24
|
+
charge a fee for Segreant or a modified version of it, including a copy
|
|
25
|
+
relabelled under another name.
|
|
26
26
|
|
|
27
27
|
## Every version becomes fully open source
|
|
28
28
|
|
|
@@ -56,5 +56,7 @@ terms, open a
|
|
|
56
56
|
under the PolyForm Noncommercial License 1.0.0 (from 2026-10-08 with an
|
|
57
57
|
individual use permission). Those copies keep the terms they were published
|
|
58
58
|
with; every later version is under the terms above.
|
|
59
|
+
- The npm package `segreant` 0.1.0 (2026-10-08) is under this license
|
|
60
|
+
(`BUSL-1.1` in its package metadata and LICENSE file).
|
|
59
61
|
|
|
60
62
|
*This page explains the license; it is not legal advice.*
|
package/README.md
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
**Govern the spend. Not the developer.**
|
|
8
8
|
|
|
9
9
|
The financial control layer for AI coding agents, running on your own machine.
|
|
10
|
-
|
|
11
|
-
provider's bill, assigned to the budget it belongs to, and traced to
|
|
12
|
-
it produced. And every number tells you exactly how much it can be trusted.
|
|
10
|
+
Your agents' spend is metered, capped before a loop runs away with it, checked
|
|
11
|
+
against the provider's bill, assigned to the budget it belongs to, and traced to
|
|
12
|
+
the work it produced. And every number tells you exactly how much it can be trusted.
|
|
13
13
|
|
|
14
|
-
`local-first` · `zero runtime dependencies` · `
|
|
14
|
+
`local-first` · `zero runtime dependencies` · `3,000+ tests` · `Node 24+` · `free to use, including at work (BUSL-1.1)`
|
|
15
15
|
|
|
16
16
|
[](https://github.com/GamingDragonwastaken/Segreant/actions/workflows/ci.yml)
|
|
17
17
|
|
|
@@ -87,9 +87,11 @@ No API key, no account, no setup. With Node 24 or newer:
|
|
|
87
87
|
npx segreant demo --serve # labelled synthetic data; opens the dashboard
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
Open **http://localhost:8091**. You'll see
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
Open **http://localhost:8091**. You'll see a month's statement with all four
|
|
91
|
+
figures, spend by project and model with each session and what its commits
|
|
92
|
+
came to, caps and alerts, a comparison with a generated provider bill,
|
|
93
|
+
cost-centre splits, and what the spend produced, with a review-only
|
|
94
|
+
cheaper-model comparison. It all runs on an isolated `demo.db` and is
|
|
93
95
|
labelled as synthetic. Clear it with `segreant demo --clear`.
|
|
94
96
|
|
|
95
97
|
## Use it on your real work
|
|
@@ -100,9 +102,16 @@ Install the `segreant` command (it has no runtime dependencies):
|
|
|
100
102
|
npm install -g segreant
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
On Windows, a project-local install (`npm install segreant`) inside a folder
|
|
106
|
+
whose path contains `&` cannot start through `npx` or `node_modules\.bin`:
|
|
107
|
+
npm's generated `.cmd` shim does not quote that path. The global install above
|
|
108
|
+
and `npx segreant` from any folder are not affected. For a local install there,
|
|
109
|
+
run `node node_modules/segreant/bin/segreant.mjs` instead.
|
|
110
|
+
|
|
103
111
|
**Option A: no wiring at all.** If you use Claude Code, Codex or opencode,
|
|
104
112
|
Segreant reads the usage those tools already log on your machine, including
|
|
105
|
-
subscription usage a proxy never sees
|
|
113
|
+
subscription usage a proxy never sees. Antigravity is read from 0.2.0, which is
|
|
114
|
+
unreleased and not yet on npm:
|
|
106
115
|
|
|
107
116
|
```bash
|
|
108
117
|
segreant scan # finds your AI tools and git repos; changes nothing
|
|
@@ -131,10 +140,12 @@ export OPENAI_BASE_URL="http://localhost:8090/v1"
|
|
|
131
140
|
|
|
132
141
|
A tool pointed at the proxy this way cannot connect while Segreant is stopped.
|
|
133
142
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
Requests to OpenAI's and Anthropic's official APIs go through as soon as Segreant
|
|
144
|
+
starts: that traffic was already going to your provider, and Segreant meters it
|
|
145
|
+
on the way. Everything Segreant would send on its own (webhooks, team rollups,
|
|
146
|
+
hosted judging) stays off until you allow it, and another provider or gateway
|
|
147
|
+
needs one rule, which a refused request prints
|
|
148
|
+
([GETTING-STARTED.md](docs/GETTING-STARTED.md)). Then set a cap:
|
|
138
149
|
|
|
139
150
|
```bash
|
|
140
151
|
segreant budget --daily 25 --soft 18 --runaway 2 # hard cap, warning, loop guard
|
|
@@ -166,11 +177,34 @@ Per-tool recipes (Cursor, aider, opencode, Antigravity, your own SDK scripts):
|
|
|
166
177
|
- **Keep your data yours.** SQLite on your disk, verified backup and restore,
|
|
167
178
|
and evidence packs you can sign and verify.
|
|
168
179
|
|
|
180
|
+
### In the dashboard, without the terminal
|
|
181
|
+
|
|
182
|
+
`segreant start` serves a local dashboard organised by job:
|
|
183
|
+
|
|
184
|
+
- **Statement**: one calendar month, side by side. What was measured, what the
|
|
185
|
+
provider billed, how it was split, and what it produced. The four are never
|
|
186
|
+
added together, and the month downloads as a CSV.
|
|
187
|
+
- **Spend**: where the measured spend went, by project, model and source.
|
|
188
|
+
- **Protect**: caps and alerts that stop spend before it happens.
|
|
189
|
+
- **Close**: declare the OpenAI project, bring in the cost export, and record
|
|
190
|
+
the comparison with what Segreant measured.
|
|
191
|
+
- **Allocate**: cost centres, the rules that assign spend to them, and the
|
|
192
|
+
monthly split, previewed before it is recorded.
|
|
193
|
+
- **Value**: what the spend produced, with the limits stated.
|
|
194
|
+
|
|
195
|
+
- **Settings**: choose what you see, from density to which jobs sit in the top
|
|
196
|
+
bar, and a map of everything Segreant can do. Each capability the dashboard
|
|
197
|
+
runs opens from that map in one click.
|
|
198
|
+
|
|
199
|
+
Splits, bill comparisons, imports, adoptions and budget changes show a preview
|
|
200
|
+
before anything is saved. The dashboard follows your system's light or dark
|
|
201
|
+
setting, works at phone width, and loads nothing from the internet.
|
|
202
|
+
|
|
169
203
|
Every command, flag and design detail is in the **[Segreant guide](docs/GUIDE.md)**.
|
|
170
204
|
|
|
171
205
|
## For teams and enterprises
|
|
172
206
|
|
|
173
|
-
The same local tool scales up without sending prompts or code
|
|
207
|
+
The same local tool scales up without sending prompts or code to any Segreant-run service:
|
|
174
208
|
|
|
175
209
|
- **Team rollups.** Each machine signs and pushes per-project value and spend
|
|
176
210
|
summaries to a team server *you* run. Per-developer views are opt-in,
|
|
@@ -211,13 +245,17 @@ Segreant has no hosted service and sends no telemetry by default. The ledger
|
|
|
211
245
|
lives under `~/.segreant`, and the dashboard loads nothing from third parties:
|
|
212
246
|
no CDNs, fonts or analytics. Requests you route through the proxy still go to
|
|
213
247
|
the AI provider you configured, and your API keys pass through without being
|
|
214
|
-
stored.
|
|
215
|
-
|
|
248
|
+
stored. Segreant also downloads the public price list when it meets a model
|
|
249
|
+
its rate card does not know; that download sends nothing about you, and
|
|
250
|
+
`segreant pricing --auto off` stops it. Every other outbound path (alert
|
|
251
|
+
webhooks, team rollups) is opt-in. The privacy statement is [PRIVACY.md](docs/PRIVACY.md), also
|
|
252
|
+
one click from every dashboard page; every path in detail is in
|
|
253
|
+
[DATA-BOUNDARIES.md](docs/DATA-BOUNDARIES.md).
|
|
216
254
|
|
|
217
255
|
## Status
|
|
218
256
|
|
|
219
257
|
Segreant is **pre-release (0.1.0)**, published on npm as `segreant`. CI covers Linux, macOS and Windows, including a packaged-install
|
|
220
|
-
smoke test,
|
|
258
|
+
smoke test, real-browser tests and supply-chain checks.
|
|
221
259
|
Reconciliation and outcome measurement are implemented and tested but have not
|
|
222
260
|
yet been validated against real provider accounts or with outside users. That
|
|
223
261
|
is what the first users will prove.
|
|
@@ -240,10 +278,10 @@ The full index is in [docs/README.md](docs/README.md).
|
|
|
240
278
|
|
|
241
279
|
| | |
|
|
242
280
|
|---|---|
|
|
243
|
-
| Product code | ~
|
|
244
|
-
| Tests | ~
|
|
281
|
+
| Product code | ~105,000 lines of strict TypeScript |
|
|
282
|
+
| Tests | ~81,000 lines, 3,000+ test cases across the CLI, dashboard, team server and a real browser |
|
|
245
283
|
| Runtime dependencies | 0 |
|
|
246
|
-
| CI |
|
|
284
|
+
| CI | 14 checks on every pull request: three operating systems, install paths with spaces and `&`, the team server against real PostgreSQL, a packaged install, supply chain, real-browser tests, and exact-head verification |
|
|
247
285
|
|
|
248
286
|
## Development
|
|
249
287
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code status line: `"statusLine": { "type": "command", "command": "segreant-statusline" }`.
|
|
3
|
+
// Deliberately bypasses the full launcher (re-exec, runtime snapshot, CLI load):
|
|
4
|
+
// a status line runs on every refresh and must answer in about a tenth of a
|
|
5
|
+
// second. Prints one line on stdout and always exits 0, so a failure here can
|
|
6
|
+
// never break the person's status bar.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
let line = 'segreant';
|
|
10
|
+
try {
|
|
11
|
+
let raw = '';
|
|
12
|
+
try { raw = readFileSync(0, 'utf8').slice(0, 1_000_000); } catch { raw = ''; }
|
|
13
|
+
const { dbPath } = await import('../dist/config.js');
|
|
14
|
+
const { statuslineLine } = await import('../dist/quota/statusline.js');
|
|
15
|
+
line = statuslineLine(dbPath(), raw, Date.now());
|
|
16
|
+
} catch {
|
|
17
|
+
line = 'segreant';
|
|
18
|
+
}
|
|
19
|
+
process.stdout.write(line + '\n');
|
|
20
|
+
process.exit(0);
|
package/bin/segreant.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// path that a userland process.emitWarning override can't intercept, so we
|
|
7
7
|
// re-exec ourselves once with --disable-warning to keep output clean. This is
|
|
8
8
|
// the only knob that reliably suppresses it for `npx segreant`.
|
|
9
|
-
import {
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
10
|
import { acquirePublicationLock } from './publication-lock.mjs';
|
|
11
11
|
import { createRuntimeSnapshot, reapOrphanRuntimeSnapshots } from './runtime-snapshot.mjs';
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -27,18 +27,29 @@ if (major < 24) {
|
|
|
27
27
|
const self = fileURLToPath(import.meta.url);
|
|
28
28
|
|
|
29
29
|
if (!process.env.__SEGREANT_CHILD) {
|
|
30
|
-
|
|
30
|
+
// Asynchronous, so the launcher can forward signals. With spawnSync it could
|
|
31
|
+
// not: a SIGTERM to `segreant` (a supervisor, `kill <pid>`) ended the
|
|
32
|
+
// launcher and left a running `start` or dashboard behind as an orphan.
|
|
33
|
+
// Ctrl+C in a terminal worked only because it signals the whole group.
|
|
34
|
+
const child = spawn(
|
|
31
35
|
process.execPath,
|
|
32
36
|
['--disable-warning=ExperimentalWarning', self, ...process.argv.slice(2)],
|
|
33
37
|
{ stdio: 'inherit', env: { ...process.env, __SEGREANT_CHILD: '1' } },
|
|
34
38
|
);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
process.exit(1);
|
|
39
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
40
|
+
process.on(signal, () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); });
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
child.on('error', (error) => {
|
|
43
|
+
console.error(`Segreant could not start its runtime: ${error.message}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
});
|
|
46
|
+
child.on('exit', (code, signal) => {
|
|
47
|
+
if (typeof code === 'number') process.exit(code);
|
|
48
|
+
// A child ended by a forwarded SIGINT/SIGTERM is the requested stop.
|
|
49
|
+
if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
50
|
+
console.error(`Segreant runtime terminated by signal ${signal ?? 'unknown'}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
42
53
|
} else {
|
|
43
54
|
const here = dirname(self);
|
|
44
55
|
// Collect snapshots whose owning process is gone before adding another one,
|
package/dist/alerts/detect.js
CHANGED
|
@@ -171,6 +171,17 @@ export function detectAlerts(inp) {
|
|
|
171
171
|
metric: `${Math.round(inp.estimatedShare * 100)}% of last-7d spend estimated`,
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
|
+
// Prices the user set are not estimates of a list price, and not checked
|
|
175
|
+
// against one either; said apart from "approximate" so neither is mislabelled.
|
|
176
|
+
if ((inp.declaredShare ?? 0) > 0.2) {
|
|
177
|
+
out.push({
|
|
178
|
+
id: 'declared-pricing',
|
|
179
|
+
severity: 'info',
|
|
180
|
+
title: 'Some costs use prices you set',
|
|
181
|
+
detail: 'Part of recent spend is priced at prices set with segreant pricing set; they are not checked against a price list or your bill.',
|
|
182
|
+
metric: `${Math.round((inp.declaredShare ?? 0) * 100)}% of last-7d spend at prices you set`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
174
185
|
return out.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
175
186
|
}
|
|
176
187
|
/** Gather the inputs once, so alerts and their coverage are read from the same observation. */
|
|
@@ -194,6 +205,7 @@ function gatherAlertInputs(store, config, opts) {
|
|
|
194
205
|
const blocked24h = store.healthStats(now - day, now + 1000).blocked;
|
|
195
206
|
const week = store.healthStats(now - 7 * day, now + 1000);
|
|
196
207
|
const estimatedShare = week.totalCostUsd > 0 ? week.estimatedCostUsd / week.totalCostUsd : 0;
|
|
208
|
+
const declaredShare = week.totalCostUsd > 0 ? week.declaredCostUsd / week.totalCostUsd : 0;
|
|
197
209
|
// Carried alongside the share so coverage can tell an unestimated window from
|
|
198
210
|
// an empty one; the share alone cannot (D-141).
|
|
199
211
|
const pricedWindowSpendUsd = week.totalCostUsd;
|
|
@@ -217,6 +229,7 @@ function gatherAlertInputs(store, config, opts) {
|
|
|
217
229
|
baselinePrunedBeforeMs,
|
|
218
230
|
blocked24h,
|
|
219
231
|
estimatedShare,
|
|
232
|
+
declaredShare,
|
|
220
233
|
pricedWindowSpendUsd,
|
|
221
234
|
runaway,
|
|
222
235
|
realizedSpendShare: opts.realizedSpendShare ?? null,
|
|
@@ -503,6 +503,9 @@ function buildLocalCaptureEvidence(reconciliation, reconciliationRunId, issuedAt
|
|
|
503
503
|
providerProjectRef: reconciliation.providerProjectRef,
|
|
504
504
|
observationRunId: reconciliation.observationRunId,
|
|
505
505
|
reconciliationRunId,
|
|
506
|
+
// How the exact local amounts became this microdollar total, when a rule
|
|
507
|
+
// applied; absent on runs recorded before it, which rounded nothing.
|
|
508
|
+
...(reconciliation.localQuantization ? { localQuantization: { ...reconciliation.localQuantization } } : {}),
|
|
506
509
|
},
|
|
507
510
|
scope: scope({ provider: 'openai', declaredScopeId: reconciliation.declaredScopeId, providerProjectRef: reconciliation.providerProjectRef }),
|
|
508
511
|
grain: grain(['provider_project_period']),
|
|
@@ -590,6 +593,7 @@ export function billingReconciliationClaim(input) {
|
|
|
590
593
|
// reader would get a residual with no statement of whether it bounds
|
|
591
594
|
// off-path spend at all.
|
|
592
595
|
offPathBound: input.run.offPathBound,
|
|
596
|
+
...(input.run.localQuantization ? { localQuantization: { ...input.run.localQuantization } } : {}),
|
|
593
597
|
snapshotStability: input.run.snapshotStability,
|
|
594
598
|
providerSourceKind: input.run.providerSourceKind,
|
|
595
599
|
...(input.reconciliationRunId === undefined ? {} : { reconciliationRunId: nonEmpty(input.reconciliationRunId, 'reconciliation run id') }),
|
package/dist/billing/importer.js
CHANGED
|
@@ -32,25 +32,44 @@ function preview(document, fileName, fileSha256, fileSizeBytes) {
|
|
|
32
32
|
reconciliationStatus: 'not_reconciled',
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
function sizeLimitError() {
|
|
36
|
+
return new Error(`billing evidence exceeds the ${MAX_BILLING_IMPORT_BYTES / (1024 * 1024)} MiB v1 import limit`);
|
|
37
|
+
}
|
|
35
38
|
/** Read, size-gate, hash, and strictly validate a Segreant billing-evidence v1 JSON file. */
|
|
36
39
|
export function readBillingImportFile(path) {
|
|
37
40
|
const stat = statSync(path);
|
|
38
41
|
if (!stat.isFile())
|
|
39
42
|
throw new Error('billing import --file must point to a regular local file');
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
// Refuse before reading: a multi-gigabyte file must never be pulled into memory.
|
|
44
|
+
if (stat.size > MAX_BILLING_IMPORT_BYTES)
|
|
45
|
+
throw sizeLimitError();
|
|
46
|
+
return parseBillingImportText(readFileSync(path), basename(path));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The validation readBillingImportFile applies, over bytes already in hand. The
|
|
50
|
+
* dashboard import route calls this directly; the CLI reaches it through
|
|
51
|
+
* readBillingImportFile, so the two surfaces give the same preview for the same bytes.
|
|
52
|
+
*/
|
|
53
|
+
export function parseBillingImportText(raw, fileName) {
|
|
54
|
+
if (raw.byteLength > MAX_BILLING_IMPORT_BYTES)
|
|
55
|
+
throw sizeLimitError();
|
|
44
56
|
let value;
|
|
45
57
|
try {
|
|
46
|
-
|
|
58
|
+
// A UTF-8 byte-order mark is not JSON, and Windows tools write one by
|
|
59
|
+
// default, so an export saved from Notepad was refused as "not valid JSON".
|
|
60
|
+
// It is skipped for parsing only: the digest below is over the file's own
|
|
61
|
+
// bytes, mark included, so the same file still has one identity.
|
|
62
|
+
const text = raw.toString('utf8');
|
|
63
|
+
value = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
47
64
|
}
|
|
48
|
-
catch
|
|
49
|
-
|
|
65
|
+
catch {
|
|
66
|
+
// Parser diagnostics can quote the file contents, which belong only in the
|
|
67
|
+
// operator's export, never in HTTP responses or CLI error output.
|
|
68
|
+
throw new Error('billing evidence is not valid JSON');
|
|
50
69
|
}
|
|
51
70
|
const document = parseBillingImportDocument(value);
|
|
52
|
-
const fileName = basename(path);
|
|
53
71
|
const fileSha256 = createHash('sha256').update(raw).digest('hex');
|
|
54
|
-
const
|
|
55
|
-
|
|
72
|
+
const fileSizeBytes = raw.byteLength;
|
|
73
|
+
const input = { document, fileName, fileSha256, fileSizeBytes, format: 'json' };
|
|
74
|
+
return { input, preview: preview(document, fileName, fileSha256, fileSizeBytes) };
|
|
56
75
|
}
|
|
@@ -107,6 +107,122 @@ export function describeOffPathBound(bound) {
|
|
|
107
107
|
function refuse(refusal, detail) {
|
|
108
108
|
return { status: 'refused', refusal, detail };
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The rounding a comparison applied to the local side, in words, or null when
|
|
112
|
+
* it changed nothing. A run recorded before the rule existed rounded nothing
|
|
113
|
+
* either: it refused any exact amount finer than a microdollar.
|
|
114
|
+
*/
|
|
115
|
+
export function describeLocalQuantization(q) {
|
|
116
|
+
if (!q || q.daysRounded === 0)
|
|
117
|
+
return null;
|
|
118
|
+
const days = `${q.daysRounded} day total${q.daysRounded === 1 ? '' : 's'}`;
|
|
119
|
+
return `Local amounts finer than a microdollar were summed exactly for each day; ${days} then rounded to the microdollar, half to even, so the local figure is within $${(q.daysRounded * 0.0000005).toFixed(7)} of the exact sum.`;
|
|
120
|
+
}
|
|
121
|
+
/** An exact decimal (coefficient x 10^-scale) as bigint microdollars, rounded half to even. */
|
|
122
|
+
function halfEvenMicrosBig(coefficient, scale) {
|
|
123
|
+
const shift = scale - 6;
|
|
124
|
+
if (shift <= 0)
|
|
125
|
+
return { micros: coefficient * 10n ** BigInt(-shift), rounded: false };
|
|
126
|
+
const divisor = 10n ** BigInt(shift);
|
|
127
|
+
const negative = coefficient < 0n;
|
|
128
|
+
const abs = negative ? -coefficient : coefficient;
|
|
129
|
+
let q = abs / divisor;
|
|
130
|
+
const r = abs % divisor;
|
|
131
|
+
const twice = r * 2n;
|
|
132
|
+
if (twice > divisor || (twice === divisor && q % 2n === 1n))
|
|
133
|
+
q += 1n;
|
|
134
|
+
return { micros: negative ? -q : q, rounded: r !== 0n };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* An exact amount as an integer coefficient and a decimal scale, read through
|
|
138
|
+
* `formatMoneyAmount` so this stays a visible monetary read site (the FX
|
|
139
|
+
* consumer sweep classifies every file that reads Money).
|
|
140
|
+
*/
|
|
141
|
+
function exactParts(value) {
|
|
142
|
+
const text = formatMoneyAmount(value);
|
|
143
|
+
const negative = text.startsWith('-');
|
|
144
|
+
const [whole, fraction = ''] = (negative ? text.slice(1) : text).split('.');
|
|
145
|
+
const coefficient = BigInt(`${whole}${fraction}`);
|
|
146
|
+
return { coefficient: negative ? -coefficient : coefficient, scale: fraction.length };
|
|
147
|
+
}
|
|
148
|
+
/** bigint microdollars as a number, or null when a number cannot hold them exactly. */
|
|
149
|
+
function safeMicros(micros) {
|
|
150
|
+
const max = BigInt(Number.MAX_SAFE_INTEGER);
|
|
151
|
+
return micros > max || micros < -max ? null : Number(micros);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* An exact USD amount in microdollars, rounded half to even; says whether
|
|
155
|
+
* rounding changed it. Throws rather than return a number that lost digits.
|
|
156
|
+
*/
|
|
157
|
+
export function halfEvenMicros(value) {
|
|
158
|
+
const { coefficient, scale } = exactParts(value);
|
|
159
|
+
const { micros, rounded } = halfEvenMicrosBig(coefficient, scale);
|
|
160
|
+
const n = safeMicros(micros);
|
|
161
|
+
if (n === null)
|
|
162
|
+
throw new Error('amount is outside the supported fixed-point range');
|
|
163
|
+
return { micros: n, rounded };
|
|
164
|
+
}
|
|
165
|
+
function addExact(day, coefficient, scale) {
|
|
166
|
+
if (scale > day.scale) {
|
|
167
|
+
day.coefficient *= 10n ** BigInt(scale - day.scale);
|
|
168
|
+
day.scale = scale;
|
|
169
|
+
}
|
|
170
|
+
day.coefficient += coefficient * 10n ** BigInt(day.scale - scale);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Local capture per UTC day under the reconciliation's one rule: each row's
|
|
174
|
+
* exact amount (or, for a legacy row, its rate-card projection) is summed
|
|
175
|
+
* exactly per day, and only the day total is rounded to the microdollar, half
|
|
176
|
+
* to even. Anything that compares itself with a reconciliation's local figure
|
|
177
|
+
* uses this, so the two cannot disagree by a rounding: a period total rounded
|
|
178
|
+
* once from floats could come out below the per-day sum of a subset of it.
|
|
179
|
+
*/
|
|
180
|
+
export function quantizeLocalDays(rows, accept) {
|
|
181
|
+
const exactByDay = new Map();
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
if (!accept(row))
|
|
184
|
+
continue;
|
|
185
|
+
const key = utcDayStart(row.tsEpochMs);
|
|
186
|
+
const bucket = exactByDay.get(key) ?? { coefficient: 0n, scale: 6, requests: 0 };
|
|
187
|
+
if (row.economicAmount !== undefined) {
|
|
188
|
+
if (row.economicAmount.currency !== 'USD') {
|
|
189
|
+
return { refusal: {
|
|
190
|
+
code: 'local_exact_amount_is_not_usd',
|
|
191
|
+
message: `request ${row.requestId} has an exact local amount in ${row.economicAmount.currency}; this reconciliation has no FX policy`,
|
|
192
|
+
} };
|
|
193
|
+
}
|
|
194
|
+
// Kept exact until the day is summed. Per-token prices give amounts like
|
|
195
|
+
// 0.0014008, finer than the provider's microdollars; refusing them here
|
|
196
|
+
// refused almost every real OpenAI route (found seeding the demo bill,
|
|
197
|
+
// 2026-10-16). The rule is stated in the result, not invented silently.
|
|
198
|
+
const parts = exactParts(row.economicAmount);
|
|
199
|
+
addExact(bucket, parts.coefficient, parts.scale);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Legacy rows have no exact economic event. Retain their compatibility
|
|
203
|
+
// projection explicitly as the rate-card estimate documented by the
|
|
204
|
+
// result, and add it into the same exact sum: rounding the exact part
|
|
205
|
+
// alone and adding the legacy part after could round a tie the wrong way.
|
|
206
|
+
addExact(bucket, BigInt(Math.round(row.costUsd * 1_000_000)), 6);
|
|
207
|
+
}
|
|
208
|
+
bucket.requests += 1;
|
|
209
|
+
exactByDay.set(key, bucket);
|
|
210
|
+
}
|
|
211
|
+
// Each day's exact sum becomes microdollars once, rounded half to even, and
|
|
212
|
+
// is checked against the number range before it leaves bigint.
|
|
213
|
+
let daysRounded = 0;
|
|
214
|
+
const localByDay = new Map();
|
|
215
|
+
for (const [key, day] of exactByDay) {
|
|
216
|
+
const { micros, rounded } = halfEvenMicrosBig(day.coefficient, day.scale);
|
|
217
|
+
const n = safeMicros(micros);
|
|
218
|
+
if (n === null)
|
|
219
|
+
return { refusal: { code: 'local_exact_amount_requires_explicit_quantization', message: 'a day total is outside the supported fixed-point range' } };
|
|
220
|
+
if (rounded)
|
|
221
|
+
daysRounded += 1;
|
|
222
|
+
localByDay.set(key, { micros: n, requests: day.requests });
|
|
223
|
+
}
|
|
224
|
+
return { localByDay, daysRounded };
|
|
225
|
+
}
|
|
110
226
|
/** UTC day key for a local ledger row, matching the provider's bucket boundary. */
|
|
111
227
|
function utcDayStart(tsEpochMs) {
|
|
112
228
|
return Math.floor(tsEpochMs / DAY_MS) * DAY_MS;
|
|
@@ -182,38 +298,11 @@ export function reconcileOpenAiCosts(input) {
|
|
|
182
298
|
else
|
|
183
299
|
providerByDay.set(line.bucketStartMs, [line]);
|
|
184
300
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
continue;
|
|
191
|
-
const key = utcDayStart(row.tsEpochMs);
|
|
192
|
-
const bucket = localByDay.get(key) ?? { micros: 0, requests: 0 };
|
|
193
|
-
let capturedMicros;
|
|
194
|
-
if (row.economicAmount !== undefined) {
|
|
195
|
-
if (row.economicAmount.currency !== 'USD') {
|
|
196
|
-
return refuse('local_exact_amount_is_not_usd', `request ${row.requestId} has an exact local amount in ${row.economicAmount.currency}; this reconciliation has no FX policy`);
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
// Preserve the canonical exact amount until the fixed-point boundary.
|
|
200
|
-
// If it cannot be represented in provider microdollars, refusing is safer
|
|
201
|
-
// than inventing a rounding mode for a consequential comparison.
|
|
202
|
-
capturedMicros = usdMicros(formatMoneyAmount(row.economicAmount), `request ${row.requestId} exact amount`);
|
|
203
|
-
}
|
|
204
|
-
catch (error) {
|
|
205
|
-
return refuse('local_exact_amount_requires_explicit_quantization', `request ${row.requestId} exact local amount cannot be represented in microdollars without an explicit quantization policy: ${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
// Legacy rows have no exact economic event. Retain their compatibility
|
|
210
|
-
// projection explicitly as the rate-card estimate documented by the result.
|
|
211
|
-
capturedMicros = Math.round(row.costUsd * 1_000_000);
|
|
212
|
-
}
|
|
213
|
-
bucket.micros += capturedMicros;
|
|
214
|
-
bucket.requests += 1;
|
|
215
|
-
localByDay.set(key, bucket);
|
|
216
|
-
}
|
|
301
|
+
const quantized = quantizeLocalDays(input.requests, (row) => isOnDeclaredRoute(row, input.run.declaredScopeId)
|
|
302
|
+
&& row.tsEpochMs >= input.run.periodStartMs && row.tsEpochMs < input.run.periodEndMs);
|
|
303
|
+
if ('refusal' in quantized)
|
|
304
|
+
return refuse(quantized.refusal.code, quantized.refusal.message);
|
|
305
|
+
const { localByDay, daysRounded } = quantized;
|
|
217
306
|
const days = [];
|
|
218
307
|
const allDays = [...new Set([...providerByDay.keys(), ...localByDay.keys()])].sort((a, b) => a - b);
|
|
219
308
|
let providerTotal = 0;
|
|
@@ -290,6 +379,7 @@ export function reconcileOpenAiCosts(input) {
|
|
|
290
379
|
},
|
|
291
380
|
days,
|
|
292
381
|
snapshotStability,
|
|
382
|
+
localQuantization: { rule: 'day_total_half_even_micros', daysRounded },
|
|
293
383
|
unstableDayStartMs,
|
|
294
384
|
providerSourceKind: sourceKind,
|
|
295
385
|
// The fifth condition appears only when it is true. A reader who sees four
|
package/dist/billing/scope.js
CHANGED
|
@@ -18,6 +18,13 @@ function safeRef(value, label, required) {
|
|
|
18
18
|
throw new Error(`${label} is required`);
|
|
19
19
|
if (trimmed.length > MAX_REF || CONTROL.test(trimmed))
|
|
20
20
|
throw new Error(`${label} must be a short, single-line reference`);
|
|
21
|
+
// A reference is stored for good and issued to the kernel, so a key typed into
|
|
22
|
+
// this field would become durable ledger content. OpenAI keys start `sk-`
|
|
23
|
+
// (sk-proj-, sk-admin-, sk-svcacct-); restricted keys `rk-`. Refuse the shape
|
|
24
|
+
// outright rather than store it and hope nobody exports the ledger.
|
|
25
|
+
if (/^(sk|rk)-/i.test(trimmed) || /^bearer\s/i.test(trimmed)) {
|
|
26
|
+
throw new Error(`${label} looks like an API key. Use the organisation id (org_…) or project id (proj_…) from your OpenAI settings; never a key.`);
|
|
27
|
+
}
|
|
21
28
|
return trimmed;
|
|
22
29
|
}
|
|
23
30
|
/** Canonical, credential-free identity of the configured upstream endpoint. */
|
package/dist/budget/guard.js
CHANGED
|
@@ -125,18 +125,47 @@ export function resolveEnforcedSpend(exact, floatUsd) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
/** One clause naming the basis, appended to whatever refusal or warning states the number. */
|
|
128
|
+
/**
|
|
129
|
+
* Spend on the basis a cap governs, over [startMs, endMs), projected exactly
|
|
130
|
+
* as the guard projects it (resolveEnforcedSpend), with the number of requests
|
|
131
|
+
* behind it. The guide and the budget advisor read this rather than a series
|
|
132
|
+
* of their own: a separate series could say "no proxied spend" while the guard
|
|
133
|
+
* was blocking on it (review 4, F3).
|
|
134
|
+
*/
|
|
135
|
+
export function capBasisSpend(store, budget, startMs, endMs) {
|
|
136
|
+
const liveOnly = !budget.capIncludesImported;
|
|
137
|
+
const exact = typeof store.exactSpendBetween === 'function' ? store.exactSpendBetween(startMs, endMs, liveOnly) : null;
|
|
138
|
+
const resolved = resolveEnforcedSpend(exact, store.spendBetween(startMs, endMs, liveOnly));
|
|
139
|
+
return { usd: resolved.usd, requests: exact?.requestCount ?? null, basis: liveOnly ? 'live_proxy' : 'all_observed' };
|
|
140
|
+
}
|
|
128
141
|
export function describeSpendBasis(basis) {
|
|
129
142
|
if (basis.enforcedAgainst === 'rate_card_float' && basis.exactResolvedUsd === null) {
|
|
130
|
-
return 'Basis: local
|
|
143
|
+
return 'Basis: local estimates (list prices, and any prices you set), with no exact economic projection available.';
|
|
131
144
|
}
|
|
132
145
|
const bases = basis.sourceBases.length > 0 ? basis.sourceBases.join(', ') : 'none declared';
|
|
133
146
|
if (basis.complete)
|
|
134
147
|
return `Basis: effective economic charges (${bases}), complete for this window.`;
|
|
135
148
|
const which = basis.enforcedAgainst === 'exact_effective'
|
|
136
149
|
? `effective economic charges (${bases})`
|
|
137
|
-
: 'local
|
|
150
|
+
: 'local estimates (list prices, and any prices you set)';
|
|
138
151
|
return `Basis: ${which}, a lower bound — ${basis.unresolvedRequests} of ${basis.requestCount} requests in this window are unpriced.`;
|
|
139
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Spend beside the cap it crossed. Two decimals hid the overshoot: $5.004
|
|
155
|
+
* against a $5 cap read "$5.00 of $5.00". When the two would round alike but
|
|
156
|
+
* differ, add digits (to the microdollar) until they do not. A difference
|
|
157
|
+
* below the microdollar is said in words, so two different amounts never
|
|
158
|
+
* print as the same figure (PR #142 review).
|
|
159
|
+
*/
|
|
160
|
+
export function spendBesideCap(spendUsd, capUsd) {
|
|
161
|
+
let digits = 2;
|
|
162
|
+
while (digits < 6 && spendUsd !== capUsd && spendUsd.toFixed(digits) === capUsd.toFixed(digits))
|
|
163
|
+
digits += 1;
|
|
164
|
+
const shown = '$' + spendUsd.toFixed(digits);
|
|
165
|
+
if (spendUsd === capUsd || spendUsd.toFixed(digits) !== capUsd.toFixed(digits))
|
|
166
|
+
return shown;
|
|
167
|
+
return `${spendUsd > capUsd ? 'just over' : 'just under'} ${shown}`;
|
|
168
|
+
}
|
|
140
169
|
export function startOfLocalDay(now = Date.now()) {
|
|
141
170
|
const d = new Date(now);
|
|
142
171
|
d.setHours(0, 0, 0, 0);
|
|
@@ -250,7 +279,7 @@ export class BudgetGuard {
|
|
|
250
279
|
return {
|
|
251
280
|
...base,
|
|
252
281
|
action: 'block',
|
|
253
|
-
reason: `Daily budget reached:
|
|
282
|
+
reason: `Daily budget reached: ${spendBesideCap(daySpend, dailyLimit)} of $${dailyLimit.toFixed(2)} cap` +
|
|
254
283
|
(liveOnly ? ' (live proxy spend; imported spend excluded). ' : ' (includes imported spend). ') +
|
|
255
284
|
describeSpendBasis(day.basis),
|
|
256
285
|
};
|
|
@@ -259,7 +288,7 @@ export class BudgetGuard {
|
|
|
259
288
|
return {
|
|
260
289
|
...base,
|
|
261
290
|
action: 'block',
|
|
262
|
-
reason: `Session budget reached:
|
|
291
|
+
reason: `Session budget reached: ${spendBesideCap(session.usd, cfg.sessionUsd)} of $${cfg.sessionUsd.toFixed(2)} cap. `
|
|
263
292
|
+ describeSpendBasis(session.basis),
|
|
264
293
|
};
|
|
265
294
|
}
|