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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isDemo } from '../config.js';
|
|
2
|
+
import { modelNameError, PriceOverridesError, setPriceOverride } from './overrides.js';
|
|
3
|
+
import { loadPricing, rateFor } from './pricing.js';
|
|
4
|
+
export function declarePrice(model, input) {
|
|
5
|
+
if (isDemo())
|
|
6
|
+
throw new PriceOverridesError('Demo mode only shows sample data; run without --demo to declare a price.');
|
|
7
|
+
if (input.kind === 'like') {
|
|
8
|
+
const error = modelNameError(input.like);
|
|
9
|
+
if (error)
|
|
10
|
+
throw new PriceOverridesError(`--like: ${error}`);
|
|
11
|
+
// A declaration or family guess is insufficient: point directly at a named card entry.
|
|
12
|
+
if (!Object.values(loadPricing().providers).some((p) => Object.hasOwn(p.models, input.like))) {
|
|
13
|
+
throw new PriceOverridesError(`${input.like} is not an exact rate-card match; segreant pricing --coverage lists models seen.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return setPriceOverride(model, input);
|
|
17
|
+
}
|
|
18
|
+
/** Recorded guesses that are still guesses under today's card and declarations. */
|
|
19
|
+
export function unknownModels(store) {
|
|
20
|
+
const recorded = store.unresolvedModels();
|
|
21
|
+
const models = new Map();
|
|
22
|
+
for (const row of recorded) {
|
|
23
|
+
const match = rateFor(row.provider, row.model).pricing.rateMatchKind;
|
|
24
|
+
if (match === 'exact_provider' || match === 'exact_cross_provider' || match === 'user_declared' || match === 'user_declared_free')
|
|
25
|
+
continue;
|
|
26
|
+
const key = JSON.stringify([row.provider, row.model]);
|
|
27
|
+
const prior = models.get(key);
|
|
28
|
+
if (prior) {
|
|
29
|
+
prior.requests += row.requests;
|
|
30
|
+
prior.costUsd += row.costUsd;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
models.set(key, { ...row, rateMatchKind: match });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...models.values()].sort((a, b) => b.costUsd - a.costUsd || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model));
|
|
37
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { addMoney, formatMoneyAmount, money } from '../economics/money.js';
|
|
12
12
|
import { applyExactRate, exactRate } from '../economics/rate.js';
|
|
13
13
|
const RATE_KEYS = new Set(['input', 'output', 'cache_write_5m', 'cache_write_1h', 'cache_read']);
|
|
14
|
-
const USAGE_KEYS = new Set(['inputTokens', 'outputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'cacheWriteTtl', 'reasoningTokens']);
|
|
14
|
+
const USAGE_KEYS = new Set(['inputTokens', 'outputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'cacheWriteTtl', 'cacheWriteTtlObserved', 'reasoningTokens']);
|
|
15
15
|
function assertObject(value, label) {
|
|
16
16
|
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
17
17
|
throw new Error(`${label} must be an object`);
|
|
@@ -78,6 +78,9 @@ function canonicalUsage(value) {
|
|
|
78
78
|
}
|
|
79
79
|
if (value.reasoningTokens !== undefined)
|
|
80
80
|
tokenCount(value.reasoningTokens, 'exact normalized usage reasoningTokens');
|
|
81
|
+
if (value.cacheWriteTtlObserved !== undefined && typeof value.cacheWriteTtlObserved !== 'boolean') {
|
|
82
|
+
throw new Error('exact normalized usage cacheWriteTtlObserved must be a boolean');
|
|
83
|
+
}
|
|
81
84
|
return Object.freeze({
|
|
82
85
|
inputTokens,
|
|
83
86
|
outputTokens,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkpoint, not the clock: the first time Segreant prices a request by a
|
|
3
|
+
* guess (a family match or the generic fallback), it asks the public price list
|
|
4
|
+
* whether the model is known yet. A 30-day refresh left a new model at a guessed
|
|
5
|
+
* price for up to a month; claude-haiku-5-5 was in the public list while
|
|
6
|
+
* Segreant priced it at $3/$15 instead of $0.10/$0.50.
|
|
7
|
+
*
|
|
8
|
+
* The lookup never sits in a request's path: the request is recorded at its
|
|
9
|
+
* provisional price at once, and the lookup runs afterwards. When the list does
|
|
10
|
+
* know the model, recorded rows are NOT rewritten here; the user is told, and
|
|
11
|
+
* `segreant reprice` previews the correction (preview-then-commit).
|
|
12
|
+
*
|
|
13
|
+
* One lookup at a time, and none when the card was checked within the last hour
|
|
14
|
+
* (the check is a conditional GET; the list does not change faster than that).
|
|
15
|
+
*/
|
|
16
|
+
import { pricingStatus, rateFor } from './pricing.js';
|
|
17
|
+
export const FIRST_SIGHT_MIN_INTERVAL_MS = 60 * 60 * 1000;
|
|
18
|
+
const GUESSED = new Set(['family_provider', 'family_cross_provider', 'fallback']);
|
|
19
|
+
/** What the refreshed card itself answers; a price the user declared meanwhile is not the list's answer (PR #146 review). */
|
|
20
|
+
const CARD_EXACT = new Set(['exact_provider', 'exact_cross_provider']);
|
|
21
|
+
/** Whether the card's last check of the price list is older than the interval (or never happened). */
|
|
22
|
+
export function priceCheckDue(status, now = Date.now()) {
|
|
23
|
+
const when = status.lastCheckedAt ?? status.fetchedAt;
|
|
24
|
+
const ms = when === null ? NaN : Date.parse(when);
|
|
25
|
+
return !Number.isFinite(ms) || now - ms >= FIRST_SIGHT_MIN_INTERVAL_MS;
|
|
26
|
+
}
|
|
27
|
+
function defaultLastChecked() {
|
|
28
|
+
const st = pricingStatus();
|
|
29
|
+
const when = st.lastCheckedAt ?? st.fetchedAt;
|
|
30
|
+
const ms = when === null ? NaN : Date.parse(when);
|
|
31
|
+
return Number.isFinite(ms) ? ms : null;
|
|
32
|
+
}
|
|
33
|
+
export function createFirstSightWatcher(options) {
|
|
34
|
+
const now = options.now ?? Date.now;
|
|
35
|
+
const lastChecked = options.lastCheckedMs ?? defaultLastChecked;
|
|
36
|
+
const minInterval = options.minIntervalMs ?? FIRST_SIGHT_MIN_INTERVAL_MS;
|
|
37
|
+
// Models priced by a guess that no lookup has answered yet, by tuple key.
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
let running = null;
|
|
40
|
+
let lastAttemptMs = null;
|
|
41
|
+
const due = () => {
|
|
42
|
+
const t = now();
|
|
43
|
+
if (lastAttemptMs !== null && t - lastAttemptMs < minInterval)
|
|
44
|
+
return false;
|
|
45
|
+
const checked = lastChecked();
|
|
46
|
+
return checked === null || t - checked >= minInterval;
|
|
47
|
+
};
|
|
48
|
+
const run = () => {
|
|
49
|
+
if (running || pending.size === 0 || !due())
|
|
50
|
+
return;
|
|
51
|
+
lastAttemptMs = now();
|
|
52
|
+
running = (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const result = await options.refresh();
|
|
55
|
+
if (!result.ok) {
|
|
56
|
+
options.onFailed?.(result.error ?? 'the price list could not be read');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Every pending model is checked against the refreshed card, including
|
|
60
|
+
// one first seen while this lookup was running: a snapshot taken at the
|
|
61
|
+
// start left it pending for good once the card priced it exactly, since
|
|
62
|
+
// exact requests never call note() again (PR #146 review). Known or not,
|
|
63
|
+
// each has had its answer; one the list does not know is not asked about
|
|
64
|
+
// again until the next interval.
|
|
65
|
+
const answered = [...pending.values()];
|
|
66
|
+
pending.clear();
|
|
67
|
+
const learned = answered.filter(({ provider, model }) => CARD_EXACT.has(rateFor(provider, model).pricing.rateMatchKind));
|
|
68
|
+
if (learned.length > 0)
|
|
69
|
+
options.onLearned?.(learned);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
options.onFailed?.(error instanceof Error ? error.message : String(error));
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
running = null;
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
note(provider, model, rateMatchKind) {
|
|
81
|
+
try {
|
|
82
|
+
if (!GUESSED.has(rateMatchKind) || !options.enabled())
|
|
83
|
+
return;
|
|
84
|
+
const key = JSON.stringify([provider, model]);
|
|
85
|
+
if (!pending.has(key))
|
|
86
|
+
pending.set(key, { provider, model });
|
|
87
|
+
run();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A sighting must never break the request that caused it.
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async idle() {
|
|
94
|
+
while (running)
|
|
95
|
+
await running;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prices the user declared for models no rate card knows: a private or
|
|
3
|
+
* fine-tuned model, a local model that costs nothing, or a new model the public
|
|
4
|
+
* price list has not caught up with yet. A declaration is the user's claim, not
|
|
5
|
+
* a published list price, so it is recorded under its own basis
|
|
6
|
+
* (`user_declared_price`) and never mixed into "list cost".
|
|
7
|
+
*
|
|
8
|
+
* Three forms:
|
|
9
|
+
* - `rate`: decimal USD per 1M tokens, the same units as the rate card;
|
|
10
|
+
* - `like`: price this model at another model's rate on the current card;
|
|
11
|
+
* - `free`: a local model; every request costs zero.
|
|
12
|
+
*
|
|
13
|
+
* The file is written only by `setPriceOverride` (validated, atomic). A hand
|
|
14
|
+
* edit that breaks it is refused with the reason, never half-read: a price that
|
|
15
|
+
* silently disappeared would change what a cap counts.
|
|
16
|
+
*/
|
|
17
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { acquireConfigMutationLock, DemoReadOnlyError, isDemo, segreantHome } from '../config.js';
|
|
20
|
+
export class PriceOverridesError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'PriceOverridesError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A plain non-negative decimal with at most 12 places, e.g. "0.1" or "15". */
|
|
27
|
+
const DECIMAL = /^(0|[1-9]\d{0,6})(\.\d{1,12})?$/;
|
|
28
|
+
const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,199}$/;
|
|
29
|
+
const MAX_MODELS = 500;
|
|
30
|
+
export function overridesPath() {
|
|
31
|
+
return join(segreantHome(), 'pricing', 'overrides.json');
|
|
32
|
+
}
|
|
33
|
+
function validDecimal(value, positive) {
|
|
34
|
+
if (typeof value !== 'string' || !DECIMAL.test(value))
|
|
35
|
+
return false;
|
|
36
|
+
return !positive || Number(value) > 0;
|
|
37
|
+
}
|
|
38
|
+
/** The reason a model name cannot carry a declared price, or null. */
|
|
39
|
+
export function modelNameError(model) {
|
|
40
|
+
if (typeof model !== 'string' || !MODEL_NAME.test(model)) {
|
|
41
|
+
return 'model names are 1-200 characters: letters, digits and . _ : @ / + -, starting with a letter or digit';
|
|
42
|
+
}
|
|
43
|
+
if (model === '__proto__' || model === 'constructor' || model === 'prototype')
|
|
44
|
+
return 'that model name is reserved';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function overrideError(model, raw, requireDeclaredAt) {
|
|
48
|
+
const nameError = modelNameError(model);
|
|
49
|
+
if (nameError)
|
|
50
|
+
return `${JSON.stringify(model)}: ${nameError}`;
|
|
51
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
52
|
+
return `${model}: must be an object`;
|
|
53
|
+
const o = raw;
|
|
54
|
+
if (requireDeclaredAt && (typeof o['declaredAt'] !== 'string' || Number.isNaN(Date.parse(o['declaredAt'])))) {
|
|
55
|
+
return `${model}: declaredAt must be a timestamp`;
|
|
56
|
+
}
|
|
57
|
+
const allowed = new Set(['kind', 'declaredAt']);
|
|
58
|
+
switch (o['kind']) {
|
|
59
|
+
case 'rate':
|
|
60
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h'])
|
|
61
|
+
allowed.add(k);
|
|
62
|
+
// Input and output must be positive: a zero there is what `free` is for,
|
|
63
|
+
// and a typo'd zero would silently stop a cap from counting this model.
|
|
64
|
+
if (!validDecimal(o['input'], true))
|
|
65
|
+
return `${model}: input must be a positive decimal (USD per 1M tokens)`;
|
|
66
|
+
if (!validDecimal(o['output'], true))
|
|
67
|
+
return `${model}: output must be a positive decimal (USD per 1M tokens)`;
|
|
68
|
+
if (o['cacheRead'] !== undefined && !validDecimal(o['cacheRead'], true))
|
|
69
|
+
return `${model}: cacheRead must be a positive decimal`;
|
|
70
|
+
if (o['cacheWrite'] !== undefined && !validDecimal(o['cacheWrite'], true))
|
|
71
|
+
return `${model}: cacheWrite must be a positive decimal`;
|
|
72
|
+
if (o['cacheWrite1h'] !== undefined && !validDecimal(o['cacheWrite1h'], true))
|
|
73
|
+
return `${model}: cacheWrite1h must be a positive decimal`;
|
|
74
|
+
break;
|
|
75
|
+
case 'like':
|
|
76
|
+
allowed.add('like');
|
|
77
|
+
if (modelNameError(o['like']) !== null)
|
|
78
|
+
return `${model}: like must name another model`;
|
|
79
|
+
if (o['like'] === model)
|
|
80
|
+
return `${model}: cannot be priced like itself`;
|
|
81
|
+
break;
|
|
82
|
+
case 'free':
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
return `${model}: kind must be rate, like or free`;
|
|
86
|
+
}
|
|
87
|
+
for (const k of Object.keys(o))
|
|
88
|
+
if (!allowed.has(k))
|
|
89
|
+
return `${model}: unknown field ${k}`;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** The first problem with a parsed overrides file, or null when it is valid. */
|
|
93
|
+
export function validatePriceOverrides(raw) {
|
|
94
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
95
|
+
return 'the file is not a JSON object';
|
|
96
|
+
const o = raw;
|
|
97
|
+
if (o['version'] !== 1)
|
|
98
|
+
return 'version must be 1';
|
|
99
|
+
const models = o['models'];
|
|
100
|
+
if (!models || typeof models !== 'object' || Array.isArray(models))
|
|
101
|
+
return 'models must be an object';
|
|
102
|
+
const entries = Object.entries(models);
|
|
103
|
+
if (entries.length > MAX_MODELS)
|
|
104
|
+
return `at most ${MAX_MODELS} declared prices`;
|
|
105
|
+
for (const [model, entry] of entries) {
|
|
106
|
+
const error = overrideError(model, entry, true);
|
|
107
|
+
if (error)
|
|
108
|
+
return error;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
let cache = null;
|
|
113
|
+
/**
|
|
114
|
+
* The declared prices, re-read when the file's content changes. Throws
|
|
115
|
+
* PriceOverridesError when the file is broken or cannot be read.
|
|
116
|
+
*
|
|
117
|
+
* Only a missing file means "no declarations". A path the process cannot
|
|
118
|
+
* reach (a directory that lost search permission, an I/O error) used to read
|
|
119
|
+
* as absent, so a negotiated price silently dropped out of accounting and the
|
|
120
|
+
* cap (PR #146 review). The cache is keyed on the text itself, not on mtime and
|
|
121
|
+
* size: an edit that keeps both, such as a restored backup, is still seen.
|
|
122
|
+
*/
|
|
123
|
+
export function loadPriceOverrides() {
|
|
124
|
+
const path = overridesPath();
|
|
125
|
+
let text;
|
|
126
|
+
try {
|
|
127
|
+
text = readFileSync(path, 'utf8');
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error?.code === 'ENOENT') {
|
|
131
|
+
cache = null;
|
|
132
|
+
return { version: 1, models: {} };
|
|
133
|
+
}
|
|
134
|
+
throw new PriceOverridesError(`${path} could not be read (${error?.code ?? String(error)}); check its permissions, or remove it`);
|
|
135
|
+
}
|
|
136
|
+
if (cache && cache.path === path && cache.text === text)
|
|
137
|
+
return cache.file;
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(text.replace(/^/, ''));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw new PriceOverridesError(`${path} is not valid JSON; fix it or remove it (segreant pricing unset <model> rewrites it)`);
|
|
144
|
+
}
|
|
145
|
+
const error = validatePriceOverrides(parsed);
|
|
146
|
+
if (error)
|
|
147
|
+
throw new PriceOverridesError(`${path}: ${error}`);
|
|
148
|
+
const file = parsed;
|
|
149
|
+
// A null-prototype copy: a model named like an Object method can never resolve to one.
|
|
150
|
+
const models = Object.create(null);
|
|
151
|
+
for (const [model, entry] of Object.entries(file.models))
|
|
152
|
+
models[model] = entry;
|
|
153
|
+
const safe = { version: 1, models };
|
|
154
|
+
cache = { path, text, file: safe };
|
|
155
|
+
return safe;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The declarations that price requests: none while showing sample data. The
|
|
159
|
+
* demo is advertised as isolated, so a real negotiated rate or private model
|
|
160
|
+
* name must never change or appear in it (PR #146 review).
|
|
161
|
+
*/
|
|
162
|
+
export function activePriceOverrides() {
|
|
163
|
+
return isDemo() ? Object.create(null) : loadPriceOverrides().models;
|
|
164
|
+
}
|
|
165
|
+
export function priceOverrideFor(model) {
|
|
166
|
+
return activePriceOverrides()[model] ?? null;
|
|
167
|
+
}
|
|
168
|
+
function writeOverrides(file) {
|
|
169
|
+
const path = overridesPath();
|
|
170
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
171
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
172
|
+
const sorted = {};
|
|
173
|
+
for (const model of Object.keys(file.models).sort())
|
|
174
|
+
sorted[model] = file.models[model];
|
|
175
|
+
// Private like the config and backups: model names and negotiated rates are
|
|
176
|
+
// not for other local users (PR #146 review). chmod covers a leftover temp
|
|
177
|
+
// file, which writeFileSync's mode would not narrow.
|
|
178
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, models: sorted }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
179
|
+
chmodSync(tmp, 0o600);
|
|
180
|
+
renameSync(tmp, path);
|
|
181
|
+
cache = null;
|
|
182
|
+
}
|
|
183
|
+
/** Validate and store one declaration. Throws PriceOverridesError with the reason when it is not valid. */
|
|
184
|
+
/**
|
|
185
|
+
* One read-modify-write at a time, across processes: two writers that each
|
|
186
|
+
* loaded the same file would otherwise both succeed while the later rename
|
|
187
|
+
* dropped the other's declaration (PR #146 review). The configuration lock is
|
|
188
|
+
* shared, so a price edit also never interleaves with a config edit; a second
|
|
189
|
+
* writer is refused with the reason rather than waiting or losing data.
|
|
190
|
+
*/
|
|
191
|
+
function withOverridesLock(mutate) {
|
|
192
|
+
if (isDemo())
|
|
193
|
+
throw new DemoReadOnlyError();
|
|
194
|
+
const lock = acquireConfigMutationLock();
|
|
195
|
+
try {
|
|
196
|
+
cache = null;
|
|
197
|
+
return mutate();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
lock.release();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function setPriceOverride(model, input, now = new Date()) {
|
|
204
|
+
const declared = { ...input, declaredAt: now.toISOString() };
|
|
205
|
+
const error = overrideError(model, declared, true);
|
|
206
|
+
if (error)
|
|
207
|
+
throw new PriceOverridesError(error);
|
|
208
|
+
return withOverridesLock(() => {
|
|
209
|
+
const file = loadPriceOverrides();
|
|
210
|
+
const models = { ...file.models, [model]: declared };
|
|
211
|
+
if (Object.keys(models).length > MAX_MODELS)
|
|
212
|
+
throw new PriceOverridesError(`at most ${MAX_MODELS} declared prices`);
|
|
213
|
+
writeOverrides({ version: 1, models });
|
|
214
|
+
return declared;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Remove a declaration. Returns false when there was none. */
|
|
218
|
+
export function removePriceOverride(model) {
|
|
219
|
+
return withOverridesLock(() => {
|
|
220
|
+
const file = loadPriceOverrides();
|
|
221
|
+
if (!(model in file.models))
|
|
222
|
+
return false;
|
|
223
|
+
const models = {};
|
|
224
|
+
for (const [name, entry] of Object.entries(file.models))
|
|
225
|
+
if (name !== model)
|
|
226
|
+
models[name] = entry;
|
|
227
|
+
writeOverrides({ version: 1, models });
|
|
228
|
+
return true;
|
|
229
|
+
});
|
|
230
|
+
}
|