@openplan/dsh-fuse 0.1.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.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Pricing for the fuse and the meter — resolved generically, never hand-maintained.
3
+ *
4
+ * ## Why this module exists at all
5
+ *
6
+ * The harness reports **tokens**, exactly and for free (`TokenUsage`), and models
7
+ * **no monetary cost anywhere** (the only "pricing" in the harness is per-route
8
+ * *image* token pricing). The proposal asks for `cost_usd` and `limitUsd`, so
9
+ * `USD = tokens × price` and this module owns the `price` half.
10
+ *
11
+ * Nobody is invoiced from this number: its jobs are to express the customer's own
12
+ * policy in the unit they wrote it in, and to rank projects/sessions against each
13
+ * other. An approximate price is therefore fine; a MISSING price is fatal — it
14
+ * prices every call at zero, so no budget ever cuts and both gates go inert. That
15
+ * is the defect this resolver exists to prevent.
16
+ *
17
+ * ## Resolution order (measured against real gateways)
18
+ *
19
+ * 1. `pricingAliases[model]` — explicit escape hatch, highest precedence.
20
+ * 2. `${provider}/${model}` — the exact route, from the registry or the gateway's
21
+ * own `/models`. This is the correct key: the same model costs different
22
+ * amounts on different routes (`deepseek/deepseek-v4.1-flash` ranges
23
+ * $0.15–$0.375 per 1M input across registries), so keying on model alone is
24
+ * wrong in principle.
25
+ * 3. bare `model` — the provider is absent from the registry (a private gateway).
26
+ * Falls back to the **consensus** price across every provider that publishes
27
+ * the model: the modal quote, which measurements show is the upstream list
28
+ * price that pass-through resellers charge "at cost".
29
+ * 4. progressively trimmed variants, then `${provider}/${bare}`.
30
+ * 5. `null` — reported as **unpriced**, never silently zero.
31
+ *
32
+ * Verified on this machine's real routes: a pass-through reseller that publishes
33
+ * "at cost" pricing reproduces the registry's numbers to the cent (8/8 models),
34
+ * and the generic resolver prices 68/69 of its catalogue with no per-provider
35
+ * configuration.
36
+ */
37
+ /** Default registry: 200+ providers, `cost` in USD per 1M tokens. */
38
+ export const DEFAULT_REGISTRY_URL = "https://models.dev/api.json";
39
+ /** Lowercase + separator-normalized form used for every lookup. */
40
+ export function normalizeModelId(model) {
41
+ return model.trim().toLowerCase().replace(/[:_]/g, "-").replace(/\s+/g, "-");
42
+ }
43
+ /**
44
+ * Ordered candidate keys for one model id, most specific first.
45
+ *
46
+ * `provider/anthropic/claude-sonnet-5` yields the full id, then each
47
+ * progressively stripped suffix (`anthropic/claude-sonnet-5`,
48
+ * `claude-sonnet-5`) — so a table keyed by a bare model id, by a
49
+ * vendor-qualified id, or by the adapter's own prefixed id all resolve. When
50
+ * the caller knows the provider route, `${provider}/${bare}` is tried too
51
+ * (a table keyed `openai/gpt-4o` still prices a reported `gpt-4o`).
52
+ */
53
+ export function pricingCandidates(model, provider) {
54
+ const out = [];
55
+ const push = (value) => {
56
+ const trimmed = value.trim();
57
+ if (trimmed && !out.includes(trimmed))
58
+ out.push(trimmed);
59
+ };
60
+ push(model);
61
+ const segments = model.split("/").filter(Boolean);
62
+ for (let i = 1; i < segments.length; i += 1) {
63
+ push(segments.slice(i).join("/"));
64
+ }
65
+ const bare = segments[segments.length - 1];
66
+ if (provider && bare)
67
+ push(`${provider}/${bare}`);
68
+ return out;
69
+ }
70
+ /** Bare model id (last path segment), normalized. */
71
+ function bareModelId(model) {
72
+ const segments = model.split("/").filter(Boolean);
73
+ return normalizeModelId(segments[segments.length - 1] ?? model);
74
+ }
75
+ /**
76
+ * Resolve the price entry for one model id.
77
+ *
78
+ * Precedence: explicit alias → exact key → `${provider}/${bare}` → bare model →
79
+ * progressively stripped suffix. Returns `null` when nothing matches, so callers
80
+ * can report the miss (and mark the call unpriced) instead of pricing at zero.
81
+ */
82
+ export function resolvePricingEntry(table, model, opts = {}) {
83
+ if (!table)
84
+ return null;
85
+ const aliasTarget = opts.aliases?.[model];
86
+ if (aliasTarget) {
87
+ const entry = table[aliasTarget];
88
+ if (entry)
89
+ return { entry, key: aliasTarget, via: "alias" };
90
+ }
91
+ // 1. exact reported id (a table may be keyed by the adapter's own id).
92
+ const exact = table[model];
93
+ if (exact)
94
+ return { entry: exact, key: model, via: "exact" };
95
+ const bare = bareModelId(model);
96
+ // 2. the exact route — `provider/model`. The correct key when present.
97
+ if (opts.provider && bare) {
98
+ const routeKey = `${normalizeModelId(opts.provider)}/${bare}`;
99
+ const entry = table[routeKey];
100
+ if (entry)
101
+ return { entry, key: routeKey, via: "route" };
102
+ }
103
+ // 3. bare model — the provider is not in the registry (private gateway).
104
+ const bareEntry = table[bare];
105
+ if (bareEntry)
106
+ return { entry: bareEntry, key: bare, via: "model" };
107
+ // 4. progressively stripped variants, then `${provider}/${bare}`.
108
+ for (const candidate of pricingCandidates(model, opts.provider).slice(1)) {
109
+ const entry = table[candidate];
110
+ if (!entry)
111
+ continue;
112
+ const via = candidate.includes("/") ? "suffix" : "provider";
113
+ return { entry, key: candidate, via };
114
+ }
115
+ return null;
116
+ }
117
+ /**
118
+ * Cost in USD from the resolved table entry (cents per 1M tokens).
119
+ *
120
+ * Cache reads and writes price at the entry's own cache rate when the table
121
+ * publishes one, and at the INPUT rate when it does not — deliberately
122
+ * conservative: the fuse prefers overestimating spend to missing a cut. The
123
+ * harness reports cache counts as DISJOINT from `inputTokens` (`TokenUsage`:
124
+ * "billed input = sum of the three"), so nothing is double counted here, and
125
+ * `reasoningTokens` is deliberately ignored because the docs state it is
126
+ * "already included in `outputTokens`; totals must not add it again".
127
+ *
128
+ * @returns USD cost plus the key that priced it, or `null` when the model is
129
+ * unpriced — this function never invents a price.
130
+ */
131
+ export function estimateCostUsd(table, model, counts, opts = {}) {
132
+ const resolved = resolvePricingEntry(table, model, opts);
133
+ if (!resolved)
134
+ return null;
135
+ const { entry } = resolved;
136
+ const cacheReadCentsPerM = entry.cacheReadCentsPerM ?? entry.inputCentsPerM;
137
+ const cacheWriteCentsPerM = entry.cacheWriteCentsPerM ?? entry.inputCentsPerM;
138
+ const cents = counts.inputTokens * entry.inputCentsPerM +
139
+ counts.outputTokens * entry.outputCentsPerM +
140
+ counts.cacheReadTokens * cacheReadCentsPerM +
141
+ (counts.cacheWriteTokens ?? 0) * cacheWriteCentsPerM;
142
+ return {
143
+ costUsd: cents / 1e6 / 100,
144
+ key: resolved.key,
145
+ via: resolved.via,
146
+ };
147
+ }
148
+ function usdPerMToCents(raw) {
149
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
150
+ return undefined;
151
+ }
152
+ return raw * 100;
153
+ }
154
+ function registryEntry(cost) {
155
+ const input = usdPerMToCents(cost.input);
156
+ const output = usdPerMToCents(cost.output);
157
+ if (input === undefined || output === undefined)
158
+ return null;
159
+ const cacheRead = usdPerMToCents(cost.cache_read);
160
+ const cacheWrite = usdPerMToCents(cost.cache_write);
161
+ return {
162
+ inputCentsPerM: input,
163
+ outputCentsPerM: output,
164
+ ...(cacheRead !== undefined ? { cacheReadCentsPerM: cacheRead } : {}),
165
+ ...(cacheWrite !== undefined ? { cacheWriteCentsPerM: cacheWrite } : {}),
166
+ };
167
+ }
168
+ /**
169
+ * Parse the registry into a table keyed BOTH ways:
170
+ *
171
+ * - `${provider}/${model}` — the exact route (authoritative).
172
+ * - bare `${model}` — the **modal** (most-agreed) quote across every provider,
173
+ * used only when the route is absent. Measurements show this modal value is
174
+ * the upstream list price (e.g. 34 of 39 providers quote exactly $5.00/1M for
175
+ * `gpt-5.5`), which is what a pass-through reseller charges at cost.
176
+ *
177
+ * Every key is normalized so a reported `deepseek/deepseek-v4.1-flash` and a
178
+ * registry `deepseek-v4.1-flash` land on the same lookup.
179
+ */
180
+ export function parsePricingRegistry(json) {
181
+ const table = {};
182
+ if (typeof json !== "object" || json === null) {
183
+ return { table, routes: 0, models: 0 };
184
+ }
185
+ /** bare model → (quote key → {entry, providers}). */
186
+ const quotes = new Map();
187
+ let routes = 0;
188
+ for (const [providerId, providerValue] of Object.entries(json)) {
189
+ if (typeof providerValue !== "object" || providerValue === null)
190
+ continue;
191
+ const models = providerValue.models;
192
+ if (typeof models !== "object" || models === null)
193
+ continue;
194
+ const provider = normalizeModelId(providerId);
195
+ for (const [rawModelId, modelValue] of Object.entries(models)) {
196
+ if (typeof modelValue !== "object" || modelValue === null)
197
+ continue;
198
+ const cost = modelValue.cost;
199
+ if (typeof cost !== "object" || cost === null)
200
+ continue;
201
+ const entry = registryEntry(cost);
202
+ if (!entry)
203
+ continue;
204
+ const bare = bareModelId(rawModelId);
205
+ const routeKey = `${provider}/${bare}`;
206
+ // First writer wins per route: a provider advertising the same model
207
+ // twice (e.g. `x` and `vendor/x`) must not override itself.
208
+ if (!table[routeKey]) {
209
+ table[routeKey] = entry;
210
+ routes += 1;
211
+ }
212
+ const quoteKey = `${entry.inputCentsPerM}/${entry.outputCentsPerM}/${entry.cacheReadCentsPerM ?? ""}`;
213
+ const bucket = quotes.get(bare) ?? new Map();
214
+ const existing = bucket.get(quoteKey);
215
+ if (existing)
216
+ existing.providers += 1;
217
+ else
218
+ bucket.set(quoteKey, { entry, providers: 1 });
219
+ quotes.set(bare, bucket);
220
+ }
221
+ }
222
+ // Modal quote per model — the value most independent providers agree on.
223
+ let models = 0;
224
+ const bareKeys = new Set();
225
+ for (const [bare, bucket] of quotes) {
226
+ let best = null;
227
+ for (const candidate of bucket.values()) {
228
+ if (best === null ||
229
+ candidate.providers > best.providers ||
230
+ (candidate.providers === best.providers &&
231
+ candidate.entry.inputCentsPerM < best.entry.inputCentsPerM)) {
232
+ best = candidate;
233
+ }
234
+ }
235
+ if (!best)
236
+ continue;
237
+ // A bare key must never shadow a real route key of the same name.
238
+ if (!table[bare]) {
239
+ table[bare] = best.entry;
240
+ bareKeys.add(bare);
241
+ models += 1;
242
+ }
243
+ }
244
+ return { table, routes, models };
245
+ }
246
+ // ── Source 2: the gateway's own /models ───────────────────────────────────────
247
+ /** USD per token (string or number) → cents per 1M. */
248
+ function usdPerTokenToCents(raw) {
249
+ const value = typeof raw === "string" ? Number(raw) : raw;
250
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
251
+ return undefined;
252
+ }
253
+ return value * 1e6 * 100;
254
+ }
255
+ /**
256
+ * Normalize one OpenAI-compatible `/models` entry that publishes prices.
257
+ *
258
+ * The convention is real but the shape is not standardized; three layouts cover
259
+ * every publicly-readable gateway measured:
260
+ * - OpenRouter: `pricing.{prompt,completion,input_cache_read,input_cache_write}`
261
+ * in USD **per token** (strings).
262
+ * - DeepInfra: `metadata.pricing.{input_tokens,output_tokens,cache_read_tokens}`
263
+ * in USD **per 1M**.
264
+ * - Novita: `input_token_price_per_m` / `output_token_price_per_m` per 1M.
265
+ * A registry-shaped `cost.{input,output,cache_read,cache_write}` is accepted too.
266
+ */
267
+ export function parseGatewayModelEntry(model) {
268
+ if (typeof model !== "object" || model === null)
269
+ return null;
270
+ const m = model;
271
+ const pricing = m.pricing;
272
+ const cost = m.cost;
273
+ const meta = m.metadata;
274
+ const metaPricing = meta?.pricing;
275
+ // OpenRouter: USD per token.
276
+ if (pricing) {
277
+ const input = usdPerTokenToCents(pricing.prompt);
278
+ const output = usdPerTokenToCents(pricing.completion);
279
+ if (input !== undefined && output !== undefined) {
280
+ const cacheRead = usdPerTokenToCents(pricing.input_cache_read);
281
+ const cacheWrite = usdPerTokenToCents(pricing.input_cache_write);
282
+ return {
283
+ inputCentsPerM: input,
284
+ outputCentsPerM: output,
285
+ ...(cacheRead !== undefined ? { cacheReadCentsPerM: cacheRead } : {}),
286
+ ...(cacheWrite !== undefined
287
+ ? { cacheWriteCentsPerM: cacheWrite }
288
+ : {}),
289
+ };
290
+ }
291
+ }
292
+ // DeepInfra: USD per 1M under metadata.pricing.
293
+ if (metaPricing) {
294
+ const input = usdPerMToCents(metaPricing.input_tokens);
295
+ const output = usdPerMToCents(metaPricing.output_tokens);
296
+ if (input !== undefined && output !== undefined) {
297
+ const cacheRead = usdPerMToCents(metaPricing.cache_read_tokens);
298
+ return {
299
+ inputCentsPerM: input,
300
+ outputCentsPerM: output,
301
+ ...(cacheRead !== undefined ? { cacheReadCentsPerM: cacheRead } : {}),
302
+ };
303
+ }
304
+ }
305
+ // Novita: per-1M fields in $0.0001 units (1500 → $0.15/1M → 15 cents/1M).
306
+ const novitaInputRaw = m.input_token_price_per_m;
307
+ const novitaOutputRaw = m.output_token_price_per_m;
308
+ const novitaInput = typeof novitaInputRaw === "number" && Number.isFinite(novitaInputRaw)
309
+ ? novitaInputRaw / 100
310
+ : undefined;
311
+ const novitaOutput = typeof novitaOutputRaw === "number" && Number.isFinite(novitaOutputRaw)
312
+ ? novitaOutputRaw / 100
313
+ : undefined;
314
+ if (novitaInput !== undefined && novitaOutput !== undefined) {
315
+ return { inputCentsPerM: novitaInput, outputCentsPerM: novitaOutput };
316
+ }
317
+ // Registry-shaped `cost`.
318
+ if (cost)
319
+ return registryEntry(cost);
320
+ return null;
321
+ }
322
+ /**
323
+ * Parse a gateway `/models` response, keyed by `${provider}/${model}`.
324
+ *
325
+ * Returns an empty table when the gateway publishes no prices (e.g. command-code)
326
+ * — the caller then falls back to the registry by model id.
327
+ */
328
+ export function parseGatewayModels(json, provider) {
329
+ const table = {};
330
+ const list = typeof json === "object" && json !== null
331
+ ? (json.data ??
332
+ json.models)
333
+ : undefined;
334
+ if (!Array.isArray(list))
335
+ return { table, priced: 0, total: 0 };
336
+ const route = normalizeModelId(provider);
337
+ let priced = 0;
338
+ for (const model of list) {
339
+ if (typeof model !== "object" || model === null)
340
+ continue;
341
+ const id = model.id;
342
+ if (typeof id !== "string" || !id)
343
+ continue;
344
+ const entry = parseGatewayModelEntry(model);
345
+ if (!entry)
346
+ continue;
347
+ table[`${route}/${bareModelId(id)}`] = entry;
348
+ priced += 1;
349
+ }
350
+ return { table, priced, total: list.length };
351
+ }
352
+ // ── Fetch + cache ─────────────────────────────────────────────────────────────
353
+ /**
354
+ * Fetch + merge every configured price source. Never throws: a failed source
355
+ * keeps whatever the config already carries, and a config override always wins
356
+ * on conflicts.
357
+ *
358
+ * The registry is the primary source (200+ providers, keyed by route, carries
359
+ * cache read/write). A gateway `/models` is consulted first when configured,
360
+ * because a gateway that publishes its own prices is authoritative for its own
361
+ * routes.
362
+ */
363
+ export async function fetchPricingTable(input) {
364
+ const fetchImpl = input.fetchImpl ?? fetch;
365
+ const table = { ...(input.override ?? {}) };
366
+ const failures = [];
367
+ let routes = 0;
368
+ let models = 0;
369
+ let gatewayPriced = 0;
370
+ const headers = () => {
371
+ const envName = input.gatewayApiKeyEnv;
372
+ const key = envName ? process.env[envName] : undefined;
373
+ return key ? { authorization: `Bearer ${key}` } : {};
374
+ };
375
+ // The gateway first: its own numbers are authoritative for its own routes.
376
+ if (input.gatewayUrl) {
377
+ try {
378
+ const res = await fetchImpl(input.gatewayUrl, { headers: headers() });
379
+ if (!res.ok) {
380
+ failures.push(`gateway: status ${res.status}`);
381
+ }
382
+ else {
383
+ const parsed = parseGatewayModels(await res.json(), input.gatewayProvider ?? "gateway");
384
+ for (const [key, entry] of Object.entries(parsed.table)) {
385
+ if (!table[key])
386
+ table[key] = entry;
387
+ }
388
+ gatewayPriced = parsed.priced;
389
+ }
390
+ }
391
+ catch (err) {
392
+ failures.push(`gateway: ${String(err).slice(0, 120)}`);
393
+ }
394
+ }
395
+ // The registry.
396
+ const registryUrl = input.registryUrl === undefined ? DEFAULT_REGISTRY_URL : input.registryUrl;
397
+ if (registryUrl) {
398
+ try {
399
+ const res = await fetchImpl(registryUrl, {
400
+ headers: { accept: "application/json" },
401
+ });
402
+ if (!res.ok) {
403
+ failures.push(`registry: status ${res.status}`);
404
+ }
405
+ else {
406
+ const parsed = parsePricingRegistry(await res.json());
407
+ routes = parsed.routes;
408
+ models = parsed.models;
409
+ for (const [key, entry] of Object.entries(parsed.table)) {
410
+ if (!table[key])
411
+ table[key] = entry;
412
+ }
413
+ }
414
+ }
415
+ catch (err) {
416
+ failures.push(`registry: ${String(err).slice(0, 120)}`);
417
+ }
418
+ }
419
+ return { table, failures, routes, models, gatewayPriced };
420
+ }
421
+ /**
422
+ * TTL cache for the sync (refresh hourly; the first call inside the plugin is
423
+ * non-blocking — the fuse starts on the config table and upgrades when the
424
+ * fetch lands). `onUpdate` lets the caller persist the table for offline use.
425
+ */
426
+ export function createPricingCache(fetchTable, ttlMs = 3_600_000, onUpdate) {
427
+ let cached = {};
428
+ let fetchedAt = 0;
429
+ let inFlight = null;
430
+ return {
431
+ current: () => cached,
432
+ hydrate: (table) => {
433
+ cached = table;
434
+ },
435
+ refresh: () => {
436
+ if (inFlight || Date.now() - fetchedAt < ttlMs)
437
+ return;
438
+ inFlight = fetchTable()
439
+ .then(({ table }) => {
440
+ cached = table;
441
+ fetchedAt = Date.now();
442
+ onUpdate?.(table);
443
+ })
444
+ .catch(() => undefined)
445
+ .finally(() => {
446
+ inFlight = null;
447
+ });
448
+ },
449
+ };
450
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * dsh-router — policy-based routing (proposal §1, módulo router).
3
+ *
4
+ * MVP = heuristic rules, honest and calibrated: pick the CHEAPEST allowed
5
+ * model from a cascade (cheap → expensive) and escalate one step per retry
6
+ * when the current route fails (FrugalGPT/RouteLLM pattern: rules first,
7
+ * cheap→expensive, no hidden quality degradation — a router that visibly
8
+ * degrades is worse than no router). Pure — no I/O.
9
+ */
10
+ export interface RouterPolicies {
11
+ /** Models a policy allows; absent = no model restriction. */
12
+ allowedModels?: string[];
13
+ /** Models explicitly excluded (provider denylist). */
14
+ denylistedModels?: string[];
15
+ }
16
+ export type RouteReason = "requested" | "allowed_first" | "escalated";
17
+ export interface RouteDecision {
18
+ model: string;
19
+ reason: RouteReason;
20
+ }
21
+ /** Pick a policy-compliant model for this attempt. */
22
+ export declare function routeDecision(input: {
23
+ /** The model the harness asked for. */
24
+ requestedModel: string;
25
+ /** Attempt index (0 = first try, 1 = first retry, …). */
26
+ attempt: number;
27
+ /** Ordered cascade cheap → expensive (model ids, provider-prefixed). */
28
+ cascade: string[];
29
+ policies?: RouterPolicies;
30
+ }): RouteDecision;
package/dist/router.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * dsh-router — policy-based routing (proposal §1, módulo router).
3
+ *
4
+ * MVP = heuristic rules, honest and calibrated: pick the CHEAPEST allowed
5
+ * model from a cascade (cheap → expensive) and escalate one step per retry
6
+ * when the current route fails (FrugalGPT/RouteLLM pattern: rules first,
7
+ * cheap→expensive, no hidden quality degradation — a router that visibly
8
+ * degrades is worse than no router). Pure — no I/O.
9
+ */
10
+ /** Pick a policy-compliant model for this attempt. */
11
+ export function routeDecision(input) {
12
+ const allowed = input.cascade.filter((model) => (!input.policies?.allowedModels ||
13
+ input.policies.allowedModels.includes(model)) &&
14
+ !input.policies?.denylistedModels?.includes(model));
15
+ // First attempt + requested model allowed → the harness's choice stands.
16
+ if (input.attempt === 0 &&
17
+ input.policies?.allowedModels?.includes(input.requestedModel)) {
18
+ return { model: input.requestedModel, reason: "requested" };
19
+ }
20
+ // First attempt + requested model denied → the cheapest allowed route.
21
+ if (input.attempt === 0) {
22
+ const fallback = allowed[0];
23
+ if (!fallback)
24
+ return { model: input.requestedModel, reason: "requested" };
25
+ return { model: fallback, reason: "allowed_first" };
26
+ }
27
+ // Retry: escalate one step up the cascade from the currently used model
28
+ // (the cheap route failed — the next is more capable, never cheaper).
29
+ const currentIndex = allowed.indexOf(input.requestedModel);
30
+ const next = allowed[currentIndex + 1] ??
31
+ allowed[allowed.length - 1] ??
32
+ input.requestedModel;
33
+ return { model: next, reason: "escalated" };
34
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * dsh local store — libsql persistence for the plugin (proposal §1: "Store
3
+ * local libsql — sem build nativa, distribuível sem compilação").
4
+ *
5
+ * The local fuse's spentUsd comes from HERE in local-only mode: spend is
6
+ * recorded per project and windowed on demand, surviving restarts, and the
7
+ * sync path pulls exactly the rows not yet acknowledged by the SaaS. The
8
+ * store is pure persistence — enforcement math stays in fuseDecision.
9
+ *
10
+ * ## Fidelity
11
+ *
12
+ * A stored row carries everything the SaaS needs to answer "quanto queimamos,
13
+ * quem é o vilão, o que a política travou hoje" (proposal §3): the hashed
14
+ * session identity, the reasoning effort actually used, and the full disjoint
15
+ * token accounting the harness reports (`TokenUsage`: uncached input, output,
16
+ * cache reads, cache writes). Dropping any of them makes a server-side policy
17
+ * unevaluable or a session dimension unanswerable, so they are part of the
18
+ * schema rather than of the sync mapping.
19
+ *
20
+ * The `usage` table is migrated in place ({@link migrate}) so an existing
21
+ * `file:local.db` from an earlier version keeps its spend history instead of
22
+ * being discarded.
23
+ */
24
+ export interface StoredUsage {
25
+ id: number;
26
+ /** SHA-256 of the harness session id — the raw id never leaves the machine. */
27
+ sessionId: string;
28
+ project: string;
29
+ costUsd: number;
30
+ /** ISO timestamp — windows are computed from this, never from now. */
31
+ at: string;
32
+ synced: boolean;
33
+ /** Meter fidelity: the model/provider behind this spend (best effort). */
34
+ model: string;
35
+ provider: string;
36
+ reasoningEffort: string;
37
+ inputTokens: number;
38
+ outputTokens: number;
39
+ cacheReadTokens: number;
40
+ cacheWriteTokens: number;
41
+ /** Model-request latency for the step, when the durable log provided one. */
42
+ durationMs: number | null;
43
+ /** True when no price resolved for this call — visible, never silent. */
44
+ unpriced: boolean;
45
+ /** Client-generated stable id — the SaaS dedupes on (org, event_id). */
46
+ eventId: string;
47
+ /** Tool names this step called (metrics only — never arguments). */
48
+ tools: string[];
49
+ }
50
+ /** One metered call, as the meter projects it from the session log. */
51
+ export interface UsageRecord {
52
+ sessionId?: string;
53
+ project: string;
54
+ costUsd: number;
55
+ at: string;
56
+ model?: string;
57
+ provider?: string;
58
+ reasoningEffort?: string;
59
+ inputTokens?: number;
60
+ outputTokens?: number;
61
+ cacheReadTokens?: number;
62
+ cacheWriteTokens?: number;
63
+ durationMs?: number | null;
64
+ unpriced?: boolean;
65
+ eventId?: string;
66
+ /** Tool names this step called (metrics only). */
67
+ tools?: string[];
68
+ }
69
+ export interface LocalStore {
70
+ record(event: UsageRecord): Promise<void>;
71
+ /** Sum of spend since `since` (ISO) for a project/dev (or the whole store). */
72
+ spentForWindow(opts: {
73
+ project?: string | null;
74
+ dev?: string | null;
75
+ since: string;
76
+ }): Promise<number>;
77
+ /** Rows not yet acknowledged by the SaaS (the batch payload). */
78
+ pendingSync(limit?: number): Promise<StoredUsage[]>;
79
+ /** Mark rows as synced after a successful batch. */
80
+ markSynced(ids: number[]): Promise<void>;
81
+ /** Fuse cuts (proposal §3) — reported to the SaaS, never lost. */
82
+ recordCut(event: {
83
+ project: string;
84
+ rule: string;
85
+ }): Promise<void>;
86
+ /** Unsynced cuts (cap: a stuck sync can't grow the batch unbounded). */
87
+ pendingCuts(limit?: number): Promise<{
88
+ id: number;
89
+ project: string;
90
+ rule: string;
91
+ }[]>;
92
+ markCutsSynced(ids: number[]): Promise<void>;
93
+ /**
94
+ * The SaaS 429 (secondary gate) engages the local fuse too: the rule +
95
+ * reset at persist so the offline gate reflects the remote decision
96
+ * (sync fidelity — a team blocked centrally is blocked locally). Blocks
97
+ * are scoped: an `org` block stops every call, a `project` block only the
98
+ * referenced project, a `dev` block only the referenced dev — one
99
+ * project's 429 must never freeze another project's work.
100
+ */
101
+ setRemoteBlock(block: {
102
+ rule: string;
103
+ resetAt: string;
104
+ scope?: "org" | "project" | "dev";
105
+ reference?: string;
106
+ } | null): Promise<void>;
107
+ /** The active block governing a call with this project/dev, or null. */
108
+ remoteBlockFor(input: {
109
+ project: string;
110
+ dev: string;
111
+ }): Promise<{
112
+ rule: string;
113
+ resetAt: string;
114
+ } | null>;
115
+ /**
116
+ * The org policy pulled from the SaaS (`GET /v1/policy`) — the panel is the
117
+ * control plane, so the local fuse reads its budgets/policies from here when
118
+ * the deployment published them. Cached for offline use.
119
+ */
120
+ setRemotePolicy(policy: RemotePolicy | null): Promise<void>;
121
+ remotePolicy(): Promise<RemotePolicy | null>;
122
+ /** Persist the resolved price table so enforcement is offline-capable. */
123
+ setPricingTable(table: Record<string, unknown> | null): Promise<void>;
124
+ pricingTable(): Promise<Record<string, unknown> | null>;
125
+ /** Release the underlying client (tests, teardown). */
126
+ close(): Promise<void>;
127
+ }
128
+ /**
129
+ * The org's enforcement state as the SaaS publishes it, in the fuse's own
130
+ * vocabulary (see `GET /v1/policy`). Optional members mean "not configured",
131
+ * which is why the fuse treats an absent budget list as "no local cap".
132
+ *
133
+ * Budgets carry their `scope` and `reference` so the fuse applies each one
134
+ * only to calls it governs — never every org budget as a global cap.
135
+ */
136
+ export interface RemotePolicy {
137
+ budgets?: {
138
+ limitUsd: number;
139
+ window: "month" | "day";
140
+ scope?: "org" | "project" | "dev";
141
+ /** For project/dev scopes: the project label / developer id. */
142
+ reference?: string;
143
+ }[];
144
+ maxReasoningEffort?: string;
145
+ allowedModels?: string[];
146
+ denylistedProjects?: string[];
147
+ /** ISO timestamp of the published revision (diagnostics). */
148
+ updatedAt?: string;
149
+ }
150
+ /**
151
+ * Resolve the DSH home (harness root) the same way the harness itself does:
152
+ * `$DSH_HOME` when set and non-blank, else `~/.dsh`. The default store path is
153
+ * anchored here so the ledger is stable regardless of the working directory
154
+ * the harness happened to be started from — a CWD-relative default silently
155
+ * split spend across one empty ledger per launch directory.
156
+ */
157
+ export declare function dshHome(): string;
158
+ /**
159
+ * The default store location: `$DSH_HOME/dsh-fuse/local.db` (or the
160
+ * equivalent under `~/.dsh`). The parent directory is created on demand by
161
+ * {@link createLocalStore}, so a fresh harness home needs no manual setup.
162
+ */
163
+ export declare function defaultStoreUrl(): string;
164
+ /** `url`: file path for the installed plugin, `:memory:` for tests. A relative
165
+ * `file:` path is only honored when explicitly configured — the default is
166
+ * the harness-home-anchored {@link defaultStoreUrl}, never the CWD.
167
+ */
168
+ export declare function createLocalStore(url?: string): LocalStore;