@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/LICENSE +21 -0
- package/README.md +214 -0
- package/cordis.patch.yml +29 -0
- package/dist/budget-tool.d.ts +66 -0
- package/dist/budget-tool.js +108 -0
- package/dist/config.d.ts +155 -0
- package/dist/config.js +125 -0
- package/dist/fuse.d.ts +59 -0
- package/dist/fuse.js +83 -0
- package/dist/harness.d.ts +48 -0
- package/dist/harness.js +773 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +55 -0
- package/dist/meter.d.ts +71 -0
- package/dist/meter.js +73 -0
- package/dist/pricing.d.ts +193 -0
- package/dist/pricing.js +450 -0
- package/dist/router.d.ts +30 -0
- package/dist/router.js +34 -0
- package/dist/store.d.ts +168 -0
- package/dist/store.js +412 -0
- package/dist/sync.d.ts +70 -0
- package/dist/sync.js +153 -0
- package/dist/wire.d.ts +52 -0
- package/dist/wire.js +15 -0
- package/package.json +64 -0
package/dist/harness.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
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 { createBudgetStatusTool, } from "./budget-tool.js";
|
|
35
|
+
import { assertUsableConfig } from "./config.js";
|
|
36
|
+
import { fuseDecision } from "./fuse.js";
|
|
37
|
+
import { hashSessionId, projectCall } from "./meter.js";
|
|
38
|
+
import { createPricingCache, estimateCostUsd, fetchPricingTable, } from "./pricing.js";
|
|
39
|
+
import { routeDecision } from "./router.js";
|
|
40
|
+
import { createLocalStore } from "./store.js";
|
|
41
|
+
import { fetchPolicy, syncBatch } from "./sync.js";
|
|
42
|
+
/** Fixed-density fallback estimate (chars/4) when no token meter is mounted. */
|
|
43
|
+
function estimateTokens(text) {
|
|
44
|
+
return Math.ceil(text.length / 4);
|
|
45
|
+
}
|
|
46
|
+
/** Estimated response size used to price a step before it runs. */
|
|
47
|
+
const DEFAULT_OUTPUT_TOKENS = 512;
|
|
48
|
+
function windowStart(now, window) {
|
|
49
|
+
const start = new Date(now);
|
|
50
|
+
if (window === "month")
|
|
51
|
+
start.setUTCDate(1);
|
|
52
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
53
|
+
return start.toISOString();
|
|
54
|
+
}
|
|
55
|
+
/** Window reset fallback when a 429 arrives without a reset_at. */
|
|
56
|
+
function windowReset(now, window) {
|
|
57
|
+
const reset = new Date(now);
|
|
58
|
+
if (window === "month")
|
|
59
|
+
reset.setUTCMonth(reset.getUTCMonth() + 1, 1);
|
|
60
|
+
else
|
|
61
|
+
reset.setUTCDate(reset.getUTCDate() + 1);
|
|
62
|
+
reset.setUTCHours(0, 0, 0, 0);
|
|
63
|
+
return reset.toISOString();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The plugin entry points.
|
|
67
|
+
*
|
|
68
|
+
* Exported as NAMED functions (the shape the harness's own tutorials use) and
|
|
69
|
+
* re-assembled into the object form by `index.ts`, which is what the loader
|
|
70
|
+
* needs: it unwraps a module to `exports.default` when one exists
|
|
71
|
+
* (`Loader.unwrapExports`) and then reads `plugin.Config` to validate the row's
|
|
72
|
+
* config. A bare-function default export therefore arrives with `Config`
|
|
73
|
+
* undefined — the schema is skipped, defaults are never applied, and `apply`
|
|
74
|
+
* receives partial config. That failure is silent at the type level and only
|
|
75
|
+
* shows up against a real profile, which is why the smoke test boots one.
|
|
76
|
+
*/
|
|
77
|
+
export function apply(ctx, config) {
|
|
78
|
+
// Refuse configuration the plugin cannot act on (see config.ts) — Cordis
|
|
79
|
+
// turns the throw into a FAILED fiber, which is the documented outcome.
|
|
80
|
+
assertUsableConfig(config);
|
|
81
|
+
const store = createLocalStore(config.storeUrl);
|
|
82
|
+
const project = config.project;
|
|
83
|
+
const dev = config.dev;
|
|
84
|
+
/** Per-session latest request header (model/provider/reasoning effort). */
|
|
85
|
+
const headers = new Map();
|
|
86
|
+
/** Per `sessionId:turn:step` step-open time, for the latency measure. */
|
|
87
|
+
const stepStarts = new Map();
|
|
88
|
+
/** Tool NAMES per step (metrics only — never arguments). */
|
|
89
|
+
const stepTools = new Map();
|
|
90
|
+
/** Session id → sha256, computed once per session. */
|
|
91
|
+
const sessionHashes = new Map();
|
|
92
|
+
/** Models already reported as unpriced — one warning each, not per call. */
|
|
93
|
+
const unpricedReported = new Set();
|
|
94
|
+
/** Routes whose reasoning set could not be resolved — warned once. */
|
|
95
|
+
const unrankableReported = new Set();
|
|
96
|
+
/** Reasoning-effort ids per route, resolved from the harness lazily. */
|
|
97
|
+
const routeEfforts = new Map();
|
|
98
|
+
const logger = ctx.logger;
|
|
99
|
+
const tokenMeter = () => ctx.get?.("tokenMeter") ?? undefined;
|
|
100
|
+
const llm = () => ctx.get?.("llm") ?? undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Documented load-time rule: *"A plugin should also reject schema-valid
|
|
103
|
+
* config that names an unavailable resource or provider as soon as it can
|
|
104
|
+
* resolve that reference."*
|
|
105
|
+
*
|
|
106
|
+
* Enforced for what this plugin can actually know: when the `llm` service
|
|
107
|
+
* IS mounted, any provider prefix named by `cascade` / `allowedModels` must
|
|
108
|
+
* be a registered route. A model id is NOT rejected for being absent from a
|
|
109
|
+
* catalog — the docs are explicit that catalog membership is advisory.
|
|
110
|
+
*
|
|
111
|
+
* Runs only when `llm` is present: a hard `inject` on an optional service
|
|
112
|
+
* would leave the fiber PENDING forever, silently enforcing nothing.
|
|
113
|
+
*/
|
|
114
|
+
function assertProvidersResolvable() {
|
|
115
|
+
const runtime = llm();
|
|
116
|
+
if (!runtime)
|
|
117
|
+
return;
|
|
118
|
+
let registered;
|
|
119
|
+
try {
|
|
120
|
+
registered = runtime.listProviders();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return; // topology unavailable — nothing to verify against
|
|
124
|
+
}
|
|
125
|
+
const known = new Set(registered.map((provider) => provider.id));
|
|
126
|
+
if (known.size === 0)
|
|
127
|
+
return;
|
|
128
|
+
const named = [...config.cascade, ...config.policies.allowedModels];
|
|
129
|
+
const missing = new Set();
|
|
130
|
+
for (const model of named) {
|
|
131
|
+
const prefix = model.split("/")[0];
|
|
132
|
+
// Only provider-prefixed ids name a route; a bare id does not.
|
|
133
|
+
if (!prefix || prefix === model)
|
|
134
|
+
continue;
|
|
135
|
+
if (!known.has(prefix))
|
|
136
|
+
missing.add(prefix);
|
|
137
|
+
}
|
|
138
|
+
if (missing.size > 0) {
|
|
139
|
+
throw new Error(`configuration names provider(s) with no registered route: ${[...missing].join(", ")}. Registered providers: ${[...known].join(", ")}.`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
assertProvidersResolvable();
|
|
143
|
+
// ── Pricing: config table + optional registry/gateway sync ─────────────
|
|
144
|
+
// Seeded from whatever the last successful fetch persisted, so enforcement
|
|
145
|
+
// is offline-capable from the first boot; the live fetch upgrades it and
|
|
146
|
+
// repersists. The generic resolver keys by `provider/model` first, then by
|
|
147
|
+
// the modal price across every provider that publishes the model.
|
|
148
|
+
const pricingCache = createPricingCache(() => fetchPricingTable({
|
|
149
|
+
registryUrl: config.pricingRegistryUrl,
|
|
150
|
+
gatewayUrl: config.pricingGatewayUrl || undefined,
|
|
151
|
+
gatewayProvider: config.pricingGatewayProvider || undefined,
|
|
152
|
+
gatewayApiKeyEnv: config.pricingGatewayApiKeyEnv || undefined,
|
|
153
|
+
override: config.pricingTable,
|
|
154
|
+
}), 3_600_000, (table) => void store.setPricingTable(table));
|
|
155
|
+
void store.pricingTable().then((persisted) => {
|
|
156
|
+
if (persisted)
|
|
157
|
+
pricingCache.hydrate(persisted);
|
|
158
|
+
if (config.pricingRegistryUrl || config.pricingGatewayUrl) {
|
|
159
|
+
pricingCache.refresh();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
const pricingTableNow = () => ({
|
|
163
|
+
...pricingCache.current(),
|
|
164
|
+
...config.pricingTable,
|
|
165
|
+
});
|
|
166
|
+
/**
|
|
167
|
+
* Price one call. An unresolvable model is reported once per model and
|
|
168
|
+
* prices at zero for the row (tokens are still real and still stored) —
|
|
169
|
+
* never silently: an unpriced call is flagged on the wire and, when the
|
|
170
|
+
* deployment set one, covered by the single org fallback rate.
|
|
171
|
+
*/
|
|
172
|
+
function priceFor(model, provider, counts) {
|
|
173
|
+
const priced = estimateCostUsd(pricingTableNow(), model, counts, {
|
|
174
|
+
provider,
|
|
175
|
+
aliases: config.pricingAliases,
|
|
176
|
+
});
|
|
177
|
+
if (priced)
|
|
178
|
+
return { costUsd: priced.costUsd, unpriced: false };
|
|
179
|
+
if (model && !unpricedReported.has(model)) {
|
|
180
|
+
unpricedReported.add(model);
|
|
181
|
+
logger.warn(`[dsh] no price for model "%s" on route "%s" — call metered but priced at zero (unpriced); set pricingAliases/pricingTable or an unpricedFallback`, model, provider);
|
|
182
|
+
}
|
|
183
|
+
if (config.unpricedFallback &&
|
|
184
|
+
typeof config.unpricedFallback.inputCentsPerM === "number" &&
|
|
185
|
+
typeof config.unpricedFallback.outputCentsPerM === "number") {
|
|
186
|
+
const fb = config.unpricedFallback;
|
|
187
|
+
const cents = counts.inputTokens * fb.inputCentsPerM +
|
|
188
|
+
counts.outputTokens * fb.outputCentsPerM +
|
|
189
|
+
counts.cacheReadTokens * (fb.cacheReadCentsPerM ?? fb.inputCentsPerM) +
|
|
190
|
+
(counts.cacheWriteTokens ?? 0) *
|
|
191
|
+
(fb.cacheWriteCentsPerM ?? fb.inputCentsPerM);
|
|
192
|
+
return { costUsd: cents / 1e6 / 100, unpriced: true };
|
|
193
|
+
}
|
|
194
|
+
return { costUsd: 0, unpriced: true };
|
|
195
|
+
}
|
|
196
|
+
// ── Meter: enqueue synchronously, drain off the hot path ───────────────
|
|
197
|
+
let queue = [];
|
|
198
|
+
let draining = null;
|
|
199
|
+
async function drainLoop() {
|
|
200
|
+
while (queue.length > 0) {
|
|
201
|
+
const batch = queue;
|
|
202
|
+
queue = [];
|
|
203
|
+
for (const row of batch) {
|
|
204
|
+
try {
|
|
205
|
+
await store.record({
|
|
206
|
+
sessionId: await row.sessionHash,
|
|
207
|
+
project,
|
|
208
|
+
costUsd: row.costUsd,
|
|
209
|
+
at: row.at,
|
|
210
|
+
model: row.model,
|
|
211
|
+
provider: row.provider,
|
|
212
|
+
reasoningEffort: row.reasoningEffort,
|
|
213
|
+
inputTokens: row.counts.inputTokens,
|
|
214
|
+
outputTokens: row.counts.outputTokens,
|
|
215
|
+
cacheReadTokens: row.counts.cacheReadTokens,
|
|
216
|
+
cacheWriteTokens: row.counts.cacheWriteTokens,
|
|
217
|
+
durationMs: row.durationMs,
|
|
218
|
+
unpriced: row.unpriced,
|
|
219
|
+
eventId: row.eventId,
|
|
220
|
+
tools: row.tools,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
logger.warn("[dsh] meter write failed", { err: String(err) });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function scheduleDrain() {
|
|
230
|
+
if (draining)
|
|
231
|
+
return;
|
|
232
|
+
draining = drainLoop().finally(() => {
|
|
233
|
+
draining = null;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** Settle once every enqueued row has been handed to the store. */
|
|
237
|
+
async function flushMeter() {
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (!draining && queue.length > 0)
|
|
240
|
+
scheduleDrain();
|
|
241
|
+
if (!draining)
|
|
242
|
+
return;
|
|
243
|
+
await draining;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function sessionHash(sessionId) {
|
|
247
|
+
let cached = sessionHashes.get(sessionId);
|
|
248
|
+
if (!cached) {
|
|
249
|
+
cached = hashSessionId(sessionId);
|
|
250
|
+
sessionHashes.set(sessionId, cached);
|
|
251
|
+
}
|
|
252
|
+
return cached;
|
|
253
|
+
}
|
|
254
|
+
// ── Firehose ───────────────────────────────────────────────────────────
|
|
255
|
+
ctx.on("session/event", (session, event) => {
|
|
256
|
+
const sessionId = session.id;
|
|
257
|
+
switch (event.type) {
|
|
258
|
+
case "step/start": {
|
|
259
|
+
stepStarts.set(`${sessionId}:${event.data.turn}:${event.data.step}`, event.time);
|
|
260
|
+
stepTools.set(`${sessionId}:${event.data.turn}:${event.data.step}`, []);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
case "tool/call": {
|
|
264
|
+
// Metrics only: the tool NAME, never the arguments.
|
|
265
|
+
const key = `${sessionId}:${event.data.turn}:${event.data.step}`;
|
|
266
|
+
const tools = stepTools.get(key);
|
|
267
|
+
if (tools && !tools.includes(event.data.name))
|
|
268
|
+
tools.push(event.data.name);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case "request/header": {
|
|
272
|
+
const call = event.data.header.config;
|
|
273
|
+
headers.set(sessionId, {
|
|
274
|
+
provider: call.provider,
|
|
275
|
+
model: call.model,
|
|
276
|
+
reasoningEffort: call.reasoningEffort,
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
case "assistant/message": {
|
|
281
|
+
if (!event.data.usage)
|
|
282
|
+
return;
|
|
283
|
+
// A request succeeded — the next request of this turn is a fresh
|
|
284
|
+
// attempt, not a retry, so the escalation counter clears.
|
|
285
|
+
retryCounts.delete(retryKey(sessionId, event.data.turn));
|
|
286
|
+
const header = headers.get(sessionId);
|
|
287
|
+
// `AssistantMessage.source` IS `ModelMessageSource` (the harness
|
|
288
|
+
// types an assistant message's provenance directly), so the model
|
|
289
|
+
// and provider that served this call need no lookup and no cast.
|
|
290
|
+
const source = event.data.message.source;
|
|
291
|
+
const stepKey = `${sessionId}:${event.data.turn}:${event.data.step}`;
|
|
292
|
+
// Tool names observed during this step — consumed here, then
|
|
293
|
+
// dropped so a long session's map cannot grow unbounded.
|
|
294
|
+
const tools = stepTools.get(stepKey) ?? [];
|
|
295
|
+
stepTools.delete(stepKey);
|
|
296
|
+
const projection = projectCall({
|
|
297
|
+
provenance: { provider: source.provider, model: source.model },
|
|
298
|
+
header,
|
|
299
|
+
usage: event.data.usage,
|
|
300
|
+
settledAt: event.time,
|
|
301
|
+
stepStartedAt: stepStarts.get(stepKey),
|
|
302
|
+
});
|
|
303
|
+
stepStarts.delete(stepKey);
|
|
304
|
+
const priced = priceFor(projection.model, projection.provider, projection.counts);
|
|
305
|
+
queue.push({
|
|
306
|
+
sessionHash: sessionHash(sessionId),
|
|
307
|
+
at: new Date(event.time).toISOString(),
|
|
308
|
+
model: projection.model,
|
|
309
|
+
provider: projection.provider,
|
|
310
|
+
reasoningEffort: projection.reasoningEffort,
|
|
311
|
+
counts: projection.counts,
|
|
312
|
+
costUsd: priced.costUsd,
|
|
313
|
+
unpriced: priced.unpriced,
|
|
314
|
+
eventId: crypto.randomUUID(),
|
|
315
|
+
tools,
|
|
316
|
+
durationMs: projection.durationMs,
|
|
317
|
+
});
|
|
318
|
+
scheduleDrain();
|
|
319
|
+
logger.info("[dsh] metered", {
|
|
320
|
+
model: projection.model,
|
|
321
|
+
costUsd: priced.costUsd,
|
|
322
|
+
unpriced: priced.unpriced,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
case "turn/end": {
|
|
327
|
+
for (const key of stepStarts.keys()) {
|
|
328
|
+
if (key.startsWith(`${sessionId}:`))
|
|
329
|
+
stepStarts.delete(key);
|
|
330
|
+
}
|
|
331
|
+
for (const key of retryCounts.keys()) {
|
|
332
|
+
if (key.startsWith(`${sessionId}\u0000`))
|
|
333
|
+
retryCounts.delete(key);
|
|
334
|
+
}
|
|
335
|
+
void flushMeter();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
default:
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
ctx.on("session/disposed", (session) => {
|
|
343
|
+
headers.delete(session.id);
|
|
344
|
+
sessionHashes.delete(session.id);
|
|
345
|
+
void flushMeter();
|
|
346
|
+
});
|
|
347
|
+
// ── Policy: the panel publishes it, the local fuse enforces it ─────────
|
|
348
|
+
/**
|
|
349
|
+
* Budgets/policies in force for this call. The SaaS-published policy wins
|
|
350
|
+
* per field when present (the panel is the control plane); the deployment's
|
|
351
|
+
* `cordis.yml` values are the offline fallback, so enforcement never
|
|
352
|
+
* depends on the network being reachable at boot.
|
|
353
|
+
*/
|
|
354
|
+
async function effectivePolicy() {
|
|
355
|
+
const remote = await store.remotePolicy();
|
|
356
|
+
const now = new Date();
|
|
357
|
+
const limits = remote?.budgets?.length ? remote.budgets : config.budgets;
|
|
358
|
+
/**
|
|
359
|
+
* Which budgets govern THIS call. A published budget applies only to
|
|
360
|
+
* the calls in its scope, never globally:
|
|
361
|
+
* - `org` → every call on this machine; spend is this machine's total.
|
|
362
|
+
* - `project` → only calls whose project label matches `reference`; spend
|
|
363
|
+
* is this project's.
|
|
364
|
+
* - `dev` → only calls whose dev matches `reference`; spend is this
|
|
365
|
+
* dev's.
|
|
366
|
+
* A local `config.budgets` entry has no scope and applies to this
|
|
367
|
+
* project on this machine (the offline fallback's meaning).
|
|
368
|
+
*/
|
|
369
|
+
const budgets = [];
|
|
370
|
+
for (const budget of limits) {
|
|
371
|
+
const scope = budget.scope;
|
|
372
|
+
if (scope === "project") {
|
|
373
|
+
const reference = budget.reference;
|
|
374
|
+
// Absent reference = legacy publication: apply to this project.
|
|
375
|
+
if (reference !== undefined && reference !== project)
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (scope === "dev") {
|
|
379
|
+
const reference = budget.reference;
|
|
380
|
+
if (reference !== undefined && reference !== dev)
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const spentUsd = scope === "project"
|
|
384
|
+
? await store.spentForWindow({
|
|
385
|
+
project,
|
|
386
|
+
since: windowStart(now, budget.window),
|
|
387
|
+
})
|
|
388
|
+
: scope === "dev"
|
|
389
|
+
? await store.spentForWindow({
|
|
390
|
+
dev,
|
|
391
|
+
since: windowStart(now, budget.window),
|
|
392
|
+
})
|
|
393
|
+
: await store.spentForWindow({
|
|
394
|
+
since: windowStart(now, budget.window),
|
|
395
|
+
});
|
|
396
|
+
budgets.push({
|
|
397
|
+
limitUsd: budget.limitUsd,
|
|
398
|
+
window: budget.window,
|
|
399
|
+
spentUsd,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
budgets,
|
|
404
|
+
// The SaaS-published value wins per field when it is present; the
|
|
405
|
+
// config's empty encodings ("no cap", "all allowed", "none denied")
|
|
406
|
+
// translate back to absent so the fuse treats them as unrestricted.
|
|
407
|
+
policies: {
|
|
408
|
+
maxReasoningEffort: remote?.maxReasoningEffort ||
|
|
409
|
+
config.policies.maxReasoningEffort ||
|
|
410
|
+
undefined,
|
|
411
|
+
allowedModels: remote?.allowedModels?.length
|
|
412
|
+
? remote.allowedModels
|
|
413
|
+
: config.policies.allowedModels.length > 0
|
|
414
|
+
? config.policies.allowedModels
|
|
415
|
+
: undefined,
|
|
416
|
+
denylistedProjects: remote?.denylistedProjects?.length
|
|
417
|
+
? remote.denylistedProjects
|
|
418
|
+
: config.policies.denylistedProjects.length > 0
|
|
419
|
+
? config.policies.denylistedProjects
|
|
420
|
+
: undefined,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
// ── Model-facing status: the documented "separate model-facing ask tool" ─
|
|
425
|
+
/**
|
|
426
|
+
* The model-facing view of the policy state — same scope filtering as
|
|
427
|
+
* `effectivePolicy`, so the numbers the agent reads are exactly the numbers
|
|
428
|
+
* the fuse enforces. A local `config.budgets` entry (no scope) is the
|
|
429
|
+
* offline fallback and reads as an org-level budget on this machine.
|
|
430
|
+
*/
|
|
431
|
+
async function readBudgetStatus() {
|
|
432
|
+
const now = new Date();
|
|
433
|
+
const remote = await store.remotePolicy();
|
|
434
|
+
const limits = remote?.budgets?.length ? remote.budgets : config.budgets;
|
|
435
|
+
const budgets = [];
|
|
436
|
+
for (const budget of limits) {
|
|
437
|
+
const scope = budget.scope ?? "org";
|
|
438
|
+
const reference = budget.reference;
|
|
439
|
+
if (scope === "project" &&
|
|
440
|
+
reference !== undefined &&
|
|
441
|
+
reference !== project)
|
|
442
|
+
continue;
|
|
443
|
+
if (scope === "dev" && reference !== undefined && reference !== dev)
|
|
444
|
+
continue;
|
|
445
|
+
const spentUsd = scope === "project"
|
|
446
|
+
? await store.spentForWindow({
|
|
447
|
+
project,
|
|
448
|
+
since: windowStart(now, budget.window),
|
|
449
|
+
})
|
|
450
|
+
: scope === "dev"
|
|
451
|
+
? await store.spentForWindow({
|
|
452
|
+
dev,
|
|
453
|
+
since: windowStart(now, budget.window),
|
|
454
|
+
})
|
|
455
|
+
: await store.spentForWindow({
|
|
456
|
+
since: windowStart(now, budget.window),
|
|
457
|
+
});
|
|
458
|
+
budgets.push({
|
|
459
|
+
scope,
|
|
460
|
+
...(reference !== undefined ? { reference } : {}),
|
|
461
|
+
window: budget.window,
|
|
462
|
+
limitUsd: budget.limitUsd,
|
|
463
|
+
spentUsd,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const block = await store.remoteBlockFor({ project, dev });
|
|
467
|
+
return {
|
|
468
|
+
project,
|
|
469
|
+
dev,
|
|
470
|
+
at: now.toISOString(),
|
|
471
|
+
budgets,
|
|
472
|
+
block,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Register `dsh_budget_status` for the agent when the composition mounts
|
|
477
|
+
* the `tools` service (`@deepseek-ai/dsh-tools`). Registration is optional
|
|
478
|
+
* by design — the plugin declares no `inject`, so an absent service skips
|
|
479
|
+
* the surface instead of holding the fiber PENDING. Status-only: the tool
|
|
480
|
+
* reads state and never decides.
|
|
481
|
+
*/
|
|
482
|
+
const toolsRuntime = ctx.get?.("tools");
|
|
483
|
+
if (config.budgetStatusTool && toolsRuntime) {
|
|
484
|
+
ctx.effect(() => toolsRuntime.register(createBudgetStatusTool(readBudgetStatus)));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The route's ordered reasoning-effort ids, from the harness itself: the
|
|
488
|
+
* cap is ranked by index in the ADAPTER's own set, never by a local table.
|
|
489
|
+
* Cached per route; `undefined` when no llm service or no metadata, which
|
|
490
|
+
* the fuse reports as "not enforced" instead of guessing.
|
|
491
|
+
*/
|
|
492
|
+
async function reasoningEffortsFor(provider, model) {
|
|
493
|
+
const runtime = llm();
|
|
494
|
+
if (!runtime || !provider || !model)
|
|
495
|
+
return undefined;
|
|
496
|
+
const key = `${provider}\u0000${model}`;
|
|
497
|
+
const cached = routeEfforts.get(key);
|
|
498
|
+
if (cached)
|
|
499
|
+
return cached;
|
|
500
|
+
try {
|
|
501
|
+
const info = await runtime.resolveModelInfo(provider, model);
|
|
502
|
+
const ids = (info.reasoning?.efforts ?? []).map((effort) => String(effort.id));
|
|
503
|
+
if (ids.length > 0) {
|
|
504
|
+
routeEfforts.set(key, ids);
|
|
505
|
+
return ids;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// The route publishes no reasoning metadata — reported below.
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
// ── Fuse: the primary gate, before any token is spent ──────────────────
|
|
514
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
515
|
+
const agentId = payload.agent.id;
|
|
516
|
+
const header = headers.get(agentId);
|
|
517
|
+
const model = header?.model ||
|
|
518
|
+
payload.agent.options?.model ||
|
|
519
|
+
"";
|
|
520
|
+
const provider = header?.provider ||
|
|
521
|
+
payload.agent.options
|
|
522
|
+
?.provider ||
|
|
523
|
+
"";
|
|
524
|
+
// Sync fidelity: a SaaS 429 engages the offline fuse until the
|
|
525
|
+
// window resets — the team blocked centrally is blocked locally.
|
|
526
|
+
// Blocks are scoped: a project budget 429 freezes only that
|
|
527
|
+
// project on this machine (org blocks freeze everything).
|
|
528
|
+
const remoteBlock = await store.remoteBlockFor({ project, dev });
|
|
529
|
+
if (remoteBlock) {
|
|
530
|
+
logger.warn("[dsh] fuse blocked (remote 429 active)", {
|
|
531
|
+
rule: remoteBlock.rule,
|
|
532
|
+
resetAt: remoteBlock.resetAt,
|
|
533
|
+
project,
|
|
534
|
+
});
|
|
535
|
+
return { kind: "reject" };
|
|
536
|
+
}
|
|
537
|
+
const estimatedCostUsd = estimateStepCostUsd(payload.agent, payload.messages, model, provider);
|
|
538
|
+
const { budgets, policies } = await effectivePolicy();
|
|
539
|
+
const efforts = policies.maxReasoningEffort
|
|
540
|
+
? await reasoningEffortsFor(provider, model)
|
|
541
|
+
: undefined;
|
|
542
|
+
const decision = fuseDecision({
|
|
543
|
+
project,
|
|
544
|
+
model,
|
|
545
|
+
reasoningEffort: header?.reasoningEffort,
|
|
546
|
+
estimatedCostUsd,
|
|
547
|
+
budgets,
|
|
548
|
+
policies,
|
|
549
|
+
now: new Date(),
|
|
550
|
+
reasoningEfforts: efforts,
|
|
551
|
+
});
|
|
552
|
+
for (const gap of decision.notEnforced) {
|
|
553
|
+
const key = `${gap}\u0000${provider}/${model}`;
|
|
554
|
+
if (unrankableReported.has(key))
|
|
555
|
+
continue;
|
|
556
|
+
unrankableReported.add(key);
|
|
557
|
+
logger.warn("[dsh] policy not enforceable on this route (%s): the adapter publishes no matching reasoning-effort id, so the cap was not applied", gap);
|
|
558
|
+
}
|
|
559
|
+
if (!decision.allowed) {
|
|
560
|
+
await store.recordCut({
|
|
561
|
+
project,
|
|
562
|
+
rule: decision.rule ?? "unknown",
|
|
563
|
+
});
|
|
564
|
+
logger.warn("[dsh] fuse blocked", {
|
|
565
|
+
rule: decision.rule,
|
|
566
|
+
project,
|
|
567
|
+
});
|
|
568
|
+
return { kind: "reject" };
|
|
569
|
+
}
|
|
570
|
+
return next();
|
|
571
|
+
});
|
|
572
|
+
/**
|
|
573
|
+
* Price the step before it runs. Prefers the harness's own replay-aware
|
|
574
|
+
* token meter (`ctx.tokenMeter.measure`, the documented request-pressure
|
|
575
|
+
* snapshot) and falls back to the fixed chars/4 heuristic only when no
|
|
576
|
+
* meter is mounted, so the estimate does not drift from the harness's own
|
|
577
|
+
* accounting on a full composition.
|
|
578
|
+
*
|
|
579
|
+
* Uses the meter's `surfaceTokens` — the surface-only route-priced total —
|
|
580
|
+
* as the input estimate, never `totalTokens`, because the docs define
|
|
581
|
+
* `totalTokens` as *request-and-response* pressure; adding
|
|
582
|
+
* `DEFAULT_OUTPUT_TOKENS` on top of it would count the response twice and
|
|
583
|
+
* manufacture false-positive cuts.
|
|
584
|
+
*/
|
|
585
|
+
function estimateStepCostUsd(agent, messages, model, provider) {
|
|
586
|
+
let inputTokens = null;
|
|
587
|
+
const meter = tokenMeter();
|
|
588
|
+
if (meter) {
|
|
589
|
+
try {
|
|
590
|
+
const measurement = meter.measure(agent.session);
|
|
591
|
+
inputTokens = measurement.surfaceTokens;
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
inputTokens = null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (inputTokens === null) {
|
|
598
|
+
inputTokens = estimateTokens(messages.map((message) => JSON.stringify(message)).join("\n"));
|
|
599
|
+
}
|
|
600
|
+
return priceFor(model, provider, {
|
|
601
|
+
inputTokens,
|
|
602
|
+
outputTokens: DEFAULT_OUTPUT_TOKENS,
|
|
603
|
+
cacheReadTokens: 0,
|
|
604
|
+
cacheWriteTokens: 0,
|
|
605
|
+
}).costUsd;
|
|
606
|
+
}
|
|
607
|
+
// ── Router: policy-compliant model rewrite ─────────────────────────────
|
|
608
|
+
/**
|
|
609
|
+
* Escalation counter per (agent, turn): bumped on `agent/request-error`
|
|
610
|
+
* (the documented "you own recovery" seam) and cleared when a request
|
|
611
|
+
* succeeds. `payload.step` is NOT an attempt count — it increments once per
|
|
612
|
+
* step of a normal tool-using turn, so using it would walk the cascade to
|
|
613
|
+
* the most expensive model on a routine 5-step turn (the visible quality
|
|
614
|
+
* degradation the proposal rules out). Escalation therefore only happens
|
|
615
|
+
* when the immediately previous request of this turn actually failed.
|
|
616
|
+
*/
|
|
617
|
+
const retryCounts = new Map();
|
|
618
|
+
const retryKey = (agentId, turn) => `${agentId}\u0000${turn}`;
|
|
619
|
+
ctx.on("agent/request-error", async (payload, next) => {
|
|
620
|
+
// Observe + delegate: the plugin never owns provider retries.
|
|
621
|
+
const key = retryKey(payload.agent.id, payload.turn);
|
|
622
|
+
retryCounts.set(key, (retryCounts.get(key) ?? 0) + 1);
|
|
623
|
+
return next();
|
|
624
|
+
});
|
|
625
|
+
ctx.on("agent/request", async (payload, next) => {
|
|
626
|
+
const current = await next();
|
|
627
|
+
const attempt = retryCounts.get(retryKey(payload.agent.id, payload.turn)) ?? 0;
|
|
628
|
+
const routed = routeDecision({
|
|
629
|
+
requestedModel: current.model,
|
|
630
|
+
attempt,
|
|
631
|
+
cascade: config.cascade,
|
|
632
|
+
policies: config.policies,
|
|
633
|
+
});
|
|
634
|
+
if (routed.model !== current.model) {
|
|
635
|
+
logger.info("[dsh] routed", {
|
|
636
|
+
from: current.model,
|
|
637
|
+
to: routed.model,
|
|
638
|
+
reason: routed.reason,
|
|
639
|
+
attempt,
|
|
640
|
+
});
|
|
641
|
+
return { ...current, model: routed.model };
|
|
642
|
+
}
|
|
643
|
+
return current;
|
|
644
|
+
});
|
|
645
|
+
// ── SaaS sync + policy pull, released by ctx.effect on unload ──────────
|
|
646
|
+
ctx.effect(() => {
|
|
647
|
+
if (!config.baseUrl || !config.orgKey)
|
|
648
|
+
return () => undefined;
|
|
649
|
+
const target = {
|
|
650
|
+
baseUrl: config.baseUrl,
|
|
651
|
+
orgKey: config.orgKey,
|
|
652
|
+
};
|
|
653
|
+
let syncing = false;
|
|
654
|
+
let refreshing = false;
|
|
655
|
+
let lastPolicyAt = 0;
|
|
656
|
+
async function syncOnce() {
|
|
657
|
+
if (syncing)
|
|
658
|
+
return;
|
|
659
|
+
syncing = true;
|
|
660
|
+
try {
|
|
661
|
+
await flushMeter();
|
|
662
|
+
const pending = await store.pendingSync(500);
|
|
663
|
+
const pendingCuts = await store.pendingCuts(100);
|
|
664
|
+
if (pending.length === 0 && pendingCuts.length === 0)
|
|
665
|
+
return;
|
|
666
|
+
const events = [
|
|
667
|
+
...pending.map((row) => ({
|
|
668
|
+
v: 1,
|
|
669
|
+
event_id: row.eventId,
|
|
670
|
+
session_id: row.sessionId,
|
|
671
|
+
project: row.project,
|
|
672
|
+
dev,
|
|
673
|
+
provider: row.provider || "unknown",
|
|
674
|
+
model: row.model || "unknown",
|
|
675
|
+
reasoning_effort: row.reasoningEffort || undefined,
|
|
676
|
+
input_tokens: row.inputTokens,
|
|
677
|
+
output_tokens: row.outputTokens,
|
|
678
|
+
cache_read_tokens: row.cacheReadTokens,
|
|
679
|
+
cache_write_tokens: row.cacheWriteTokens,
|
|
680
|
+
duration_ms: row.durationMs ?? undefined,
|
|
681
|
+
cost_usd: row.costUsd,
|
|
682
|
+
...(row.unpriced ? { unpriced: true } : {}),
|
|
683
|
+
started_at: row.at,
|
|
684
|
+
...(row.tools.length > 0 ? { tools: row.tools } : {}),
|
|
685
|
+
})),
|
|
686
|
+
...pendingCuts.map((cut) => ({
|
|
687
|
+
kind: "cut",
|
|
688
|
+
project: cut.project,
|
|
689
|
+
rule: cut.rule,
|
|
690
|
+
})),
|
|
691
|
+
];
|
|
692
|
+
const result = await syncBatch({ ...target, events });
|
|
693
|
+
if (result.blocked) {
|
|
694
|
+
logger.warn("[dsh] SaaS blocked — engaging the local fuse", {
|
|
695
|
+
rule: result.blockedRule,
|
|
696
|
+
resetAt: result.resetAt,
|
|
697
|
+
scope: result.blockScope,
|
|
698
|
+
reference: result.blockReference,
|
|
699
|
+
});
|
|
700
|
+
await store.setRemoteBlock({
|
|
701
|
+
rule: result.blockedRule ?? "budget_exceeded",
|
|
702
|
+
resetAt: result.resetAt ?? windowReset(new Date(), "day"),
|
|
703
|
+
...(result.blockScope ? { scope: result.blockScope } : {}),
|
|
704
|
+
...(result.blockReference
|
|
705
|
+
? { reference: result.blockReference }
|
|
706
|
+
: {}),
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (!result.delivered) {
|
|
711
|
+
// Keep the rows: a failed batch is not a delivered batch.
|
|
712
|
+
logger.warn("[dsh] sync failed — rows retained for retry", {
|
|
713
|
+
status: result.status,
|
|
714
|
+
error: result.error,
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
await store.markSynced(pending.map((row) => row.id));
|
|
719
|
+
await store.markCutsSynced(pendingCuts.map((cut) => cut.id));
|
|
720
|
+
await store.setRemoteBlock(null);
|
|
721
|
+
}
|
|
722
|
+
finally {
|
|
723
|
+
syncing = false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function refreshPolicyOnce() {
|
|
727
|
+
if (refreshing)
|
|
728
|
+
return;
|
|
729
|
+
refreshing = true;
|
|
730
|
+
try {
|
|
731
|
+
const { policy, error } = await fetchPolicy(target);
|
|
732
|
+
if (error) {
|
|
733
|
+
if (error !== "unauthorized") {
|
|
734
|
+
logger.warn("[dsh] policy refresh failed — keeping the last published policy", {
|
|
735
|
+
error,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await store.setRemotePolicy(policy);
|
|
741
|
+
lastPolicyAt = Date.now();
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
refreshing = false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Pull the org policy immediately so a freshly started session enforces
|
|
748
|
+
// panel state rather than whatever the YAML happens to say.
|
|
749
|
+
void refreshPolicyOnce();
|
|
750
|
+
const timer = setInterval(() => {
|
|
751
|
+
void syncOnce().catch((err) => {
|
|
752
|
+
logger.warn("[dsh] sync failed", { err: String(err) });
|
|
753
|
+
});
|
|
754
|
+
if (Date.now() - lastPolicyAt >= config.policyRefreshMs) {
|
|
755
|
+
void refreshPolicyOnce().catch((err) => {
|
|
756
|
+
logger.warn("[dsh] policy refresh failed", { err: String(err) });
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}, config.syncIntervalMs);
|
|
760
|
+
return async () => {
|
|
761
|
+
clearInterval(timer);
|
|
762
|
+
await flushMeter();
|
|
763
|
+
store.close();
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
// Local-only mode still owns the store; release it on unload.
|
|
767
|
+
if (!config.baseUrl || !config.orgKey) {
|
|
768
|
+
ctx.effect(() => async () => {
|
|
769
|
+
await flushMeter();
|
|
770
|
+
store.close();
|
|
771
|
+
}, "dsh-meter-flush");
|
|
772
|
+
}
|
|
773
|
+
}
|