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.
Files changed (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. 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
+ }