@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,56 @@
1
+ /**
2
+ * The dsh fuse — metering ≡ enforcement for local coding agents.
3
+ *
4
+ * A free plugin for the DeepSeek Harness that exports per-call telemetry
5
+ * (metrics only: model, tokens, cost — never content) and enforces the org's
6
+ * cost policy locally, before any token is spent. The SaaS aggregates the
7
+ * team's sessions and publishes the policy; the local fuse applies it offline
8
+ * and the batch's 429 is the secondary, central gate.
9
+ *
10
+ * ## How it extends the harness
11
+ *
12
+ * It is a **hook plugin** in the harness's own vocabulary (extension
13
+ * cookbook): "an ordinary Cordis plugin on an interception point". It listens
14
+ * on the `agent/pre-step` and `agent/request` waterfalls, and observes the
15
+ * durable feed through `session/event`. It is deliberately NOT a tool (the
16
+ * docs are explicit that deployment policy does not belong in a tool the
17
+ * model must choose to call) and it modifies no part of the loop.
18
+ *
19
+ * ## Local-only mode
20
+ *
21
+ * Without `baseUrl`/`orgKey` nothing ever leaves the machine: the fuse
22
+ * enforces the configured budgets and policies entirely offline, and the
23
+ * local libsql store (`$DSH_HOME/dsh-fuse/local.db` by default) is
24
+ * the spend source.
25
+ */
26
+ export declare const name = "fuse";
27
+ import { apply as pluginApply } from "./harness.js";
28
+ export { assertUsableConfig, type BudgetConfig, Config, type DshPluginConfig, type PolicyConfig, resolveConfig, } from "./config.js";
29
+ export { type FuseBudget, type FuseDecision, type FusePolicies, fuseDecision, } from "./fuse.js";
30
+ export { type CallProjection, hashSessionId, type MessageProvenance, projectCall, type RequestHeaderView, type TokenUsageLike, } from "./meter.js";
31
+ export { createPricingCache, DEFAULT_REGISTRY_URL, estimateCostUsd, fetchPricingTable, normalizeModelId, type PricingAliases, type PricingEntry, type PricingResolution, type PricingTable, type PricingVia, parseGatewayModelEntry, parseGatewayModels, parsePricingRegistry, pricingCandidates, resolvePricingEntry, type TokenCounts, } from "./pricing.js";
32
+ export { type RouteDecision, type RouteReason, type RouterPolicies, routeDecision, } from "./router.js";
33
+ export { createLocalStore, type LocalStore, type RemotePolicy, type StoredUsage, type UsageRecord, } from "./store.js";
34
+ export { fetchPolicy, type OrgKeyTarget, parseRemotePolicy, type SyncResult, syncBatch, } from "./sync.js";
35
+ export type { BatchEvent, CutEvent, UsageEvent } from "./wire.js";
36
+ /**
37
+ * The Cordis entry.
38
+ *
39
+ * The DEFAULT export is the object form `{ name, apply, Config }` because the
40
+ * loader unwraps a module to `exports.default` before mounting it
41
+ * (`Loader.unwrapExports`) and then reads `plugin.Config` to validate the row's
42
+ * config. Exporting the bare `apply` function as default — the shape this
43
+ * package originally shipped — leaves `Config` undefined, so the schema is
44
+ * skipped, defaults are never applied, and `apply` receives partial config.
45
+ * The object form is the documented shape that keeps both together.
46
+ *
47
+ * The named exports above/below serve programmatic mounting and tests.
48
+ */
49
+ export declare const apply: typeof pluginApply;
50
+ export { pluginApply as plugin };
51
+ declare const _default: {
52
+ name: string;
53
+ apply: typeof pluginApply;
54
+ Config: import("@deepseek-ai/schemastery").default<Partial<import("./config.js").DshPluginConfig>, import("./config.js").DshPluginConfig>;
55
+ };
56
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The dsh fuse — metering ≡ enforcement for local coding agents.
3
+ *
4
+ * A free plugin for the DeepSeek Harness that exports per-call telemetry
5
+ * (metrics only: model, tokens, cost — never content) and enforces the org's
6
+ * cost policy locally, before any token is spent. The SaaS aggregates the
7
+ * team's sessions and publishes the policy; the local fuse applies it offline
8
+ * and the batch's 429 is the secondary, central gate.
9
+ *
10
+ * ## How it extends the harness
11
+ *
12
+ * It is a **hook plugin** in the harness's own vocabulary (extension
13
+ * cookbook): "an ordinary Cordis plugin on an interception point". It listens
14
+ * on the `agent/pre-step` and `agent/request` waterfalls, and observes the
15
+ * durable feed through `session/event`. It is deliberately NOT a tool (the
16
+ * docs are explicit that deployment policy does not belong in a tool the
17
+ * model must choose to call) and it modifies no part of the loop.
18
+ *
19
+ * ## Local-only mode
20
+ *
21
+ * Without `baseUrl`/`orgKey` nothing ever leaves the machine: the fuse
22
+ * enforces the configured budgets and policies entirely offline, and the
23
+ * local libsql store (`$DSH_HOME/dsh-fuse/local.db` by default) is
24
+ * the spend source.
25
+ */
26
+ export const name = "fuse";
27
+ import { Config as PluginConfig } from "./config.js";
28
+ import { apply as pluginApply } from "./harness.js";
29
+ export { assertUsableConfig, Config, resolveConfig, } from "./config.js";
30
+ export { fuseDecision, } from "./fuse.js";
31
+ export { hashSessionId, projectCall, } from "./meter.js";
32
+ export { createPricingCache, DEFAULT_REGISTRY_URL, estimateCostUsd, fetchPricingTable, normalizeModelId, parseGatewayModelEntry, parseGatewayModels, parsePricingRegistry, pricingCandidates, resolvePricingEntry, } from "./pricing.js";
33
+ export { routeDecision, } from "./router.js";
34
+ export { createLocalStore, } from "./store.js";
35
+ export { fetchPolicy, parseRemotePolicy, syncBatch, } from "./sync.js";
36
+ /**
37
+ * The Cordis entry.
38
+ *
39
+ * The DEFAULT export is the object form `{ name, apply, Config }` because the
40
+ * loader unwraps a module to `exports.default` before mounting it
41
+ * (`Loader.unwrapExports`) and then reads `plugin.Config` to validate the row's
42
+ * config. Exporting the bare `apply` function as default — the shape this
43
+ * package originally shipped — leaves `Config` undefined, so the schema is
44
+ * skipped, defaults are never applied, and `apply` receives partial config.
45
+ * The object form is the documented shape that keeps both together.
46
+ *
47
+ * The named exports above/below serve programmatic mounting and tests.
48
+ */
49
+ export const apply = pluginApply;
50
+ export { pluginApply as plugin };
51
+ export default {
52
+ name,
53
+ apply: pluginApply,
54
+ Config: PluginConfig,
55
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The meter — projects one completed model call from the durable session log
3
+ * into the metrics the fuse prices and the SaaS stores.
4
+ *
5
+ * ## Sources, and why these ones
6
+ *
7
+ * - **Model and provider** come from the assistant message's own provenance.
8
+ * `assistant/message.message.source` is `AssistantProvenance {provider,
9
+ * model}`: "Model-produced assistant messages name the provider and model
10
+ * that produced them", and the loop "stores the assembled assistant content
11
+ * with the provider and model that produced it". This is per-call truth. A
12
+ * session-keyed cache of the latest `request/header` — the previous
13
+ * implementation — resolves to "unknown" on a resumed session or a missing
14
+ * header, and then prices the call at zero.
15
+ * - **Reasoning effort** comes from the request header's `LlmCallConfig`,
16
+ * which is the only place the log records it. When the caller omitted an
17
+ * effort and the ADAPTER materialized its own default, the log records that
18
+ * a default was applied (`EpochHeader.adapterDefaults.reasoningEffort`) but
19
+ * not the value; the projection therefore reports the effort the log states
20
+ * and stays empty otherwise rather than guessing.
21
+ * - **Tokens** are the harness's own disjoint counts (`TokenUsage`: uncached
22
+ * input, output, cache reads, cache writes).
23
+ * - **Duration** is measured from the step's `step/start` to the assistant
24
+ * settlement, i.e. the model request's latency for that step. The durable
25
+ * `assistant/message.stream` is not persisted by this build (verified
26
+ * against real logs: `data` carries only `message/step/turn/usage`), so the
27
+ * step boundary is the honest available source. `null` when the step's start
28
+ * was not observed (a resumed session, or a delivery that began before the
29
+ * plugin loaded).
30
+ */
31
+ import type { TokenCounts } from "./pricing.js";
32
+ /** The harness's per-call token accounting (`TokenUsage`). */
33
+ export interface TokenUsageLike {
34
+ inputTokens: number;
35
+ outputTokens: number;
36
+ cacheReadTokens?: number;
37
+ cacheWriteTokens?: number;
38
+ }
39
+ /** `AssistantProvenance` — the provider/model that produced one message. */
40
+ export interface MessageProvenance {
41
+ provider?: string;
42
+ model?: string;
43
+ }
44
+ /** The subset of `EpochHeader` the meter reads (never the system prompt). */
45
+ export interface RequestHeaderView {
46
+ provider?: string;
47
+ model?: string;
48
+ reasoningEffort?: string;
49
+ }
50
+ export interface CallProjection {
51
+ model: string;
52
+ provider: string;
53
+ reasoningEffort: string;
54
+ counts: Required<TokenCounts>;
55
+ durationMs: number | null;
56
+ }
57
+ /** sha256 — the session id never leaves the machine raw. */
58
+ export declare function hashSessionId(sessionId: string): Promise<string>;
59
+ /**
60
+ * Project one completed model call. Pure — no I/O, no clock: the caller
61
+ * supplies the settlement time and (when observed) the step's start time.
62
+ */
63
+ export declare function projectCall(input: {
64
+ provenance?: MessageProvenance;
65
+ header?: RequestHeaderView;
66
+ usage: TokenUsageLike;
67
+ /** Epoch ms of the assistant settlement. */
68
+ settledAt: number;
69
+ /** Epoch ms when this step opened, when observed. */
70
+ stepStartedAt?: number;
71
+ }): CallProjection;
package/dist/meter.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The meter — projects one completed model call from the durable session log
3
+ * into the metrics the fuse prices and the SaaS stores.
4
+ *
5
+ * ## Sources, and why these ones
6
+ *
7
+ * - **Model and provider** come from the assistant message's own provenance.
8
+ * `assistant/message.message.source` is `AssistantProvenance {provider,
9
+ * model}`: "Model-produced assistant messages name the provider and model
10
+ * that produced them", and the loop "stores the assembled assistant content
11
+ * with the provider and model that produced it". This is per-call truth. A
12
+ * session-keyed cache of the latest `request/header` — the previous
13
+ * implementation — resolves to "unknown" on a resumed session or a missing
14
+ * header, and then prices the call at zero.
15
+ * - **Reasoning effort** comes from the request header's `LlmCallConfig`,
16
+ * which is the only place the log records it. When the caller omitted an
17
+ * effort and the ADAPTER materialized its own default, the log records that
18
+ * a default was applied (`EpochHeader.adapterDefaults.reasoningEffort`) but
19
+ * not the value; the projection therefore reports the effort the log states
20
+ * and stays empty otherwise rather than guessing.
21
+ * - **Tokens** are the harness's own disjoint counts (`TokenUsage`: uncached
22
+ * input, output, cache reads, cache writes).
23
+ * - **Duration** is measured from the step's `step/start` to the assistant
24
+ * settlement, i.e. the model request's latency for that step. The durable
25
+ * `assistant/message.stream` is not persisted by this build (verified
26
+ * against real logs: `data` carries only `message/step/turn/usage`), so the
27
+ * step boundary is the honest available source. `null` when the step's start
28
+ * was not observed (a resumed session, or a delivery that began before the
29
+ * plugin loaded).
30
+ */
31
+ /** sha256 — the session id never leaves the machine raw. */
32
+ export async function hashSessionId(sessionId) {
33
+ const data = new TextEncoder().encode(sessionId);
34
+ const digest = await crypto.subtle.digest("SHA-256", data);
35
+ return [...new Uint8Array(digest)]
36
+ .map((byte) => byte.toString(16).padStart(2, "0"))
37
+ .join("");
38
+ }
39
+ /** Coerce one provider-reported count to a non-negative integer. */
40
+ function count(value) {
41
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
42
+ return 0;
43
+ return Math.floor(value);
44
+ }
45
+ /**
46
+ * Project one completed model call. Pure — no I/O, no clock: the caller
47
+ * supplies the settlement time and (when observed) the step's start time.
48
+ */
49
+ export function projectCall(input) {
50
+ // Provenance wins: it identifies the model that actually served the call.
51
+ const model = input.provenance?.model ?? input.header?.model ?? "";
52
+ const provider = input.provenance?.provider ??
53
+ input.header?.provider ??
54
+ (model.includes("/") ? model.split("/")[0] : "") ??
55
+ "";
56
+ const durationMs = typeof input.stepStartedAt === "number" &&
57
+ Number.isFinite(input.stepStartedAt) &&
58
+ input.settledAt >= input.stepStartedAt
59
+ ? Math.round(input.settledAt - input.stepStartedAt)
60
+ : null;
61
+ return {
62
+ model,
63
+ provider,
64
+ reasoningEffort: input.header?.reasoningEffort ?? "",
65
+ counts: {
66
+ inputTokens: count(input.usage.inputTokens),
67
+ outputTokens: count(input.usage.outputTokens),
68
+ cacheReadTokens: count(input.usage.cacheReadTokens),
69
+ cacheWriteTokens: count(input.usage.cacheWriteTokens),
70
+ },
71
+ durationMs,
72
+ };
73
+ }
@@ -0,0 +1,193 @@
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
+ export interface PricingEntry {
38
+ inputCentsPerM: number;
39
+ outputCentsPerM: number;
40
+ cacheReadCentsPerM?: number;
41
+ cacheWriteCentsPerM?: number;
42
+ }
43
+ export type PricingTable = Record<string, PricingEntry>;
44
+ /** Token counts priced by {@link estimateCostUsd}. */
45
+ export interface TokenCounts {
46
+ inputTokens: number;
47
+ outputTokens: number;
48
+ cacheReadTokens: number;
49
+ cacheWriteTokens?: number;
50
+ }
51
+ /** Explicit `model id → price-table key` overrides; highest precedence. */
52
+ export type PricingAliases = Record<string, string>;
53
+ /** Default registry: 200+ providers, `cost` in USD per 1M tokens. */
54
+ export declare const DEFAULT_REGISTRY_URL = "https://models.dev/api.json";
55
+ /** How a key was derived from the requested model id — feeds diagnostics. */
56
+ export type PricingVia = "alias" | "exact" | "route" | "model" | "suffix" | "provider";
57
+ /** Which candidate key resolved, and how. */
58
+ export interface PricingResolution {
59
+ entry: PricingEntry;
60
+ /** The table key that matched. */
61
+ key: string;
62
+ /** How the key was derived from the requested model id. */
63
+ via: PricingVia;
64
+ }
65
+ /** Lowercase + separator-normalized form used for every lookup. */
66
+ export declare function normalizeModelId(model: string): string;
67
+ /**
68
+ * Ordered candidate keys for one model id, most specific first.
69
+ *
70
+ * `provider/anthropic/claude-sonnet-5` yields the full id, then each
71
+ * progressively stripped suffix (`anthropic/claude-sonnet-5`,
72
+ * `claude-sonnet-5`) — so a table keyed by a bare model id, by a
73
+ * vendor-qualified id, or by the adapter's own prefixed id all resolve. When
74
+ * the caller knows the provider route, `${provider}/${bare}` is tried too
75
+ * (a table keyed `openai/gpt-4o` still prices a reported `gpt-4o`).
76
+ */
77
+ export declare function pricingCandidates(model: string, provider?: string): string[];
78
+ /**
79
+ * Resolve the price entry for one model id.
80
+ *
81
+ * Precedence: explicit alias → exact key → `${provider}/${bare}` → bare model →
82
+ * progressively stripped suffix. Returns `null` when nothing matches, so callers
83
+ * can report the miss (and mark the call unpriced) instead of pricing at zero.
84
+ */
85
+ export declare function resolvePricingEntry(table: PricingTable | undefined, model: string, opts?: {
86
+ provider?: string;
87
+ aliases?: PricingAliases;
88
+ }): PricingResolution | null;
89
+ /**
90
+ * Cost in USD from the resolved table entry (cents per 1M tokens).
91
+ *
92
+ * Cache reads and writes price at the entry's own cache rate when the table
93
+ * publishes one, and at the INPUT rate when it does not — deliberately
94
+ * conservative: the fuse prefers overestimating spend to missing a cut. The
95
+ * harness reports cache counts as DISJOINT from `inputTokens` (`TokenUsage`:
96
+ * "billed input = sum of the three"), so nothing is double counted here, and
97
+ * `reasoningTokens` is deliberately ignored because the docs state it is
98
+ * "already included in `outputTokens`; totals must not add it again".
99
+ *
100
+ * @returns USD cost plus the key that priced it, or `null` when the model is
101
+ * unpriced — this function never invents a price.
102
+ */
103
+ export declare function estimateCostUsd(table: PricingTable | undefined, model: string, counts: TokenCounts, opts?: {
104
+ provider?: string;
105
+ aliases?: PricingAliases;
106
+ }): {
107
+ costUsd: number;
108
+ key: string;
109
+ via: PricingVia;
110
+ } | null;
111
+ /**
112
+ * Parse the registry into a table keyed BOTH ways:
113
+ *
114
+ * - `${provider}/${model}` — the exact route (authoritative).
115
+ * - bare `${model}` — the **modal** (most-agreed) quote across every provider,
116
+ * used only when the route is absent. Measurements show this modal value is
117
+ * the upstream list price (e.g. 34 of 39 providers quote exactly $5.00/1M for
118
+ * `gpt-5.5`), which is what a pass-through reseller charges at cost.
119
+ *
120
+ * Every key is normalized so a reported `deepseek/deepseek-v4.1-flash` and a
121
+ * registry `deepseek-v4.1-flash` land on the same lookup.
122
+ */
123
+ export declare function parsePricingRegistry(json: unknown): {
124
+ table: PricingTable;
125
+ routes: number;
126
+ models: number;
127
+ };
128
+ /**
129
+ * Normalize one OpenAI-compatible `/models` entry that publishes prices.
130
+ *
131
+ * The convention is real but the shape is not standardized; three layouts cover
132
+ * every publicly-readable gateway measured:
133
+ * - OpenRouter: `pricing.{prompt,completion,input_cache_read,input_cache_write}`
134
+ * in USD **per token** (strings).
135
+ * - DeepInfra: `metadata.pricing.{input_tokens,output_tokens,cache_read_tokens}`
136
+ * in USD **per 1M**.
137
+ * - Novita: `input_token_price_per_m` / `output_token_price_per_m` per 1M.
138
+ * A registry-shaped `cost.{input,output,cache_read,cache_write}` is accepted too.
139
+ */
140
+ export declare function parseGatewayModelEntry(model: unknown): PricingEntry | null;
141
+ /**
142
+ * Parse a gateway `/models` response, keyed by `${provider}/${model}`.
143
+ *
144
+ * Returns an empty table when the gateway publishes no prices (e.g. command-code)
145
+ * — the caller then falls back to the registry by model id.
146
+ */
147
+ export declare function parseGatewayModels(json: unknown, provider: string): {
148
+ table: PricingTable;
149
+ priced: number;
150
+ total: number;
151
+ };
152
+ /**
153
+ * Fetch + merge every configured price source. Never throws: a failed source
154
+ * keeps whatever the config already carries, and a config override always wins
155
+ * on conflicts.
156
+ *
157
+ * The registry is the primary source (200+ providers, keyed by route, carries
158
+ * cache read/write). A gateway `/models` is consulted first when configured,
159
+ * because a gateway that publishes its own prices is authoritative for its own
160
+ * routes.
161
+ */
162
+ export declare function fetchPricingTable(input: {
163
+ /** Registry URL; pass `""` to skip. Defaults to {@link DEFAULT_REGISTRY_URL}. */
164
+ registryUrl?: string;
165
+ /** OpenAI-compatible `/models` URL that may publish prices. */
166
+ gatewayUrl?: string;
167
+ /** Provider id used to key the gateway's routes (e.g. "opencode-go"). */
168
+ gatewayProvider?: string;
169
+ /** Env var holding the gateway bearer token, when the endpoint needs auth. */
170
+ gatewayApiKeyEnv?: string;
171
+ /** User config wins over every fetched source on per-key conflicts. */
172
+ override?: PricingTable;
173
+ fetchImpl?: typeof fetch;
174
+ }): Promise<{
175
+ table: PricingTable;
176
+ failures: string[];
177
+ routes: number;
178
+ models: number;
179
+ gatewayPriced: number;
180
+ }>;
181
+ /**
182
+ * TTL cache for the sync (refresh hourly; the first call inside the plugin is
183
+ * non-blocking — the fuse starts on the config table and upgrades when the
184
+ * fetch lands). `onUpdate` lets the caller persist the table for offline use.
185
+ */
186
+ export declare function createPricingCache(fetchTable: () => Promise<{
187
+ table: PricingTable;
188
+ }>, ttlMs?: number, onUpdate?: (table: PricingTable) => void): {
189
+ current: () => PricingTable;
190
+ refresh: () => void;
191
+ /** Seed the cache from persisted state (offline first boot). */
192
+ hydrate: (table: PricingTable) => void;
193
+ };