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
|
@@ -10,8 +10,68 @@
|
|
|
10
10
|
import { api } from './api.js';
|
|
11
11
|
import { h } from './dom.js';
|
|
12
12
|
import { signal } from './signal.js';
|
|
13
|
-
import { isPrecise, usd, count } from './fmt.js';
|
|
13
|
+
import { isPrecise, usd, usdFromMicros, share, count, basisWords } from './fmt.js';
|
|
14
|
+
import { residualBoundWords, dayRange, quantizationWords } from './reconcileWords.js';
|
|
15
|
+
import { localMidnightIso, dayAfterIso, monthRange, REASON_WORDS } from './periods.js';
|
|
16
|
+
import { slug } from './slug.js';
|
|
17
|
+
import { ruleFields, describeRule } from '../components/ruleFields.js';
|
|
18
|
+
import { pricingUnknownAction, repriceAction } from './pricingActions.js';
|
|
19
|
+
/**
|
|
20
|
+
* Sample data runs against a read-only configuration: the server refuses every
|
|
21
|
+
* settings write. The preview says so before Apply is offered, instead of
|
|
22
|
+
* enabling a button whose only outcome is a refusal (H005).
|
|
23
|
+
*/
|
|
24
|
+
const DEMO_SETTINGS_BLOCKED = 'This dashboard is showing sample data, so settings can\'t be changed here. Start Segreant without --demo to change your own.';
|
|
25
|
+
/** Sample data refuses every ledger write; the preview says so before Apply. */
|
|
26
|
+
const DEMO_LEDGER_BLOCKED = 'This dashboard is showing sample data, so nothing can be saved here. Start Segreant without --demo to change your own.';
|
|
27
|
+
/** A value no quoting can carry literally into every shell the command may be pasted into. */
|
|
28
|
+
class UnquotableValue extends Error {
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A value as one literal shell word. Single quotes are literal in POSIX shells
|
|
32
|
+
* and in PowerShell; the double quotes JSON produces are not, so a name like
|
|
33
|
+
* `$(whoami)` pasted from the drawer ran in the shell and saved a different
|
|
34
|
+
* value from Apply. A value holding a single quote has no quoting that is
|
|
35
|
+
* literal in both, so the command is withheld instead of shown wrong.
|
|
36
|
+
*/
|
|
37
|
+
function shellWord(text) {
|
|
38
|
+
// Bare only when no shell gives the first character a meaning: a leading
|
|
39
|
+
// `@` is splatting in PowerShell, and a leading `-` would read as a flag.
|
|
40
|
+
if (/^[A-Za-z0-9._:/][A-Za-z0-9._:/@-]*$/.test(text))
|
|
41
|
+
return text;
|
|
42
|
+
// PowerShell closes a single-quoted string on the curly quotes too
|
|
43
|
+
// (U+2018, U+2019, U+201A, U+201B), so a name like O\u2019Brien broke out of
|
|
44
|
+
// the quoting and anything after it ran (Codex review 2026-10-16).
|
|
45
|
+
if (!/['\u2018\u2019\u201A\u201B]/.test(text) && !text.startsWith('-'))
|
|
46
|
+
return `'${text}'`;
|
|
47
|
+
throw new UnquotableValue();
|
|
48
|
+
}
|
|
49
|
+
/** The command a drawer shows, or null when a value cannot be written literally. */
|
|
50
|
+
function quoted(build) {
|
|
51
|
+
try {
|
|
52
|
+
return build();
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e instanceof UnquotableValue)
|
|
56
|
+
return null;
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Tell the shell a claim changed, so the four-figure strip re-reads it (as the pages do). */
|
|
61
|
+
function ledgerChanged(claim) {
|
|
62
|
+
window.dispatchEvent(new CustomEvent('segreant:ledger-changed', { detail: { claim } }));
|
|
63
|
+
}
|
|
64
|
+
/** Read a typed dollar amount: null for empty, NaN for anything unusable. */
|
|
65
|
+
function enteredUsd(raw) {
|
|
66
|
+
const t = raw.trim();
|
|
67
|
+
if (t === '')
|
|
68
|
+
return null;
|
|
69
|
+
const n = Number(t);
|
|
70
|
+
return Number.isFinite(n) && n >= 0 ? n : Number.NaN;
|
|
71
|
+
}
|
|
14
72
|
const BUILDERS = {
|
|
73
|
+
'pricing-unknown': pricingUnknownAction,
|
|
74
|
+
reprice: repriceAction,
|
|
15
75
|
'clear-proposals': (cap) => ({
|
|
16
76
|
capability: cap,
|
|
17
77
|
preview: async () => {
|
|
@@ -128,8 +188,20 @@ const BUILDERS = {
|
|
|
128
188
|
*/
|
|
129
189
|
budget: (cap) => {
|
|
130
190
|
const entered = signal('');
|
|
191
|
+
let adviceRead = null;
|
|
192
|
+
let advice;
|
|
193
|
+
const adviceSoon = () => {
|
|
194
|
+
adviceRead ??= api.value().then((v) => (advice = v.budget ?? null), () => (advice = null));
|
|
195
|
+
if (advice !== undefined)
|
|
196
|
+
return Promise.resolve(advice);
|
|
197
|
+
return Promise.race([adviceRead, new Promise((resolve) => setTimeout(() => resolve(undefined), 1200))]);
|
|
198
|
+
};
|
|
131
199
|
return {
|
|
132
200
|
capability: cap,
|
|
201
|
+
command: () => {
|
|
202
|
+
const amount = enteredUsd(entered());
|
|
203
|
+
return amount === null || Number.isNaN(amount) ? null : `segreant budget --daily ${amount}`;
|
|
204
|
+
},
|
|
133
205
|
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'budget-cap-input' }, isPrecise() ? 'New daily cap (USD)' : 'New daily limit, in dollars'), h('input', {
|
|
134
206
|
id: 'budget-cap-input',
|
|
135
207
|
class: 'drawer-input',
|
|
@@ -150,23 +222,40 @@ const BUILDERS = {
|
|
|
150
222
|
oninput: (event) => entered.set(event.target.value),
|
|
151
223
|
}), h('p', { id: 'budget-cap-note', class: 'drawer-note', text: 'Leave this empty to change nothing. Enter 0 to block all spend.' })),
|
|
152
224
|
preview: async () => {
|
|
153
|
-
const [settings,
|
|
225
|
+
const [settings, advice] = await Promise.all([api.settings(), adviceSoon()]);
|
|
154
226
|
const current = settings.budget?.dailyUsd ?? null;
|
|
155
|
-
|
|
227
|
+
// Validation is part of the preview, so a wrong amount is named before
|
|
228
|
+
// Apply and a corrected one re-enables it (H004).
|
|
229
|
+
const amount = enteredUsd(entered());
|
|
230
|
+
const blockedReason = settings.demo
|
|
231
|
+
? DEMO_SETTINGS_BLOCKED
|
|
232
|
+
: amount === null
|
|
233
|
+
? 'Enter an amount to see the change. An empty field changes nothing.'
|
|
234
|
+
: Number.isNaN(amount)
|
|
235
|
+
? 'That is not a usable amount. Enter a number of dollars, or 0 to block everything.'
|
|
236
|
+
: amount === current
|
|
237
|
+
? 'The cap is already that amount, so nothing would change.'
|
|
238
|
+
: undefined;
|
|
156
239
|
return {
|
|
157
|
-
applicable:
|
|
240
|
+
applicable: blockedReason === undefined,
|
|
241
|
+
blockedReason,
|
|
158
242
|
summary: isPrecise()
|
|
159
243
|
? 'Writes the daily cap to the local config file. Enforcement happens at the proxy, so only spend routed through it can be blocked.'
|
|
160
244
|
: 'Saves a new daily limit on this machine. Segreant can only actually block spend that goes through it.',
|
|
161
245
|
rows: [
|
|
162
246
|
{ label: 'Current cap', value: current === null ? 'unlimited' : usd(current) },
|
|
163
|
-
|
|
164
|
-
label: '
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
247
|
+
...(amount !== null && !Number.isNaN(amount)
|
|
248
|
+
? [{ label: 'New cap', value: usd(amount), note: amount === 0 ? 'blocks every proxied request' : undefined }]
|
|
249
|
+
: []),
|
|
250
|
+
advice === undefined
|
|
251
|
+
? { label: 'Recommended', value: 'still being worked out', note: 'it comes from the value read; edit the amount or reopen to see it' }
|
|
252
|
+
: {
|
|
253
|
+
label: 'Recommended',
|
|
254
|
+
value: advice?.recommendedDailyUsd != null ? usd(advice.recommendedDailyUsd) : 'not established',
|
|
255
|
+
note: advice?.canApply
|
|
256
|
+
? `from ${count(advice.basisDays)} days of observed spend`
|
|
257
|
+
: 'not enough observed history to recommend acting on',
|
|
258
|
+
},
|
|
170
259
|
{
|
|
171
260
|
label: 'Counts toward the cap',
|
|
172
261
|
value: settings.budget?.capIncludesImported ? 'all observed spend' : 'live proxy spend only',
|
|
@@ -186,11 +275,12 @@ const BUILDERS = {
|
|
|
186
275
|
};
|
|
187
276
|
},
|
|
188
277
|
commit: async () => {
|
|
189
|
-
|
|
190
|
-
|
|
278
|
+
// The preview already refused these; this guards a commit that races
|
|
279
|
+
// a field edit.
|
|
280
|
+
const parsed = enteredUsd(entered());
|
|
281
|
+
if (parsed === null)
|
|
191
282
|
return { ok: false, message: 'No value entered, so nothing was changed.' };
|
|
192
|
-
|
|
193
|
-
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
283
|
+
if (Number.isNaN(parsed)) {
|
|
194
284
|
return { ok: false, message: 'That is not a usable amount. Enter a number of dollars, or 0 to block everything.' };
|
|
195
285
|
}
|
|
196
286
|
// `dailyUsd`, not `dailyCapUsd`. applySettingsPatch only copies keys it
|
|
@@ -205,6 +295,620 @@ const BUILDERS = {
|
|
|
205
295
|
},
|
|
206
296
|
};
|
|
207
297
|
},
|
|
298
|
+
/**
|
|
299
|
+
* Add, rename or archive a cost centre: everything `segreant alloc centre`
|
|
300
|
+
* does. The Allocate page adds and archives; only here can an existing
|
|
301
|
+
* centre's name or owner change, because the page derives the id from the
|
|
302
|
+
* name and so can never reach a centre under a new name.
|
|
303
|
+
*/
|
|
304
|
+
'alloc-centres': (cap) => {
|
|
305
|
+
const target = signal('');
|
|
306
|
+
// Separate from `target`: the regions below re-render only when whether a
|
|
307
|
+
// centre is chosen changes, never on the choice itself, so a keyboard user
|
|
308
|
+
// arrowing through the select keeps focus on it.
|
|
309
|
+
const editing = signal(false);
|
|
310
|
+
const archive = signal(false);
|
|
311
|
+
const name = signal('');
|
|
312
|
+
const owner = signal('');
|
|
313
|
+
let read = null;
|
|
314
|
+
const allocation = () => (read ??= api.allocation());
|
|
315
|
+
let open = [];
|
|
316
|
+
const id = () => target() || slug(name());
|
|
317
|
+
const fill = (inputId, value) => {
|
|
318
|
+
const input = document.getElementById(inputId);
|
|
319
|
+
if (input)
|
|
320
|
+
input.value = value;
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
capability: cap,
|
|
324
|
+
command: () => quoted(() => {
|
|
325
|
+
if (!id())
|
|
326
|
+
return null;
|
|
327
|
+
if (archive())
|
|
328
|
+
return `segreant alloc centre ${id()} --archive`;
|
|
329
|
+
if (!name().trim())
|
|
330
|
+
return null;
|
|
331
|
+
return `segreant alloc centre ${id()} --name ${shellWord(name().trim())}${owner().trim() ? ` --owner ${shellWord(owner().trim())}` : ''}`;
|
|
332
|
+
}),
|
|
333
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'centre-target' }, 'Cost centre'), h('select', {
|
|
334
|
+
id: 'centre-target',
|
|
335
|
+
class: 'drawer-input',
|
|
336
|
+
onchange: (event) => {
|
|
337
|
+
const chosen = event.target.value;
|
|
338
|
+
const centre = open.find((c) => c.costCentreId === chosen);
|
|
339
|
+
target.set(chosen);
|
|
340
|
+
if (editing() !== (chosen !== ''))
|
|
341
|
+
editing.set(chosen !== '');
|
|
342
|
+
archive.set(false);
|
|
343
|
+
name.set(centre?.name ?? '');
|
|
344
|
+
owner.set(centre?.owner ?? '');
|
|
345
|
+
fill('centre-drawer-name', name());
|
|
346
|
+
fill('centre-drawer-owner', owner());
|
|
347
|
+
fill('centre-change', 'save');
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
// Built once; the centres are appended when they arrive. Rebuilding the
|
|
351
|
+
// select on arrival took focus, and the choice, from a reader who had
|
|
352
|
+
// already reached it.
|
|
353
|
+
(() => {
|
|
354
|
+
const placeholder = h('option', { value: '', text: 'A new cost centre' });
|
|
355
|
+
void allocation().then((d) => {
|
|
356
|
+
open = d.costCentres.filter((c) => c.archivedAtMs == null);
|
|
357
|
+
placeholder.after(...open.map((c) => h('option', { value: c.costCentreId, text: `${c.name} (${c.costCentreId})` })));
|
|
358
|
+
}, () => undefined);
|
|
359
|
+
return placeholder;
|
|
360
|
+
})()), () => editing()
|
|
361
|
+
? h('div', null, h('label', { class: 'drawer-h3', for: 'centre-change' }, 'Change'), h('select', { id: 'centre-change', class: 'drawer-input', onchange: (event) => archive.set(event.target.value === 'archive') }, h('option', { value: 'save', text: 'Its name or owner' }), h('option', { value: 'archive', text: 'Archive it' })))
|
|
362
|
+
: null, h('label', { class: 'drawer-h3', for: 'centre-drawer-name' }, 'Name'), h('input', {
|
|
363
|
+
id: 'centre-drawer-name', class: 'drawer-input', maxlength: '120', autocomplete: 'off', placeholder: 'for example Engineering',
|
|
364
|
+
oninput: (event) => name.set(event.target.value),
|
|
365
|
+
}), h('label', { class: 'drawer-h3', for: 'centre-drawer-owner' }, 'Owner (optional)'), h('input', {
|
|
366
|
+
id: 'centre-drawer-owner', class: 'drawer-input', maxlength: '120', autocomplete: 'off', placeholder: 'who answers for it',
|
|
367
|
+
'aria-describedby': 'centre-drawer-note',
|
|
368
|
+
oninput: (event) => owner.set(event.target.value),
|
|
369
|
+
}), h('p', { id: 'centre-drawer-note', class: 'drawer-note', text: 'Saving replaces the owner, so an empty owner field clears it, as the command does.' })),
|
|
370
|
+
preview: async () => {
|
|
371
|
+
const d = await allocation();
|
|
372
|
+
const key = id();
|
|
373
|
+
const existing = d.costCentres.find((c) => c.costCentreId === key) ?? null;
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
const namedBy = d.rules.filter((r) => r.revokedAtMs == null
|
|
376
|
+
&& (r.effectiveToMs == null || r.effectiveToMs > now)
|
|
377
|
+
&& !d.rules.some((o) => o.ruleId === r.ruleId && o.version > r.version)
|
|
378
|
+
&& r.targets.some((t) => t.costCentreId === key)).length;
|
|
379
|
+
if (archive()) {
|
|
380
|
+
return {
|
|
381
|
+
applicable: !d.demo && existing?.archivedAtMs == null,
|
|
382
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : existing?.archivedAtMs != null ? 'This cost centre is already archived.' : undefined,
|
|
383
|
+
summary: isPrecise()
|
|
384
|
+
? 'Sets archived_at_ms on the centre. Recorded runs keep it; later runs do not allocate to it.'
|
|
385
|
+
: 'Stops this cost centre receiving spend. Splits already recorded keep it, so they still add up.',
|
|
386
|
+
rows: [
|
|
387
|
+
{ label: 'Cost centre', value: existing ? existing.name : key, note: key },
|
|
388
|
+
{ label: 'Rules that name it', value: count(namedBy), note: namedBy > 0 ? 'revise them on the Allocate page, or their share has nowhere to go' : undefined },
|
|
389
|
+
{ label: 'Reversible', value: 'not from here', note: 'an archived centre stays archived; saving it again only changes its name' },
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const typed = name().trim();
|
|
394
|
+
const blockedReason = d.demo
|
|
395
|
+
? DEMO_LEDGER_BLOCKED
|
|
396
|
+
: !key
|
|
397
|
+
? 'Give the cost centre a name with at least one letter or number.'
|
|
398
|
+
: !typed
|
|
399
|
+
? 'A cost centre needs a name.'
|
|
400
|
+
: existing && existing.name === typed && (existing.owner ?? '') === owner().trim()
|
|
401
|
+
? 'Nothing would change yet: the name and owner are already these.'
|
|
402
|
+
: undefined;
|
|
403
|
+
const unchanged = existing !== null && existing.name === typed && (existing.owner ?? '') === owner().trim();
|
|
404
|
+
const effect = !existing
|
|
405
|
+
? 'adds a new cost centre'
|
|
406
|
+
: unchanged
|
|
407
|
+
? 'none yet; edit the name or owner'
|
|
408
|
+
: existing.archivedAtMs != null
|
|
409
|
+
? 'renames an archived centre; it stays archived and still receives no spend'
|
|
410
|
+
: 'changes its name or owner';
|
|
411
|
+
return {
|
|
412
|
+
applicable: blockedReason === undefined,
|
|
413
|
+
blockedReason,
|
|
414
|
+
summary: isPrecise()
|
|
415
|
+
? 'Upserts one cost_centres row by id. Recorded runs keep the figures they were computed with.'
|
|
416
|
+
: 'Saves this cost centre on this machine. Splits already recorded are not changed.',
|
|
417
|
+
rows: [
|
|
418
|
+
{ label: 'Saved as', value: key || '—', note: target() ? 'the id stays the same' : 'derived from the name' },
|
|
419
|
+
{ label: 'Effect', value: effect },
|
|
420
|
+
...(existing ? [{ label: 'Now', value: existing.name, note: existing.owner ? `owner ${existing.owner}` : 'no owner' }] : []),
|
|
421
|
+
{ label: 'Rules that name it', value: count(namedBy) },
|
|
422
|
+
],
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
commit: async () => {
|
|
426
|
+
const key = id();
|
|
427
|
+
const result = archive()
|
|
428
|
+
? await api.write.allocation({ op: 'archive', id: key })
|
|
429
|
+
: await api.write.allocation({ op: 'centre', id: key, name: name().trim(), ...(owner().trim() ? { owner: owner().trim() } : {}) });
|
|
430
|
+
read = null;
|
|
431
|
+
if (result.ok)
|
|
432
|
+
ledgerChanged('allocation');
|
|
433
|
+
return { ok: result.ok, message: result.message ?? 'Saved.' };
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
},
|
|
437
|
+
/**
|
|
438
|
+
* Allocation rules: `segreant alloc rule` and `segreant alloc revoke`. The
|
|
439
|
+
* fields are the Allocate page's own (components/ruleFields.ts), so a rule
|
|
440
|
+
* written here is the rule written there; the preview says what it takes,
|
|
441
|
+
* where it sends it, and whether it replaces a current version.
|
|
442
|
+
*/
|
|
443
|
+
'alloc-rules': (cap) => {
|
|
444
|
+
const revoking = signal(false);
|
|
445
|
+
const revokeId = signal('');
|
|
446
|
+
const ready = signal(null);
|
|
447
|
+
let read = null;
|
|
448
|
+
const load = () => (read ??= Promise.all([api.allocation(), api.overview('all').catch(() => null)]).then(([d, o]) => {
|
|
449
|
+
const open = d.costCentres.filter((c) => c.archivedAtMs == null);
|
|
450
|
+
const projects = o ? o.byProject.map((p) => p.label).filter((l) => l && l !== '(unattributed)') : [];
|
|
451
|
+
const models = o ? [...new Set(o.byModel.map((m) => m.label).filter(Boolean))] : [];
|
|
452
|
+
const r = { d, fields: ruleFields(open, () => projects, () => models, 'rule-drawer') };
|
|
453
|
+
ready.set(r);
|
|
454
|
+
return r;
|
|
455
|
+
}));
|
|
456
|
+
void load();
|
|
457
|
+
const nameOf = (d) => (id) => d.costCentres.find((c) => c.costCentreId === id)?.name ?? id;
|
|
458
|
+
const current = (d) => d.rules.filter((r) => r.revokedAtMs == null && !d.rules.some((o) => o.ruleId === r.ruleId && o.version > r.version));
|
|
459
|
+
const draft = () => {
|
|
460
|
+
const r = ready();
|
|
461
|
+
return r ? r.fields.build() : null;
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
capability: cap,
|
|
465
|
+
command: () => quoted(() => {
|
|
466
|
+
if (revoking())
|
|
467
|
+
return revokeId() ? `segreant alloc revoke ${revokeId()}` : null;
|
|
468
|
+
const x = draft();
|
|
469
|
+
if (!x || typeof x === 'string')
|
|
470
|
+
return null;
|
|
471
|
+
const centre = x.rule.targets.map((t) => (x.rule.method === 'fixed_split' ? `${t.costCentreId}:${t.ratio}` : t.costCentreId)).join(',');
|
|
472
|
+
const match = Object.entries(x.rule.match).map(([k, v]) => ` --match-${k} ${shellWord(String(v))}`).join('');
|
|
473
|
+
const body = x.body;
|
|
474
|
+
return `segreant alloc rule ${x.id} --method ${x.rule.method} --centre ${centre}${match}`
|
|
475
|
+
+ (x.priority === null ? '' : ` --priority ${x.priority}`)
|
|
476
|
+
+ (body.from ? ` --from ${body.from}` : '')
|
|
477
|
+
+ (body.until ? ` --until ${body.until}` : '')
|
|
478
|
+
+ (x.owner ? ` --owner ${shellWord(x.owner)}` : '')
|
|
479
|
+
+ (x.note ? ` --note ${shellWord(x.note)}` : '');
|
|
480
|
+
}),
|
|
481
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'rule-drawer-change' }, 'Change'), h('select', { id: 'rule-drawer-change', class: 'drawer-input', onchange: (event) => revoking.set(event.target.value === 'revoke') }, h('option', { value: 'save', text: 'Add a rule, or a new version of one' }), h('option', { value: 'revoke', text: 'Revoke a rule' })),
|
|
482
|
+
// Each region reads only whether its data has arrived or the choice
|
|
483
|
+
// above, so typing in a field never rebuilds the field.
|
|
484
|
+
() => {
|
|
485
|
+
if (!revoking())
|
|
486
|
+
return null;
|
|
487
|
+
const r = ready();
|
|
488
|
+
if (!r)
|
|
489
|
+
return h('p', { class: 'drawer-muted', text: 'Reading your rules…' });
|
|
490
|
+
return h('div', null, h('label', { class: 'drawer-h3', for: 'rule-drawer-revoke' }, 'Rule'), h('select', { id: 'rule-drawer-revoke', class: 'drawer-input', onchange: (event) => revokeId.set(event.target.value) }, h('option', { value: '', text: 'Choose a rule' }), ...current(r.d).map((rule) => h('option', { value: rule.ruleId, text: `${describeRule(rule, nameOf(r.d))} (${rule.ruleId})` }))));
|
|
491
|
+
}, () => {
|
|
492
|
+
if (revoking())
|
|
493
|
+
return null;
|
|
494
|
+
const r = ready();
|
|
495
|
+
if (!r)
|
|
496
|
+
return h('p', { class: 'drawer-muted', text: 'Reading your cost centres…' });
|
|
497
|
+
// A form with nowhere to send spend only fails at Apply; say so first.
|
|
498
|
+
if (!r.d.costCentres.some((c) => c.archivedAtMs == null)) {
|
|
499
|
+
return h('p', { class: 'drawer-note', text: 'Add a cost centre first: a rule needs somewhere to send spend. Open "Cost centres" on this map, or use step 1 of Allocate.' });
|
|
500
|
+
}
|
|
501
|
+
return h('div', { class: 'rule-drawer-fields' }, ...r.fields.children);
|
|
502
|
+
}),
|
|
503
|
+
preview: async () => {
|
|
504
|
+
const { d } = await load();
|
|
505
|
+
const names = nameOf(d);
|
|
506
|
+
const live = current(d);
|
|
507
|
+
if (revoking()) {
|
|
508
|
+
const rule = live.find((r) => r.ruleId === revokeId());
|
|
509
|
+
if (!rule)
|
|
510
|
+
return { applicable: false, blockedReason: live.length === 0 ? 'There are no current rules to revoke.' : 'Choose a rule to revoke.', summary: 'Stops a rule claiming spend from now on.' };
|
|
511
|
+
return {
|
|
512
|
+
applicable: !d.demo,
|
|
513
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : undefined,
|
|
514
|
+
summary: isPrecise()
|
|
515
|
+
? 'Sets revoked_at_ms on every version of the rule. Recorded runs keep the splits computed under it.'
|
|
516
|
+
: 'Stops this rule claiming spend. Splits already recorded keep their results, and the rule stays listed as revoked.',
|
|
517
|
+
rows: [
|
|
518
|
+
{ label: 'Rule', value: describeRule(rule, names), note: `${rule.ruleId} v${rule.version}` },
|
|
519
|
+
{ label: 'Its spend goes to', value: 'the next rule that matches, or unallocated' },
|
|
520
|
+
{ label: 'Reversible', value: 'not as such', note: 'saving the same rule again starts a new version' },
|
|
521
|
+
],
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const x = draft();
|
|
525
|
+
if (x === null)
|
|
526
|
+
return { applicable: false, blockedReason: 'Reading your cost centres…', summary: 'Decides which cost centre carries which spend.' };
|
|
527
|
+
if (typeof x === 'string')
|
|
528
|
+
return { applicable: false, blockedReason: x, summary: 'Decides which cost centre carries which spend.' };
|
|
529
|
+
const existing = live.find((r) => r.ruleId === x.id) ?? null;
|
|
530
|
+
return {
|
|
531
|
+
applicable: !d.demo,
|
|
532
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : undefined,
|
|
533
|
+
summary: isPrecise()
|
|
534
|
+
? 'Saves an allocation_rules version. Recorded runs are unchanged; new previews use effective dates.'
|
|
535
|
+
: 'Saves this rule on this machine. Splits already recorded are not changed; the next preview uses it.',
|
|
536
|
+
rows: [
|
|
537
|
+
{ label: 'The rule', value: describeRule(x.rule, names) },
|
|
538
|
+
{ label: 'Saved as', value: x.id, note: existing ? `version ${existing.version + 1}; version ${existing.version} is kept and closed when this one starts` : 'a new rule' },
|
|
539
|
+
{ label: 'Checked in order', value: x.priority === null ? '100' : String(x.priority), note: 'lowest number first; the first rule that matches takes the request' },
|
|
540
|
+
{ label: 'Applies', value: x.from || x.until ? `${x.from ? `from ${x.from}` : 'from the start'}${x.until ? ` until ${x.until}` : ''}` : 'to all of your history' },
|
|
541
|
+
...(x.owner ? [{ label: 'Owner', value: x.owner }] : []),
|
|
542
|
+
...(x.note ? [{ label: 'Note', value: x.note }] : []),
|
|
543
|
+
],
|
|
544
|
+
};
|
|
545
|
+
},
|
|
546
|
+
commit: async () => {
|
|
547
|
+
if (revoking()) {
|
|
548
|
+
const r = await api.write.allocation({ op: 'revoke', id: revokeId() });
|
|
549
|
+
read = null;
|
|
550
|
+
void load();
|
|
551
|
+
if (r.ok)
|
|
552
|
+
ledgerChanged('allocation');
|
|
553
|
+
return { ok: r.ok, message: r.message };
|
|
554
|
+
}
|
|
555
|
+
const x = draft();
|
|
556
|
+
if (!x || typeof x === 'string')
|
|
557
|
+
return { ok: false, message: typeof x === 'string' ? x : 'Nothing to save yet.' };
|
|
558
|
+
const r = await api.write.allocation(x.body);
|
|
559
|
+
read = null;
|
|
560
|
+
if (r.ok)
|
|
561
|
+
ledgerChanged('allocation');
|
|
562
|
+
return { ok: r.ok, message: isPrecise() ? r.message : 'Rule saved. Splits already recorded keep their results; the next preview uses this rule.' };
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
/**
|
|
567
|
+
* Run an allocation: `segreant alloc run --from --to [--apply]`. The preview
|
|
568
|
+
* is the server's own computation for the period, refused if it does not
|
|
569
|
+
* conserve its input; Apply records exactly that run.
|
|
570
|
+
*/
|
|
571
|
+
'alloc-run': (cap) => {
|
|
572
|
+
const ranges = [monthRange(-1), monthRange(0)];
|
|
573
|
+
const CUSTOM = String(ranges.length);
|
|
574
|
+
const chosen = signal('0');
|
|
575
|
+
const custom = signal(false);
|
|
576
|
+
const first = signal('');
|
|
577
|
+
const last = signal('');
|
|
578
|
+
let read = null;
|
|
579
|
+
const allocation = () => (read ??= api.allocation());
|
|
580
|
+
/** The period the fields name, or the reason they name none. */
|
|
581
|
+
const period = () => {
|
|
582
|
+
if (chosen() !== CUSTOM)
|
|
583
|
+
return ranges[Number(chosen())];
|
|
584
|
+
if (!first() || !last())
|
|
585
|
+
return 'Choose both the first and the last day of the period.';
|
|
586
|
+
if (last() < first())
|
|
587
|
+
return 'The last day must not be before the first day.';
|
|
588
|
+
// Both days are included: the period ends at the local midnight after the last one.
|
|
589
|
+
return { from: localMidnightIso(first()), to: dayAfterIso(last()) };
|
|
590
|
+
};
|
|
591
|
+
return {
|
|
592
|
+
capability: cap,
|
|
593
|
+
command: () => {
|
|
594
|
+
const p = period();
|
|
595
|
+
return typeof p === 'string' ? null : `segreant alloc run --from ${p.from} --to ${p.to} --apply`;
|
|
596
|
+
},
|
|
597
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'run-drawer-period' }, 'Period'), h('select', {
|
|
598
|
+
id: 'run-drawer-period', class: 'drawer-input',
|
|
599
|
+
onchange: (event) => {
|
|
600
|
+
const value = event.target.value;
|
|
601
|
+
chosen.set(value);
|
|
602
|
+
if (custom() !== (value === CUSTOM))
|
|
603
|
+
custom.set(value === CUSTOM);
|
|
604
|
+
},
|
|
605
|
+
}, ...ranges.map((r, i) => h('option', { value: String(i), text: r.label })), h('option', { value: CUSTOM, text: 'Choose dates…' })), () => custom()
|
|
606
|
+
? h('div', null, h('label', { class: 'drawer-h3', for: 'run-drawer-first' }, 'First day'), h('input', { id: 'run-drawer-first', class: 'drawer-input', type: 'date', value: first.peek(), oninput: (event) => first.set(event.target.value) }), h('label', { class: 'drawer-h3', for: 'run-drawer-last' }, 'Last day (included)'), h('input', { id: 'run-drawer-last', class: 'drawer-input', type: 'date', value: last.peek(), oninput: (event) => last.set(event.target.value) }))
|
|
607
|
+
: null),
|
|
608
|
+
preview: async () => {
|
|
609
|
+
const p = period();
|
|
610
|
+
if (typeof p === 'string')
|
|
611
|
+
return { applicable: false, blockedReason: p, summary: 'Splits the spend in a period across your cost centres, by your rules.' };
|
|
612
|
+
const [d, r] = await Promise.all([allocation(), api.write.allocation({ op: 'run', from: p.from, to: p.to })]);
|
|
613
|
+
const result = r.result;
|
|
614
|
+
if (!result)
|
|
615
|
+
return { applicable: false, blockedReason: 'The server returned no split for this period.', summary: r.message };
|
|
616
|
+
const nameOf = (id) => d.costCentres.find((c) => c.costCentreId === id)?.name ?? id;
|
|
617
|
+
const shareOf = (m) => (result.totalMicros > 0 ? share(m / result.totalMicros, 1) : '—');
|
|
618
|
+
const blockedReason = d.demo
|
|
619
|
+
? DEMO_LEDGER_BLOCKED
|
|
620
|
+
: !result.conserves
|
|
621
|
+
? 'This split does not add up to the spend it divides, so it cannot be recorded. Please report it.'
|
|
622
|
+
: undefined;
|
|
623
|
+
return {
|
|
624
|
+
applicable: blockedReason === undefined,
|
|
625
|
+
blockedReason,
|
|
626
|
+
summary: isPrecise()
|
|
627
|
+
? 'Previews alloc run for the period. Apply appends an immutable allocation run; recorded runs are never recomputed.'
|
|
628
|
+
: 'This is how the estimated spend in the period would be split. Recording it saves this split; it is never recalculated later.',
|
|
629
|
+
rows: [
|
|
630
|
+
...result.byCostCentre.map((c) => ({ label: nameOf(c.costCentreId), value: usdFromMicros(c.allocatedMicros), note: `${shareOf(c.allocatedMicros)} · ${count(c.requestCount)} requests` })),
|
|
631
|
+
...result.unallocated.map((u) => ({ label: `Unallocated: ${REASON_WORDS[u.reason] ?? u.reason.replace(/_/g, ' ')}`, value: usdFromMicros(u.micros), note: `${shareOf(u.micros)} · ${count(u.requestCount)} requests` })),
|
|
632
|
+
{ label: 'Total estimated spend', value: usdFromMicros(result.totalMicros), note: result.totalMicros > 0 ? 'list cost (estimate), not the provider bill' : 'no spend in this period' },
|
|
633
|
+
],
|
|
634
|
+
notes: [
|
|
635
|
+
...(result.retention?.truncated ? ['Some records in this period were deleted by retention. The split covers what remains, so the unallocated part may be understated.'] : []),
|
|
636
|
+
'Recorded splits are listed on the Allocate page, where each one downloads as CSV.',
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
commit: async () => {
|
|
641
|
+
const p = period();
|
|
642
|
+
if (typeof p === 'string')
|
|
643
|
+
return { ok: false, message: p };
|
|
644
|
+
const r = await api.write.allocation({ op: 'run', from: p.from, to: p.to, apply: true });
|
|
645
|
+
read = null;
|
|
646
|
+
if (r.ok)
|
|
647
|
+
ledgerChanged('allocation');
|
|
648
|
+
return {
|
|
649
|
+
ok: r.ok,
|
|
650
|
+
message: isPrecise() && r.allocationRunId
|
|
651
|
+
? `Recorded allocation run ${r.allocationRunId}.`
|
|
652
|
+
: 'Recorded. The split is saved and listed under Saved splits on the Allocate page.',
|
|
653
|
+
};
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
},
|
|
657
|
+
/**
|
|
658
|
+
* Adopt an imported export as the provider side: `segreant billing
|
|
659
|
+
* openai-costs adopt --import-id <id> [--apply]`. No credential, no network;
|
|
660
|
+
* the preview is the server's adoption plan, refusals included.
|
|
661
|
+
*/
|
|
662
|
+
'billing-adopt': (cap) => {
|
|
663
|
+
const chosen = signal('');
|
|
664
|
+
let read = null;
|
|
665
|
+
const billing = () => (read ??= api.billing());
|
|
666
|
+
return {
|
|
667
|
+
capability: cap,
|
|
668
|
+
command: () => (chosen() ? `segreant billing openai-costs adopt --import-id ${chosen()} --apply` : null),
|
|
669
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'adopt-drawer-import' }, 'Imported cost export'), h('select', { id: 'adopt-drawer-import', class: 'drawer-input', onchange: (event) => chosen.set(event.target.value) },
|
|
670
|
+
// Built once; the imports are appended when they arrive, so a reader
|
|
671
|
+
// already on the select keeps focus and their place.
|
|
672
|
+
(() => {
|
|
673
|
+
const placeholder = h('option', { value: '', text: 'Choose an export' });
|
|
674
|
+
void billing().then((d) => {
|
|
675
|
+
placeholder.after(...d.imports.map((imp) => h('option', {
|
|
676
|
+
value: imp.importId,
|
|
677
|
+
text: `${imp.fileName} · ${dayRange(imp.periodStartMs, imp.periodEndMs)}${imp.inUse ? ' · in use' : ''}`,
|
|
678
|
+
})));
|
|
679
|
+
}, () => undefined);
|
|
680
|
+
return placeholder;
|
|
681
|
+
})())),
|
|
682
|
+
preview: async () => {
|
|
683
|
+
const d = await billing();
|
|
684
|
+
const summary = isPrecise()
|
|
685
|
+
? 'Adopts an imported operator export as an operator_supplied observation for the declared scope. No credential is read and no request is made.'
|
|
686
|
+
: 'Uses a cost export you already imported as the provider side of the comparison. Nothing is fetched and no key is used.';
|
|
687
|
+
if (!d.activeScope)
|
|
688
|
+
return { applicable: false, blockedReason: 'Declare the OpenAI organisation and project first, so the export’s lines can be matched to it.', summary };
|
|
689
|
+
if (d.imports.length === 0)
|
|
690
|
+
return { applicable: false, blockedReason: 'No cost export has been imported yet. Import one on the Close page first.', summary };
|
|
691
|
+
if (!chosen())
|
|
692
|
+
return { applicable: false, blockedReason: 'Choose an export to see what would be used.', summary };
|
|
693
|
+
const p = (await api.write.billing({ op: 'adopt', importId: chosen() })).plan;
|
|
694
|
+
if (!p)
|
|
695
|
+
return { applicable: false, blockedReason: 'The server returned no plan for this export.', summary };
|
|
696
|
+
if (!p.adoptable)
|
|
697
|
+
return { applicable: false, blockedReason: p.detail ?? (p.refusal ? p.refusal.replace(/_/g, ' ') : 'This export cannot be used.'), summary };
|
|
698
|
+
const left = p.excluded && p.excluded.otherOrNoProjectRecordCount > 0
|
|
699
|
+
? { value: `${count(p.excluded.otherOrNoProjectRecordCount)} lines, ${usdFromMicros(p.excluded.otherOrNoProjectMicros)}`, note: 'no project, or another one' }
|
|
700
|
+
: { value: 'none' };
|
|
701
|
+
return {
|
|
702
|
+
applicable: !d.demo,
|
|
703
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : undefined,
|
|
704
|
+
summary,
|
|
705
|
+
rows: [
|
|
706
|
+
{ label: 'Matched to your project', value: `${count(p.matchedRecordCount)} lines, ${usdFromMicros(p.matchedMicros)}`, note: p.providerProjectRef },
|
|
707
|
+
{ label: 'Left out', ...left },
|
|
708
|
+
{ label: 'Days', value: dayRange(p.periodStartMs, p.periodEndMs) },
|
|
709
|
+
{ label: 'Evidence class', value: 'supplied by you', note: 'not read from the provider; every comparison built on it says so' },
|
|
710
|
+
],
|
|
711
|
+
};
|
|
712
|
+
},
|
|
713
|
+
commit: async () => {
|
|
714
|
+
const r = await api.write.billing({ op: 'adopt', importId: chosen(), apply: true });
|
|
715
|
+
read = null;
|
|
716
|
+
if (r.ok)
|
|
717
|
+
ledgerChanged('billing');
|
|
718
|
+
return {
|
|
719
|
+
ok: r.ok,
|
|
720
|
+
message: isPrecise() && r.observationRunId
|
|
721
|
+
? `Adopted as observation ${r.observationRunId}.`
|
|
722
|
+
: 'Recorded as the provider side for these days. You can now compare on the Close page or in the Reconcile drawer.',
|
|
723
|
+
};
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
},
|
|
727
|
+
/**
|
|
728
|
+
* Reconcile: `segreant billing reconcile [--materiality <usd>] [--apply]`.
|
|
729
|
+
* Readiness is checked first and its missing steps named; the comparison
|
|
730
|
+
* previewed is the server's own, under the threshold Apply records with.
|
|
731
|
+
*/
|
|
732
|
+
'billing-reconcile': (cap) => {
|
|
733
|
+
const materiality = signal('');
|
|
734
|
+
let read = null;
|
|
735
|
+
const billing = () => (read ??= api.billing());
|
|
736
|
+
/** Blank is the server's default; NaN is a value nobody can record under. */
|
|
737
|
+
const threshold = () => {
|
|
738
|
+
const raw = materiality().trim();
|
|
739
|
+
if (raw === '')
|
|
740
|
+
return undefined;
|
|
741
|
+
const n = Number(raw);
|
|
742
|
+
return Number.isFinite(n) && n >= 0 ? n : Number.NaN;
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
capability: cap,
|
|
746
|
+
command: () => {
|
|
747
|
+
const m = threshold();
|
|
748
|
+
if (m !== undefined && Number.isNaN(m))
|
|
749
|
+
return null;
|
|
750
|
+
return `segreant billing reconcile${m === undefined ? '' : ` --materiality ${m}`} --apply`;
|
|
751
|
+
},
|
|
752
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'reconcile-drawer-materiality' }, 'Ignore daily differences up to ($)'), h('input', {
|
|
753
|
+
id: 'reconcile-drawer-materiality', class: 'drawer-input', type: 'number', min: '0', step: '0.01', inputmode: 'decimal', placeholder: '0.50',
|
|
754
|
+
'aria-describedby': 'reconcile-drawer-note',
|
|
755
|
+
oninput: (event) => materiality.set(event.target.value),
|
|
756
|
+
}), h('p', { id: 'reconcile-drawer-note', class: 'drawer-note', text: 'A day counts as different only when the bill and our measurement differ by more than this. Blank uses $0.50.' })),
|
|
757
|
+
preview: async () => {
|
|
758
|
+
const d = await billing();
|
|
759
|
+
if (d.readiness && !d.readiness.ready) {
|
|
760
|
+
return {
|
|
761
|
+
applicable: false,
|
|
762
|
+
blockedReason: 'Not ready to compare yet. The preview lists the steps still to do.',
|
|
763
|
+
summary: 'Compares what Segreant measured with what the provider billed, day by day, for the declared project.',
|
|
764
|
+
rows: d.readiness.missing
|
|
765
|
+
.filter((m) => !m.step.startsWith('observe a closed period — route A'))
|
|
766
|
+
.map((m) => {
|
|
767
|
+
// The server's detail is the terminal spelling. In plain words,
|
|
768
|
+
// point at where the dashboard does the same step.
|
|
769
|
+
const where = /scope/.test(m.step)
|
|
770
|
+
? 'Close step 1, or "Declare a provider scope" on this map'
|
|
771
|
+
: /adopt|import/.test(m.step)
|
|
772
|
+
? 'Close step 2, or "Adopt a provider export" on this map'
|
|
773
|
+
: m.detail;
|
|
774
|
+
return { label: 'Still to do', value: m.step.replace(/^observe a closed period — route B, /, ''), note: isPrecise() ? m.detail : where };
|
|
775
|
+
}),
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
const m = threshold();
|
|
779
|
+
if (m !== undefined && Number.isNaN(m)) {
|
|
780
|
+
return { applicable: false, blockedReason: 'That is not a usable amount. Enter dollars, or leave it blank for $0.50.', summary: 'Compares what Segreant measured with what the provider billed.' };
|
|
781
|
+
}
|
|
782
|
+
const r = (await api.write.billing({ op: 'reconcile', ...(m === undefined ? {} : { materialityUsd: m }) })).result;
|
|
783
|
+
if (!r)
|
|
784
|
+
return { applicable: false, blockedReason: 'The server returned no comparison.', summary: 'Compares what Segreant measured with what the provider billed.' };
|
|
785
|
+
const residual = r.unexplainedVarianceMicros;
|
|
786
|
+
const contradicted = r.snapshotStability === 'changed_across_observations';
|
|
787
|
+
return {
|
|
788
|
+
applicable: !d.demo,
|
|
789
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : undefined,
|
|
790
|
+
summary: isPrecise()
|
|
791
|
+
? 'Previews billing reconcile for the declared scope. Apply appends a reconciliation run; the Billed claim then rests on it.'
|
|
792
|
+
: 'This is how the provider’s bill compares with what Segreant measured. Recording it saves the comparison as evidence.',
|
|
793
|
+
rows: [
|
|
794
|
+
{ label: 'Period', value: dayRange(r.periodStartMs, r.periodEndMs) },
|
|
795
|
+
{ label: 'Billed by the provider', value: usdFromMicros(r.providerReportedMicros) },
|
|
796
|
+
{ label: 'Measured by Segreant', value: usdFromMicros(r.localCapturedMicros), note: 'list cost (estimate)' },
|
|
797
|
+
{ label: 'Not explained', value: usdFromMicros(residual), note: r.providerReportedMicros > 0 ? `${share(residual / r.providerReportedMicros, 1)} of the bill` : undefined },
|
|
798
|
+
{ label: 'Days compared', value: `${count(r.coverage.daysWithBoth)} with both`, note: `${count(r.coverage.providerOnlyDays)} bill only · ${count(r.coverage.localOnlyDays)} ours only` },
|
|
799
|
+
{ label: 'Days that differ', value: count(r.coverage.materialDays), note: `by more than ${usd(r.materialityUsd)}` },
|
|
800
|
+
{ label: 'Provider side', value: basisWords(r.providerSourceKind) },
|
|
801
|
+
],
|
|
802
|
+
notes: [
|
|
803
|
+
residualBoundWords(r.offPathBound, isPrecise()),
|
|
804
|
+
...[quantizationWords(r.localQuantization, isPrecise())].filter((q) => q !== null),
|
|
805
|
+
...(contradicted ? ['The provider reported different figures for the same days at different times. This comparison would be contradicted.'] : []),
|
|
806
|
+
...r.conditions.map((c) => c.replace(/_/g, ' ')),
|
|
807
|
+
],
|
|
808
|
+
};
|
|
809
|
+
},
|
|
810
|
+
commit: async () => {
|
|
811
|
+
const m = threshold();
|
|
812
|
+
if (m !== undefined && Number.isNaN(m))
|
|
813
|
+
return { ok: false, message: 'That is not a usable amount, so nothing was recorded.' };
|
|
814
|
+
const r = await api.write.billing({ op: 'reconcile', ...(m === undefined ? {} : { materialityUsd: m }), apply: true });
|
|
815
|
+
read = null;
|
|
816
|
+
if (r.ok)
|
|
817
|
+
ledgerChanged('billing');
|
|
818
|
+
return {
|
|
819
|
+
ok: r.ok,
|
|
820
|
+
message: isPrecise() && r.reconciliationRunId
|
|
821
|
+
? `Recorded reconciliation run ${r.reconciliationRunId}.`
|
|
822
|
+
: 'Recorded. The comparison is saved, and the Billed figure on the Close page now rests on it.',
|
|
823
|
+
};
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
/**
|
|
828
|
+
* Declare or clear the OpenAI organisation and project: `segreant billing
|
|
829
|
+
* scope set` and `clear`. The preview is the server's own dry run, so what
|
|
830
|
+
* the drawer shows is what the declaration would be, not a client guess.
|
|
831
|
+
*/
|
|
832
|
+
'billing-scope': (cap) => {
|
|
833
|
+
const clear = signal(false);
|
|
834
|
+
const account = signal('');
|
|
835
|
+
const project = signal('');
|
|
836
|
+
let read = null;
|
|
837
|
+
const billing = () => (read ??= api.billing());
|
|
838
|
+
return {
|
|
839
|
+
capability: cap,
|
|
840
|
+
command: () => quoted(() => (clear()
|
|
841
|
+
? 'segreant billing scope clear --apply'
|
|
842
|
+
: account().trim()
|
|
843
|
+
? `segreant billing scope set --account-ref ${shellWord(account().trim())}${project().trim() ? ` --project-ref ${shellWord(project().trim())}` : ''} --apply`
|
|
844
|
+
: null)),
|
|
845
|
+
fields: () => h('div', null, h('label', { class: 'drawer-h3', for: 'scope-change' }, 'Change'), h('select', { id: 'scope-change', class: 'drawer-input', onchange: (event) => clear.set(event.target.value === 'clear') }, h('option', { value: 'set', text: 'Declare an organisation and project', selected: true }), h('option', { value: 'clear', text: 'Clear the declaration' })), () => clear()
|
|
846
|
+
? null
|
|
847
|
+
: h('div', null, h('label', { class: 'drawer-h3', for: 'scope-drawer-account' }, 'Organisation id'), h('input', {
|
|
848
|
+
id: 'scope-drawer-account', class: 'drawer-input', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'org_…',
|
|
849
|
+
// Rebuilt when the change switches back from Clear: show what Apply would send.
|
|
850
|
+
value: account.peek(),
|
|
851
|
+
oninput: (event) => account.set(event.target.value),
|
|
852
|
+
}), h('label', { class: 'drawer-h3', for: 'scope-drawer-project' }, 'Project id (optional)'), h('input', {
|
|
853
|
+
id: 'scope-drawer-project', class: 'drawer-input', maxlength: '200', autocomplete: 'off', spellcheck: 'false', placeholder: 'proj_…',
|
|
854
|
+
value: project.peek(),
|
|
855
|
+
'aria-describedby': 'scope-drawer-note',
|
|
856
|
+
oninput: (event) => project.set(event.target.value),
|
|
857
|
+
}), h('p', { id: 'scope-drawer-note', class: 'drawer-note', text: 'These are ids, not keys. Segreant never asks for an OpenAI key here.' }))),
|
|
858
|
+
preview: async () => {
|
|
859
|
+
const d = await billing();
|
|
860
|
+
const active = d.activeScope;
|
|
861
|
+
const current = active
|
|
862
|
+
? { label: 'Declared now', value: active.providerProjectRef ? `${active.billingAccountRef} / ${active.providerProjectRef}` : active.billingAccountRef, note: `to ${active.upstreamDisplay}` }
|
|
863
|
+
: { label: 'Declared now', value: 'nothing' };
|
|
864
|
+
if (clear()) {
|
|
865
|
+
const r = await api.write.billing({ op: 'scope-clear' });
|
|
866
|
+
return {
|
|
867
|
+
applicable: !d.demo && active != null,
|
|
868
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : active == null ? 'No project is declared, so there is nothing to clear.' : undefined,
|
|
869
|
+
summary: r.message ?? 'Clearing stops matching future OpenAI requests to this project.',
|
|
870
|
+
rows: [current, { label: 'Already recorded', value: 'unchanged', note: 'requests and recorded comparisons keep their figures' }],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const ref = account().trim();
|
|
874
|
+
if (!ref) {
|
|
875
|
+
return {
|
|
876
|
+
applicable: false,
|
|
877
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : 'Enter the organisation id to see the declaration.',
|
|
878
|
+
summary: 'Names the OpenAI organisation, and optionally the project, that requests through Segreant go to from now on.',
|
|
879
|
+
rows: [current],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const r = await api.write.billing({ op: 'scope', accountRef: ref, ...(project().trim() ? { projectRef: project().trim() } : {}) });
|
|
883
|
+
const p = r.scopePreview ?? {};
|
|
884
|
+
return {
|
|
885
|
+
applicable: !d.demo,
|
|
886
|
+
blockedReason: d.demo ? DEMO_LEDGER_BLOCKED : undefined,
|
|
887
|
+
summary: isPrecise()
|
|
888
|
+
? 'Writes an operator_declared_unverified scope. Only future OpenAI proxy rows to the exact configured endpoint are captured.'
|
|
889
|
+
: 'Your declaration, not checked with OpenAI. It applies to OpenAI requests that go through Segreant from now on.',
|
|
890
|
+
rows: [
|
|
891
|
+
current,
|
|
892
|
+
{ label: 'Will declare', value: project().trim() ? `${ref} / ${project().trim()}` : ref, note: typeof p['upstreamDisplay'] === 'string' ? `to ${p['upstreamDisplay']}` : undefined },
|
|
893
|
+
{ label: 'Past and imported spend', value: 'unchanged', note: 'it cannot be matched to the bill' },
|
|
894
|
+
],
|
|
895
|
+
notes: ['Declaring replaces any earlier declaration for future traffic.'],
|
|
896
|
+
};
|
|
897
|
+
},
|
|
898
|
+
commit: async () => {
|
|
899
|
+
const result = clear()
|
|
900
|
+
? await api.write.billing({ op: 'scope-clear', apply: true })
|
|
901
|
+
: await api.write.billing({ op: 'scope', accountRef: account().trim(), ...(project().trim() ? { projectRef: project().trim() } : {}), apply: true });
|
|
902
|
+
read = null;
|
|
903
|
+
if (result.ok)
|
|
904
|
+
ledgerChanged('billing');
|
|
905
|
+
const plain = clear()
|
|
906
|
+
? 'Cleared. Future OpenAI requests through Segreant are no longer matched to a project. Past spend and recorded comparisons are unchanged.'
|
|
907
|
+
: 'Declared for future OpenAI requests through Segreant. Past and imported spend is unchanged, and OpenAI has not checked this declaration.';
|
|
908
|
+
return { ok: result.ok, message: isPrecise() ? result.message ?? plain : plain };
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
},
|
|
208
912
|
export: (cap) => ({
|
|
209
913
|
capability: cap,
|
|
210
914
|
preview: async () => ({
|
|
@@ -238,9 +942,12 @@ const BUILDERS = {
|
|
|
238
942
|
const settings = await api.settings();
|
|
239
943
|
const current = settings.features ?? {};
|
|
240
944
|
const target = turnOn() === 'on';
|
|
945
|
+
const blockedReason = settings.demo
|
|
946
|
+
? DEMO_SETTINGS_BLOCKED
|
|
947
|
+
: current[key()] === target ? 'That switch is already set that way, so nothing would change.' : undefined;
|
|
241
948
|
return {
|
|
242
|
-
applicable:
|
|
243
|
-
blockedReason
|
|
949
|
+
applicable: blockedReason === undefined,
|
|
950
|
+
blockedReason,
|
|
244
951
|
summary: isPrecise()
|
|
245
952
|
? 'Writes one features.<key> boolean to the local config. A switched-off subsystem reports itself disabled wherever its output would appear.'
|
|
246
953
|
: 'Turns one optional part of Segreant on or off on this machine. Anything switched off says so instead of showing empty results.',
|
|
@@ -269,7 +976,9 @@ const BUILDERS = {
|
|
|
269
976
|
{ label: 'Metadata only', value: settings['metadataOnly'] ? 'yes' : 'no', note: 'prompt and response bodies are never stored when on' },
|
|
270
977
|
{
|
|
271
978
|
label: 'Egress mode',
|
|
272
|
-
value: settings.egress.mode === 'local_locked' ? 'local locked'
|
|
979
|
+
value: settings.egress.mode === 'local_locked' ? 'local locked'
|
|
980
|
+
: settings.egress.mode === 'controlled_cloud' ? 'controlled cloud'
|
|
981
|
+
: 'invalid: every cloud request is refused until the egress section of config.json is repaired',
|
|
273
982
|
note: settings.egress.receipts.ok
|
|
274
983
|
? settings.egress.rules.length + ' exact rule(s); ' + settings.egress.receipts.receiptCount + ' receipt(s); chain valid'
|
|
275
984
|
: 'receipt history INVALID; outbound requests refuse before dial until it is repaired. Restore the history; if the lock is stale, confirm no Segreant writer is active, then remove only that lock and rerun verify: ' + (settings.egress.receipts.errors[0] ?? 'history could not be verified'),
|
|
@@ -294,12 +1003,14 @@ export function actionSpec(cap) {
|
|
|
294
1003
|
capability: cap,
|
|
295
1004
|
preview: async () => ({
|
|
296
1005
|
applicable: false,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
1006
|
+
// Say what works now, not what might be built: "not built yet" is a
|
|
1007
|
+
// promise, and a shipped screen that promises is a placeholder (L1).
|
|
1008
|
+
// A command with <placeholders> is not runnable as shown: in a POSIX
|
|
1009
|
+
// shell `<kind>` is an input redirection (PR #134 review).
|
|
1010
|
+
blockedReason: `${cap.coverage === 'planned' ? 'This runs' : 'This part runs'} from the command line. ${cap.command.includes('<') ? 'Copy the command below, replace each part in angle brackets with your own value, then run it in a terminal.' : 'Copy the command below into a terminal.'}`,
|
|
300
1011
|
summary: cap.consequence === 'read'
|
|
301
|
-
? `${cap.plain}
|
|
302
|
-
: `${cap.plain}
|
|
1012
|
+
? `${cap.plain} You read this from the command line, with the command below.`
|
|
1013
|
+
: `${cap.plain} You do this from the command line, with the command below.`,
|
|
303
1014
|
notes: ['The System section lists exactly which capabilities the GUI covers and which it does not.'],
|
|
304
1015
|
}),
|
|
305
1016
|
};
|