@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.
package/dist/config.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Plugin configuration — schema + cross-field validation.
3
+ *
4
+ * The harness plugin-config contract (develop/basic/config) asks for an
5
+ * exported `Config` that is BOTH a TypeScript type and a runtime schema:
6
+ * "Do not export a plain object as Config; it does not implement the Standard
7
+ * Schema interface required by Cordis." Cordis validates it while the plugin
8
+ * loads, so bad config fails the load "with a precise error — the plugin never
9
+ * starts half-configured".
10
+ *
11
+ * ## Why the cross-field check matters more here than in a normal plugin
12
+ *
13
+ * A cost-enforcement plugin that loads with unusable configuration does not
14
+ * merely misbehave — it enforces NOTHING, silently, while the deployment
15
+ * believes it is protected. Configuration that cannot be acted on is refused
16
+ * at load time (the documented rule: "A plugin should also reject
17
+ * schema-valid config that names an unavailable resource or provider"), and
18
+ * the shape that produces a silent no-op is rejected explicitly:
19
+ *
20
+ * 1. exactly one of `baseUrl` / `orgKey`. Half a sync target is not a
21
+ * degraded sync, it is no sync (plus no `GET /v1/policy`, so the panel
22
+ * cannot reach the local fuse at all).
23
+ *
24
+ * Prices are NOT required here: the plugin resolves them generically by model
25
+ * id (see pricing.ts) and reports an unpriced call visibly rather than
26
+ * refusing to load. The old "budgets require a price source" gate was removed
27
+ * because it forced every deployment to hand-maintain a price table — exactly
28
+ * the maintenance burden the generic resolver exists to eliminate; a missing
29
+ * price is a visible state, not a boot failure and not a silent zero.
30
+ */
31
+ import Schema from "@deepseek-ai/schemastery";
32
+ /**
33
+ * The runtime schema Cordis validates before `apply` runs.
34
+ *
35
+ * Annotated as `Schema<Partial<DshPluginConfig>, DshPluginConfig>`: the first
36
+ * parameter is what a `cordis.yml` entry supplies (every field optional, filled
37
+ * by the schema defaults) and the second is what `apply` receives (complete).
38
+ * An explicit annotation is required because the inferred form cannot be named
39
+ * in the emitted declarations — it reaches into Schemastery's own type helpers
40
+ * and their vendored dependencies, which breaks the build of a package that
41
+ * ships `.d.ts`.
42
+ */
43
+ export const Config = Schema.object({
44
+ // Empty storeUrl means "the harness-home-anchored default store"
45
+ // (store.ts) — a stable ledger that does not wander with the CWD the
46
+ // harness was started from.
47
+ storeUrl: Schema.string().default(""),
48
+ project: Schema.string().default("default"),
49
+ dev: Schema.string().default("unknown"),
50
+ budgets: Schema.array(Schema.object({
51
+ limitUsd: Schema.number().min(0).required(),
52
+ window: Schema.union(["month", "day"]).default("month"),
53
+ })).default([]),
54
+ // "Unset" is encoded as an empty value rather than an absent key so the
55
+ // validated config always has a definite shape (a half-present policy
56
+ // object is exactly the kind of state that silently disables enforcement).
57
+ policies: Schema.object({
58
+ maxReasoningEffort: Schema.string().default(""),
59
+ allowedModels: Schema.array(Schema.string()).default([]),
60
+ denylistedProjects: Schema.array(Schema.string()).default([]),
61
+ }).default({
62
+ maxReasoningEffort: "",
63
+ allowedModels: [],
64
+ denylistedProjects: [],
65
+ }),
66
+ cascade: Schema.array(Schema.string()).default([]),
67
+ baseUrl: Schema.string(),
68
+ orgKey: Schema.string().role("secret"),
69
+ syncIntervalMs: Schema.number().min(1000).default(60_000),
70
+ policyRefreshMs: Schema.number().min(1000).default(300_000),
71
+ budgetStatusTool: Schema.boolean().default(true),
72
+ pricingTable: Schema.dict(Schema.object({
73
+ inputCentsPerM: Schema.number().min(0).required(),
74
+ outputCentsPerM: Schema.number().min(0).required(),
75
+ cacheReadCentsPerM: Schema.number().min(0),
76
+ cacheWriteCentsPerM: Schema.number().min(0),
77
+ })).default({}),
78
+ pricingAliases: Schema.dict(Schema.string()).default({}),
79
+ pricingRegistryUrl: Schema.string().default("https://models.dev/api.json"),
80
+ pricingGatewayUrl: Schema.string().default(""),
81
+ pricingGatewayProvider: Schema.string().default(""),
82
+ pricingGatewayApiKeyEnv: Schema.string().default(""),
83
+ unpricedFallback: Schema.object({
84
+ inputCentsPerM: Schema.number().min(0),
85
+ outputCentsPerM: Schema.number().min(0),
86
+ cacheReadCentsPerM: Schema.number().min(0),
87
+ cacheWriteCentsPerM: Schema.number().min(0),
88
+ }),
89
+ });
90
+ /**
91
+ * Validate raw configuration and apply defaults, exactly as Cordis does when it
92
+ * loads the plugin. Exposed so tests and embedders can exercise the schema
93
+ * without a cast: the parameter is the partially-populated shape a
94
+ * `cordis.yml` entry supplies, and Schemastery fills every absent field.
95
+ *
96
+ * @throws when a value has the wrong type — Cordis surfaces that as a FAILED
97
+ * fibre ("invalid configuration fails the load with an actionable error").
98
+ */
99
+ export function resolveConfig(raw) {
100
+ // A schema's whole job is to validate untrusted input, so this is the one
101
+ // place where the boundary is stated rather than assumed: `raw` is whatever
102
+ // a `cordis.yml` entry (or a test) contains, and Schemastery throws on a
103
+ // value whose type is wrong.
104
+ return Config(raw);
105
+ }
106
+ /**
107
+ * Cross-field constraints the schema cannot express, evaluated on the resolved
108
+ * config (defaults applied) so they see exactly what `apply` will receive.
109
+ *
110
+ * @throws Error with an actionable message — Cordis turns it into a FAILED
111
+ * fiber, which is the documented outcome for config naming what the plugin
112
+ * cannot serve.
113
+ */
114
+ export function assertUsableConfig(config) {
115
+ if (Boolean(config.baseUrl) !== Boolean(config.orgKey)) {
116
+ throw new Error("baseUrl and orgKey must be configured together: without both, the plugin would report nothing and the panel could not reach the local fuse (local-only mode omits both).");
117
+ }
118
+ if (config.cascade.length > 0 && config.policies.allowedModels.length > 0) {
119
+ const allowed = new Set(config.policies.allowedModels);
120
+ const usable = config.cascade.some((model) => allowed.has(model));
121
+ if (!usable) {
122
+ throw new Error("cascade and policies.allowedModels do not intersect: the router could never pick a permitted model.");
123
+ }
124
+ }
125
+ }
package/dist/fuse.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The local fuse — the PRIMARY enforcement (<100ms, offline). Decides
3
+ * whether a call may proceed; the SaaS 429 is only the secondary gate.
4
+ * Pure: budgets arrive with spentUsd precomputed (the store provides it) and
5
+ * the route's reasoning set arrives pre-resolved (the caller provides it).
6
+ *
7
+ * ## Reasoning caps are ranked by the ROUTE, never by a local table
8
+ *
9
+ * `ReasoningEffortId` is adapter-owned and opaque: the harness core "brands
10
+ * identifiers but does not enumerate their values; each adapter owns the
11
+ * ordered set, display names, and optional deployment default", and
12
+ * `LlmModelReasoningInfo.efforts` is published in "adapter-preferred display
13
+ * order". A locally invented ordinal table — or worse, an ordering derived
14
+ * from the id's characters — would rank an adapter id such as `none`/`off`
15
+ * ABOVE `high` and cut the CHEAPEST possible request: a false-positive lock,
16
+ * the exact failure the proposal's Phase 6 gate measures.
17
+ *
18
+ * So the cap compares indices inside the route's own ordered effort list.
19
+ * When that list is unavailable (no route metadata, or an id the route does
20
+ * not publish) the cap is UNENFORCEABLE, not satisfied: the decision reports
21
+ * it through {@link FuseDecision.notEnforced} so the caller can warn and the
22
+ * panel can show the gap. It never fabricates a verdict.
23
+ */
24
+ export interface FuseBudget {
25
+ limitUsd: number;
26
+ spentUsd: number;
27
+ window: "month" | "day";
28
+ }
29
+ export interface FusePolicies {
30
+ /** Highest allowed reasoning effort, as an id from the route's own set. */
31
+ maxReasoningEffort?: string;
32
+ allowedModels?: string[];
33
+ denylistedProjects?: string[];
34
+ }
35
+ export interface FuseDecision {
36
+ allowed: boolean;
37
+ rule: string | null;
38
+ resetAt: string | null;
39
+ /**
40
+ * Configured policies that could not be evaluated for this call (an
41
+ * observable gap, never a silent pass). Empty when every policy applied.
42
+ */
43
+ notEnforced: string[];
44
+ }
45
+ export declare function fuseDecision(input: {
46
+ project: string;
47
+ model: string;
48
+ reasoningEffort?: string;
49
+ estimatedCostUsd: number;
50
+ budgets: FuseBudget[];
51
+ policies: FusePolicies;
52
+ now: Date;
53
+ /**
54
+ * The route's ordered reasoning-effort ids, cheapest first (harness
55
+ * `LlmModelReasoningInfo.efforts`, adapter-preferred display order).
56
+ * Absent/empty means the route publishes no reasoning metadata.
57
+ */
58
+ reasoningEfforts?: readonly string[];
59
+ }): FuseDecision;
package/dist/fuse.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The local fuse — the PRIMARY enforcement (<100ms, offline). Decides
3
+ * whether a call may proceed; the SaaS 429 is only the secondary gate.
4
+ * Pure: budgets arrive with spentUsd precomputed (the store provides it) and
5
+ * the route's reasoning set arrives pre-resolved (the caller provides it).
6
+ *
7
+ * ## Reasoning caps are ranked by the ROUTE, never by a local table
8
+ *
9
+ * `ReasoningEffortId` is adapter-owned and opaque: the harness core "brands
10
+ * identifiers but does not enumerate their values; each adapter owns the
11
+ * ordered set, display names, and optional deployment default", and
12
+ * `LlmModelReasoningInfo.efforts` is published in "adapter-preferred display
13
+ * order". A locally invented ordinal table — or worse, an ordering derived
14
+ * from the id's characters — would rank an adapter id such as `none`/`off`
15
+ * ABOVE `high` and cut the CHEAPEST possible request: a false-positive lock,
16
+ * the exact failure the proposal's Phase 6 gate measures.
17
+ *
18
+ * So the cap compares indices inside the route's own ordered effort list.
19
+ * When that list is unavailable (no route metadata, or an id the route does
20
+ * not publish) the cap is UNENFORCEABLE, not satisfied: the decision reports
21
+ * it through {@link FuseDecision.notEnforced} so the caller can warn and the
22
+ * panel can show the gap. It never fabricates a verdict.
23
+ */
24
+ /** Index of `effort` inside the route's ordered effort ids, or -1. */
25
+ function effortIndex(efforts, effort) {
26
+ return efforts.indexOf(effort);
27
+ }
28
+ export function fuseDecision(input) {
29
+ const notEnforced = [];
30
+ if (input.policies.denylistedProjects?.includes(input.project)) {
31
+ return {
32
+ allowed: false,
33
+ rule: `denylist:${input.project}`,
34
+ resetAt: null,
35
+ notEnforced,
36
+ };
37
+ }
38
+ if (input.policies.allowedModels &&
39
+ !input.policies.allowedModels.includes(input.model)) {
40
+ return {
41
+ allowed: false,
42
+ rule: `model_not_allowed:${input.model}`,
43
+ resetAt: null,
44
+ notEnforced,
45
+ };
46
+ }
47
+ const cap = input.policies.maxReasoningEffort;
48
+ const requested = input.reasoningEffort;
49
+ if (cap && requested) {
50
+ const efforts = input.reasoningEfforts ?? [];
51
+ const requestedIndex = effortIndex(efforts, requested);
52
+ const capIndex = effortIndex(efforts, cap);
53
+ if (requestedIndex < 0 || capIndex < 0) {
54
+ // Not rankable against this route — report it, never guess.
55
+ notEnforced.push(`reasoning_cap:${cap}`);
56
+ }
57
+ else if (requestedIndex > capIndex) {
58
+ return {
59
+ allowed: false,
60
+ rule: `reasoning_cap:${cap}`,
61
+ resetAt: null,
62
+ notEnforced,
63
+ };
64
+ }
65
+ }
66
+ for (const budget of input.budgets) {
67
+ if (budget.spentUsd + input.estimatedCostUsd > budget.limitUsd) {
68
+ const reset = new Date(input.now);
69
+ if (budget.window === "month")
70
+ reset.setUTCMonth(reset.getUTCMonth() + 1, 1);
71
+ else
72
+ reset.setUTCDate(reset.getUTCDate() + 1);
73
+ reset.setUTCHours(0, 0, 0, 0);
74
+ return {
75
+ allowed: false,
76
+ rule: "budget_exceeded",
77
+ resetAt: reset.toISOString(),
78
+ notEnforced,
79
+ };
80
+ }
81
+ }
82
+ return { allowed: true, rule: null, resetAt: null, notEnforced };
83
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The harness integration — the Cordis wiring that makes the pure modules
3
+ * live against the DeepSeek Harness runtime.
4
+ *
5
+ * Types are the REAL `@deepseek-ai/*` packages (exact-rc devDeps): the
6
+ * waterfall signatures, `PreStepDecision`, `LlmCallConfig` and `SessionEvent`
7
+ * come from the installed declarations, never from local re-declarations.
8
+ *
9
+ * ## Wiring, and the dispatch contracts each one obeys
10
+ *
11
+ * - `session/event` → **meter**. This event is `@mode emit`: a synchronous,
12
+ * fire-and-forget broadcast ("returned promises and values are not awaited
13
+ * or collected") emitted post-commit. An `async` listener therefore leaves a
14
+ * floating promise that a one-shot headless run can exit before it lands.
15
+ * The handler is consequently SYNCHRONOUS and only enqueues; a single shared
16
+ * drain writes to libsql, and the plugin flushes it on `turn/end`,
17
+ * `session/disposed`, and its own disposal — the same non-blocking-enqueue +
18
+ * explicit-drain contract the harness's own telemetry seam documents for
19
+ * this exact hot path.
20
+ * - `agent/pre-step` → **fuse**. `@mode waterfall`; returning
21
+ * `{ kind: 'reject' }` without calling `next()` short-circuits the step
22
+ * before any token is spent.
23
+ * - `agent/request` → **router**. `@mode waterfall`; always calls `next()` and
24
+ * rewrites the returned config, never the messages.
25
+ * - interval → **sync + policy pull**, registered through `ctx.effect()` so
26
+ * every resource Cordis does not manage itself is released on unload, hot
27
+ * reload, config edit, or loss of a required service.
28
+ *
29
+ * The plugin declares **no `inject`**: `ctx.logger` is framework surface, not
30
+ * an injectable service, and `tokenMeter` / `llm` are optional — a hard
31
+ * dependency on an optional service would leave the fiber PENDING forever,
32
+ * silently enforcing nothing.
33
+ */
34
+ import type { Context } from "@deepseek-ai/cordis";
35
+ import { type DshPluginConfig } from "./config.js";
36
+ /**
37
+ * The plugin entry points.
38
+ *
39
+ * Exported as NAMED functions (the shape the harness's own tutorials use) and
40
+ * re-assembled into the object form by `index.ts`, which is what the loader
41
+ * needs: it unwraps a module to `exports.default` when one exists
42
+ * (`Loader.unwrapExports`) and then reads `plugin.Config` to validate the row's
43
+ * config. A bare-function default export therefore arrives with `Config`
44
+ * undefined — the schema is skipped, defaults are never applied, and `apply`
45
+ * receives partial config. That failure is silent at the type level and only
46
+ * shows up against a real profile, which is why the smoke test boots one.
47
+ */
48
+ export declare function apply(ctx: Context, config: DshPluginConfig): void;