dsh-plugin-usage 0.1.4 → 0.2.2
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/README.md +60 -5
- package/image-1.png +0 -0
- package/image.png +0 -0
- package/lib/client/index.js +241 -18
- package/lib/host/aggregate.js +98 -0
- package/lib/host/client-source.js +34 -0
- package/lib/host/fold.js +173 -0
- package/lib/host/index.js +68 -425
- package/lib/host/query.js +157 -0
- package/lib/host/rpc.js +191 -0
- package/lib/host/scan.js +233 -0
- package/lib/host/settings.js +100 -0
- package/lib/host/state.js +32 -0
- package/lib/host/time.js +82 -0
- package/package.json +18 -8
package/lib/host/rpc.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC registration.
|
|
3
|
+
*
|
|
4
|
+
* Dynamic-plugin runs expose `harness` directly; static (bundle) installs
|
|
5
|
+
* expose the Connection service. Support both from one file, plus the
|
|
6
|
+
* official Typert gateway path (P0-3).
|
|
7
|
+
*
|
|
8
|
+
* Host side, preference order:
|
|
9
|
+
* 1. dynamic-plugin run -> `harness.handle(endpoint, fn)`
|
|
10
|
+
* 2. Typert gateway present -> `typert.register(channel, endpoints)`
|
|
11
|
+
* 3. static (bundle) install -> Connection
|
|
12
|
+
*
|
|
13
|
+
* Connection has two registries and they are NOT interchangeable:
|
|
14
|
+
*
|
|
15
|
+
* - `connection.fetch.register(route)` adds one exact route to the shared
|
|
16
|
+
* `/api` dispatch table. It only touches the service's own map, so it
|
|
17
|
+
* works from any fiber.
|
|
18
|
+
* - `connection.rpc.handle(channel, handler)` mounts a physical prefix route
|
|
19
|
+
* through `owner.webServer.register(route)`, where `owner` is the fiber
|
|
20
|
+
* that READ the service. On 0.2.x every plugin fiber is a strict proxy: a
|
|
21
|
+
* plugin whose `inject` is `['timer']` throws `cannot get property
|
|
22
|
+
* "webServer" without inject` inside that registration, `effect()`
|
|
23
|
+
* rethrows it, and the channel silently never mounts — observed as HTTP
|
|
24
|
+
* 405 on `/usage/*` with no host-side message. Adding `webServer` to the
|
|
25
|
+
* plugin's static `inject` would "fix" it only for compositions that own a
|
|
26
|
+
* web server, and would gate dynamic-plugin loads on a service they never
|
|
27
|
+
* get, so the fetch registry is the correct seam for a bundle install.
|
|
28
|
+
*
|
|
29
|
+
* Therefore: register exact Fetch routes on the shared `/api` channel when
|
|
30
|
+
* that registry exists, and keep the legacy `/usage` prefix channel as a
|
|
31
|
+
* best-effort fallback for older hosts. The browser half calls the Fetch route
|
|
32
|
+
* first and falls back to the legacy channel on "route missing".
|
|
33
|
+
*
|
|
34
|
+
* Wire format of one Fetch route (POST `/api/usage/<endpoint>`):
|
|
35
|
+
* request body = JSON of the endpoint args (or empty for no args)
|
|
36
|
+
* response body = `{ ok: true, value }` | `{ ok: false, error: { code, message } }`
|
|
37
|
+
* i.e. the same result shape the legacy RPC channel wraps, minus its envelope.
|
|
38
|
+
*/
|
|
39
|
+
/** Absolute logical channel owned by this plugin on pre-0.2 hosts. */
|
|
40
|
+
export const USAGE_RPC_CHANNEL = '/usage';
|
|
41
|
+
/** Shared-channel path prefix owned by this plugin (`/api` is Connection's mount). */
|
|
42
|
+
export const USAGE_API_PREFIX = '/api/usage';
|
|
43
|
+
/**
|
|
44
|
+
* Exact Fetch route path for one endpoint.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} endpoint - endpoint name such as `query`.
|
|
47
|
+
* @returns {string} absolute path below `/api`, as `connection.fetch.register` requires.
|
|
48
|
+
*/
|
|
49
|
+
export function fetchRoutePath(endpoint) {
|
|
50
|
+
return USAGE_API_PREFIX + '/' + endpoint;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Failure result in the Connection RPC result shape.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} message - human-readable failure.
|
|
56
|
+
* @param {string} [code] - stable error code.
|
|
57
|
+
* @returns {{ ok: false, error: { code: string, message: string, details: object } }} failure result.
|
|
58
|
+
*/
|
|
59
|
+
export function errorResult(message, code = 'internal') {
|
|
60
|
+
return { ok: false, error: { code, message, details: {} } };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Run one endpoint through the handler map, converting throws into results.
|
|
64
|
+
*
|
|
65
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
66
|
+
* @param {string} endpoint - endpoint name.
|
|
67
|
+
* @param {unknown} args - decoded endpoint arguments.
|
|
68
|
+
* @returns {Promise<{ ok: true, value: any } | { ok: false, error: object }>} RPC result.
|
|
69
|
+
*/
|
|
70
|
+
export async function dispatch(handlers, endpoint, args) {
|
|
71
|
+
const fn = handlers.get(endpoint);
|
|
72
|
+
if (fn === undefined)
|
|
73
|
+
return errorResult(`unknown endpoint: ${endpoint}`, 'bad-request');
|
|
74
|
+
try {
|
|
75
|
+
return { ok: true, value: await fn(args) };
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
return errorResult(String((error && error.message) || error));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Build the Fetch-shaped handler for one exact route.
|
|
83
|
+
*
|
|
84
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
85
|
+
* @param {string} endpoint - endpoint name.
|
|
86
|
+
* @returns {(request: Request) => Promise<Response>} route handler.
|
|
87
|
+
*/
|
|
88
|
+
export function createFetchRoute(handlers, endpoint) {
|
|
89
|
+
return async function fetchRoute(request) {
|
|
90
|
+
let args = null;
|
|
91
|
+
const text = await request.text();
|
|
92
|
+
if (text.length > 0) {
|
|
93
|
+
try {
|
|
94
|
+
args = JSON.parse(text);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return Response.json(errorResult('request body is not JSON', 'bad-request'), { status: 400 });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Response.json(await dispatch(handlers, endpoint, args));
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Register every endpoint on the best transport this host offers.
|
|
105
|
+
*
|
|
106
|
+
* @param {any} ctx - host plugin context.
|
|
107
|
+
* @param {Map<string, (args: any) => any>} handlers - endpoint handlers.
|
|
108
|
+
*/
|
|
109
|
+
export function registerRPC(ctx, handlers) {
|
|
110
|
+
const dynamicHarness = (typeof harness !== 'undefined' && harness && typeof harness.handle === 'function') ? harness : null;
|
|
111
|
+
if (dynamicHarness !== null) {
|
|
112
|
+
for (const [endpoint, fn] of handlers) {
|
|
113
|
+
dynamicHarness.handle('usage/' + endpoint, fn);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// P0-3 official Typert gateway path.
|
|
118
|
+
//
|
|
119
|
+
// Target API: @deepseek-ai/dsh-api-gateway host service, exposed on the host
|
|
120
|
+
// Read the gateway only via `ctx.get('typertGateway')` — cordis ctx is a
|
|
121
|
+
// property). The installed v0.1.0-rc.7 `TypertGateway` interface exposes
|
|
122
|
+
// only invoke() (see the package's lib/types/types.d.ts); the documented
|
|
123
|
+
// contribution path (@Remote/@RemoteScope + TypertRemoteService or
|
|
124
|
+
// bindTypertRemote from @deepseek-ai/dsh-typert-protocol) needs a runtime
|
|
125
|
+
// dependency this plugin must not add, so we only claim endpoints through a
|
|
126
|
+
// registration surface that already exists on the detected object:
|
|
127
|
+
// `typert.register(channel, endpoints)`, a forward-compatible contribution
|
|
128
|
+
// hook for host compositions that expose the gateway with registration.
|
|
129
|
+
//
|
|
130
|
+
// Precondition for this path to activate: the resolved typert gateway is
|
|
131
|
+
// non-null AND `typert.register` is a function. Endpoint functions receive
|
|
132
|
+
// (args) and return the raw value; the gateway/connection side owns the
|
|
133
|
+
// RPC envelope. Otherwise we fall back silently to the connection bridge
|
|
134
|
+
// below. All guards are typeof-based: nothing is imported, zero deps.
|
|
135
|
+
// cordis ctx is a strict proxy: direct property access to an un-injected
|
|
136
|
+
// service throws — always use ctx.get (returns undefined when absent).
|
|
137
|
+
const typert = typeof ctx.get === 'function' ? ctx.get('typertGateway') : undefined;
|
|
138
|
+
if (typert && typeof typert.register === 'function') {
|
|
139
|
+
const endpoints = {};
|
|
140
|
+
for (const [endpoint, fn] of handlers) {
|
|
141
|
+
endpoints[endpoint] = fn;
|
|
142
|
+
}
|
|
143
|
+
typert.register('usage', endpoints);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Static (bundle) installs: the `connection` service is provided
|
|
147
|
+
// asynchronously by the client-connection plugin, so wait for it before
|
|
148
|
+
// registering (the official api-gateway pattern).
|
|
149
|
+
ctx.inject(['connection'], (connectionCtx) => {
|
|
150
|
+
const connection = connectionCtx.connection;
|
|
151
|
+
if (connection === undefined || connection === null)
|
|
152
|
+
return;
|
|
153
|
+
// 0.2+: exact Fetch routes on the shared /api channel. This is the path
|
|
154
|
+
// that actually mounts a route: it never reads `webServer`, so the strict
|
|
155
|
+
// fiber proxy cannot reject it.
|
|
156
|
+
const fetchRegistry = connection.fetch;
|
|
157
|
+
let mountedFetchRoutes = 0;
|
|
158
|
+
if (fetchRegistry !== undefined && typeof fetchRegistry.register === 'function') {
|
|
159
|
+
for (const endpoint of handlers.keys()) {
|
|
160
|
+
const path = fetchRoutePath(endpoint);
|
|
161
|
+
try {
|
|
162
|
+
fetchRegistry.register({
|
|
163
|
+
path,
|
|
164
|
+
methods: ['POST'],
|
|
165
|
+
requestBody: 'buffered',
|
|
166
|
+
fetch: createFetchRoute(handlers, endpoint),
|
|
167
|
+
});
|
|
168
|
+
mountedFetchRoutes += 1;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
console.warn(`[usage] failed to mount shared Fetch route ${path}: ${String((error && error.message) || error)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Pre-0.2 hosts (and any composition that still exposes the prefix
|
|
176
|
+
// channel): the legacy /usage RPC bridge. On 0.2.x this throws inside
|
|
177
|
+
// effect() because the reading fiber owns no `webServer`; that is expected
|
|
178
|
+
// and already covered by the Fetch routes, so the failure stays local.
|
|
179
|
+
const rpcRegistry = connection.rpc;
|
|
180
|
+
if (rpcRegistry !== undefined && typeof rpcRegistry.handle === 'function') {
|
|
181
|
+
try {
|
|
182
|
+
rpcRegistry.handle(USAGE_RPC_CHANNEL, async (endpoint, args) => dispatch(handlers, endpoint, args), { authority: 'loopback' });
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
if (mountedFetchRoutes === 0) {
|
|
186
|
+
console.warn(`[usage] failed to mount ${USAGE_RPC_CHANNEL} RPC channel: ${String((error && error.message) || error)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
package/lib/host/scan.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live/persisted session scanning + startup backfill.
|
|
3
|
+
*
|
|
4
|
+
* DSH 0.2.x session accessors (verified against @deepseek-ai/dsh-session and
|
|
5
|
+
* @deepseek-ai/dsh-session-persistence 0.2.0-rc.2 / 0.2.1-rc.2):
|
|
6
|
+
*
|
|
7
|
+
* - live: `ctx.sessions.list()` -> `Session`, events via
|
|
8
|
+
* `session.snapshotEvents(fromSeq)`.
|
|
9
|
+
* - persisted: `ctx.sessionPersistence.list()` -> `{ header, revision,
|
|
10
|
+
* eventCount?, sizeBytes? }`, then `open(id, 'read')` and
|
|
11
|
+
* `handle.read(offset)`; the handle carries
|
|
12
|
+
* `inheritedEventCount` and must be closed.
|
|
13
|
+
*
|
|
14
|
+
* A forked session replays its parent's prefix; `inheritedEventCount` is the
|
|
15
|
+
* cut, so folding starts there and the parent is never billed twice.
|
|
16
|
+
*
|
|
17
|
+
* Optimization lives here: bounded-concurrency backfill, per-session failure
|
|
18
|
+
* backoff, revision-based skip (unchanged logs are not re-read), progress
|
|
19
|
+
* callbacks, and watermark repair when a log shrank.
|
|
20
|
+
*/
|
|
21
|
+
import { foldNew } from './fold.js';
|
|
22
|
+
/** How many persisted headers are read concurrently per batch. */
|
|
23
|
+
const BATCH_SIZE = 8;
|
|
24
|
+
/**
|
|
25
|
+
* Per-session backoff bookkeeping, kept at module (closure) scope so it spans
|
|
26
|
+
* multiple passes. Value is { failures, skipLeft }: consecutive failure count
|
|
27
|
+
* plus the number of upcoming passes the session is skipped for
|
|
28
|
+
* (2 ** failures, capped at 5; decays by one each pass).
|
|
29
|
+
*/
|
|
30
|
+
const backoffBySession = new Map();
|
|
31
|
+
/** True while a scanPersisted pass is in flight; overlapping passes are dropped. */
|
|
32
|
+
let persistedInFlight = false;
|
|
33
|
+
/**
|
|
34
|
+
* Events of one live session at or after `fromSeq`.
|
|
35
|
+
*
|
|
36
|
+
* @param {any} session - live Session.
|
|
37
|
+
* @param {number} fromSeq - first seq to return.
|
|
38
|
+
* @returns {ReadonlyArray<object>} events in log order.
|
|
39
|
+
*/
|
|
40
|
+
export function liveEvents(session, fromSeq) {
|
|
41
|
+
if (session === null || typeof session !== 'object')
|
|
42
|
+
return [];
|
|
43
|
+
if (typeof session.snapshotEvents === 'function')
|
|
44
|
+
return session.snapshotEvents(fromSeq);
|
|
45
|
+
// Legacy pre-0.2 sessions exposed the whole log as `events`.
|
|
46
|
+
if (Array.isArray(session.events)) {
|
|
47
|
+
return session.events.filter((event) => !event || typeof event.seq !== 'number' || event.seq >= fromSeq);
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
/** Scan live in-memory sessions (cheap, every tick). */
|
|
52
|
+
export function scanLive(ctx, state) {
|
|
53
|
+
const sessions = ctx.get('sessions');
|
|
54
|
+
if (sessions === undefined || sessions === null)
|
|
55
|
+
return;
|
|
56
|
+
for (const s of sessions.list()) {
|
|
57
|
+
if (!s || !s.id)
|
|
58
|
+
continue;
|
|
59
|
+
const wm = state.watermarks.get(s.id) ?? -1;
|
|
60
|
+
// Never bill the fork-inherited prefix to the child.
|
|
61
|
+
const inherited = typeof s.inheritedEventCount === 'number' ? s.inheritedEventCount : 0;
|
|
62
|
+
const from = Math.max(wm + 1, inherited);
|
|
63
|
+
const next = foldNew(state, s.id, liveEvents(s, from), wm);
|
|
64
|
+
if (next > wm)
|
|
65
|
+
state.watermarks.set(s.id, next);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Id of one persistence listing snapshot (`{header:{id}}` in 0.2.x). */
|
|
69
|
+
function snapshotId(snap) {
|
|
70
|
+
if (!snap || typeof snap !== 'object')
|
|
71
|
+
return undefined;
|
|
72
|
+
if (snap.header && typeof snap.header.id === 'string')
|
|
73
|
+
return snap.header.id;
|
|
74
|
+
if (typeof snap.id === 'string')
|
|
75
|
+
return snap.id;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read events for one persisted session at or after `fromSeq`, preferring the
|
|
80
|
+
* 0.2.x handle API and falling back to a legacy `readFrom` backend.
|
|
81
|
+
*
|
|
82
|
+
* @param {any} persistence - sessionPersistence service.
|
|
83
|
+
* @param {string} id - session id.
|
|
84
|
+
* @param {number} fromSeq - first seq to return.
|
|
85
|
+
* @returns {Promise<{ events: ReadonlyArray<object>, inherited: number }>} events + fork cut.
|
|
86
|
+
*/
|
|
87
|
+
export async function readSessionEvents(persistence, id, fromSeq) {
|
|
88
|
+
if (typeof persistence.open === 'function') {
|
|
89
|
+
const handle = await persistence.open(id, 'read');
|
|
90
|
+
try {
|
|
91
|
+
const inherited = typeof handle.inheritedEventCount === 'number' ? handle.inheritedEventCount : 0;
|
|
92
|
+
const page = await handle.read(Math.max(fromSeq, inherited));
|
|
93
|
+
return { events: (page && page.events) || [], inherited };
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (handle && typeof handle.close === 'function') {
|
|
97
|
+
try {
|
|
98
|
+
await handle.close();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// A close failure must not turn a successful read into a failed scan.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (typeof persistence.readFrom === 'function') {
|
|
107
|
+
const page = await persistence.readFrom(id, fromSeq);
|
|
108
|
+
return { events: (page && page.events) || [], inherited: 0 };
|
|
109
|
+
}
|
|
110
|
+
return { events: [], inherited: 0 };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Incremental scan of persisted sessions (slow cadence). Headers are read in
|
|
114
|
+
* small concurrent batches; unchanged revisions are skipped; a failing session
|
|
115
|
+
* is backed off for an exponentially growing number of passes instead of
|
|
116
|
+
* aborting the pass.
|
|
117
|
+
*/
|
|
118
|
+
export async function scanPersisted(ctx, state, options) {
|
|
119
|
+
const sp = ctx.get('sessionPersistence');
|
|
120
|
+
if (sp === undefined || sp === null)
|
|
121
|
+
return;
|
|
122
|
+
if (persistedInFlight)
|
|
123
|
+
return; // previous pass still running: skip this tick
|
|
124
|
+
persistedInFlight = true;
|
|
125
|
+
try {
|
|
126
|
+
const headers = await sp.list();
|
|
127
|
+
if (!Array.isArray(headers))
|
|
128
|
+
return;
|
|
129
|
+
state.backfill.total = headers.length;
|
|
130
|
+
for (let i = 0; i < headers.length; i += BATCH_SIZE) {
|
|
131
|
+
const batch = headers.slice(i, i + BATCH_SIZE);
|
|
132
|
+
await Promise.all(batch.map((h) => processHeader(sp, state, h, options)));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
persistedInFlight = false;
|
|
137
|
+
if (options && typeof options.onDone === 'function') {
|
|
138
|
+
options.onDone({
|
|
139
|
+
status: state.backfill.status,
|
|
140
|
+
total: state.backfill.total,
|
|
141
|
+
done: state.backfill.done,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Read + fold one persisted session header; bounded by the batch caller. */
|
|
147
|
+
async function processHeader(sp, state, snap, options) {
|
|
148
|
+
const id = snapshotId(snap);
|
|
149
|
+
if (id === undefined)
|
|
150
|
+
return;
|
|
151
|
+
const backoff = backoffBySession.get(id);
|
|
152
|
+
if (backoff && backoff.skipLeft > 0) {
|
|
153
|
+
backoff.skipLeft -= 1; // decay the skip count one pass at a time
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const revision = snap && snap.revision;
|
|
158
|
+
if (revision !== undefined && Object.is(state.revisions.get(id), revision)) {
|
|
159
|
+
// The backend reports the same change token: the log is unchanged.
|
|
160
|
+
backoffBySession.delete(id);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
let wm = state.watermarks.get(id) ?? -1;
|
|
164
|
+
// A crash-repaired or migrated log can shrink below our watermark; the
|
|
165
|
+
// backend reports the current event count, so repair instead of stalling.
|
|
166
|
+
if (typeof snap.eventCount === 'number' && wm >= snap.eventCount) {
|
|
167
|
+
wm = -1;
|
|
168
|
+
state.watermarks.set(id, -1);
|
|
169
|
+
state.lastSample.delete(id);
|
|
170
|
+
}
|
|
171
|
+
const { events } = await readSessionEvents(sp, id, wm + 1);
|
|
172
|
+
const next = foldNew(state, id, events, wm);
|
|
173
|
+
if (next > wm)
|
|
174
|
+
state.watermarks.set(id, next);
|
|
175
|
+
if (revision !== undefined)
|
|
176
|
+
state.revisions.set(id, revision);
|
|
177
|
+
backoffBySession.delete(id); // success resets the consecutive-failure chain
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
// A session may be gone or mid-rewrite: back off before trying again.
|
|
181
|
+
const b = backoffBySession.get(id) || { failures: 0, skipLeft: 0 };
|
|
182
|
+
b.failures += 1;
|
|
183
|
+
b.skipLeft = Math.min(5, 2 ** b.failures);
|
|
184
|
+
backoffBySession.set(id, b);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (state.backfill.status === 'running') {
|
|
189
|
+
state.backfill.done += 1;
|
|
190
|
+
if (options && typeof options.onProgress === 'function') {
|
|
191
|
+
options.onProgress({
|
|
192
|
+
status: state.backfill.status,
|
|
193
|
+
total: state.backfill.total,
|
|
194
|
+
done: state.backfill.done,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Full history scan, once at startup. */
|
|
201
|
+
export async function runBackfill(ctx, state, options) {
|
|
202
|
+
const sp = ctx.get('sessionPersistence');
|
|
203
|
+
if (sp === undefined || sp === null) {
|
|
204
|
+
state.backfill.status = 'done';
|
|
205
|
+
fireBackfillDone(options, state);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
state.backfill.status = 'running';
|
|
209
|
+
state.backfill.done = 0;
|
|
210
|
+
// Delegate progress while the pass runs; onDone is fired right here exactly
|
|
211
|
+
// once so the pass callback does not double-report backfill completion.
|
|
212
|
+
const scanOptions = options && typeof options.onProgress === 'function'
|
|
213
|
+
? { onProgress: options.onProgress }
|
|
214
|
+
: undefined;
|
|
215
|
+
try {
|
|
216
|
+
await scanPersisted(ctx, state, scanOptions);
|
|
217
|
+
state.backfill.status = 'done';
|
|
218
|
+
fireBackfillDone(options, state);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
state.backfill.status = 'done';
|
|
222
|
+
fireBackfillDone(options, state);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function fireBackfillDone(options, state) {
|
|
226
|
+
if (options && typeof options.onDone === 'function') {
|
|
227
|
+
options.onDone({
|
|
228
|
+
status: state.backfill.status,
|
|
229
|
+
total: state.backfill.total,
|
|
230
|
+
done: state.backfill.done,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price table + cost computation.
|
|
3
|
+
*
|
|
4
|
+
* Persistence is the official plugin-config path: `index.js` exports a
|
|
5
|
+
* schemastery `Config`, so DSH materializes a form under the plugin entry's
|
|
6
|
+
* id (`usage`) in the Plugins settings tab and stores values in the profile
|
|
7
|
+
* patch. There is deliberately no private `settings.yaml` / settings-service
|
|
8
|
+
* registration here — DSH 0.2.x `ctx.settings` (SettingsForms) only offers
|
|
9
|
+
* `describe/update/replace/mutate/configure`, and hand-rolled namespaces are
|
|
10
|
+
* not reachable from the client config surface.
|
|
11
|
+
*
|
|
12
|
+
* The store below stays the in-memory view the aggregation code reads.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_CURRENCY = '¥';
|
|
15
|
+
export const DEFAULT_PRICES = {
|
|
16
|
+
'deepseek-chat': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
|
|
17
|
+
'deepseek-v3': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
|
|
18
|
+
'deepseek-v3.1': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
|
|
19
|
+
'deepseek-v4': { inputPerM: 0.27, outputPerM: 1.1, cacheReadPerM: 0.07, cacheWritePerM: 0.27 },
|
|
20
|
+
'deepseek-v4-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
|
|
21
|
+
'deepseek-v4.1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
|
|
22
|
+
'deepseek-v4.1-flash-1017': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
|
|
23
|
+
// DSH's routed default model id (`magpie` provider) aliases the same route;
|
|
24
|
+
// without this entry every session bills as "unpriced" on a stock install.
|
|
25
|
+
'group/auto-deepseek-v4-1-flash': { inputPerM: 0.14, outputPerM: 0.56, cacheReadPerM: 0.04, cacheWritePerM: 0.14 },
|
|
26
|
+
'deepseek-reasoner': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
|
|
27
|
+
'deepseek-r1': { inputPerM: 0.55, outputPerM: 2.19, cacheReadPerM: 0.14, cacheWritePerM: 0.55 },
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Shallow-merge configured prices over the defaults.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} incoming - configured per-model prices.
|
|
33
|
+
* @returns {Record<string, object>} complete price table.
|
|
34
|
+
*/
|
|
35
|
+
export function mergePrices(incoming) {
|
|
36
|
+
const merged = Object.assign({}, DEFAULT_PRICES);
|
|
37
|
+
if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
|
|
38
|
+
for (const [model, price] of Object.entries(incoming)) {
|
|
39
|
+
if (price && typeof price === 'object') {
|
|
40
|
+
merged[model] = {
|
|
41
|
+
inputPerM: Number(price.inputPerM) || 0,
|
|
42
|
+
outputPerM: Number(price.outputPerM) || 0,
|
|
43
|
+
cacheReadPerM: Number(price.cacheReadPerM) || 0,
|
|
44
|
+
cacheWritePerM: Number(price.cacheWritePerM) || 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return merged;
|
|
50
|
+
}
|
|
51
|
+
export function createPriceStore(defaultPrices, defaultCurrency) {
|
|
52
|
+
const prices = Object.assign({}, defaultPrices);
|
|
53
|
+
let currency = defaultCurrency || DEFAULT_CURRENCY;
|
|
54
|
+
const store = {
|
|
55
|
+
prices,
|
|
56
|
+
get currency() { return currency; },
|
|
57
|
+
/** Merge incoming per-model prices (each field coerced to a number). */
|
|
58
|
+
setPrices(incoming) {
|
|
59
|
+
if (!incoming || typeof incoming !== 'object')
|
|
60
|
+
return;
|
|
61
|
+
for (const [model, p] of Object.entries(incoming)) {
|
|
62
|
+
if (p && typeof p === 'object') {
|
|
63
|
+
prices[model] = {
|
|
64
|
+
inputPerM: Number(p.inputPerM) || 0,
|
|
65
|
+
outputPerM: Number(p.outputPerM) || 0,
|
|
66
|
+
cacheReadPerM: Number(p.cacheReadPerM) || 0,
|
|
67
|
+
cacheWritePerM: Number(p.cacheWritePerM) || 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
setCurrency(v) {
|
|
73
|
+
if (typeof v === 'string' && v.length > 0)
|
|
74
|
+
currency = v;
|
|
75
|
+
},
|
|
76
|
+
/** Look up a price by `provider/model` composite first, then bare model. */
|
|
77
|
+
priceOf(r) {
|
|
78
|
+
const composite = r.provider ? r.provider + '/' + r.model : '';
|
|
79
|
+
return prices[composite] || prices[r.model];
|
|
80
|
+
},
|
|
81
|
+
/** Whether an explicit price entry exists (composite first, then bare model). */
|
|
82
|
+
hasPrice(r) {
|
|
83
|
+
const composite = r.provider ? r.provider + '/' + r.model : '';
|
|
84
|
+
return composite in prices || r.model in prices;
|
|
85
|
+
},
|
|
86
|
+
costOf(r) {
|
|
87
|
+
const p = store.priceOf(r);
|
|
88
|
+
if (!p)
|
|
89
|
+
return 0;
|
|
90
|
+
return (r.inputTokens * p.inputPerM +
|
|
91
|
+
r.outputTokens * p.outputPerM +
|
|
92
|
+
r.cacheReadTokens * (p.cacheReadPerM || 0) +
|
|
93
|
+
r.cacheWriteTokens * (p.cacheWritePerM || 0)) / 1e6;
|
|
94
|
+
},
|
|
95
|
+
snapshot() {
|
|
96
|
+
return { prices: Object.assign({}, prices), currency };
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return store;
|
|
100
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutable runtime state capsule for the usage host half.
|
|
3
|
+
*
|
|
4
|
+
* - records: folded usage records, one per billed assistant settlement
|
|
5
|
+
* (`assistant/message` or `assistant/attempt`) that reported usage.
|
|
6
|
+
* - watermarks: sessionId -> last folded event seq.
|
|
7
|
+
* - revisions: sessionId -> last persisted revision folded (opaque equality
|
|
8
|
+
* token from `sessionPersistence.list()`); lets the slow scan skip sessions
|
|
9
|
+
* whose log did not change.
|
|
10
|
+
* - backfill: progress counters surfaced through query results.
|
|
11
|
+
* - prices: the price store from settings.js.
|
|
12
|
+
* - lastSample: sessionId -> { turn, step, record } — the replacement slot the
|
|
13
|
+
* official tokenUsage projection keeps, so a later sample for the same
|
|
14
|
+
* (turn, step) replaces in place and `llm/retry-started` clears it.
|
|
15
|
+
* - route: sessionId -> { provider, model } from the latest request route,
|
|
16
|
+
* used to attribute settlements that carry no `message.source`
|
|
17
|
+
* (`assistant/attempt` never does).
|
|
18
|
+
* - index: incremental aggregation index (aggregate.js), kept in sync by
|
|
19
|
+
* fold.js and consumed by query.js.
|
|
20
|
+
*/
|
|
21
|
+
export function createState(priceStore) {
|
|
22
|
+
return {
|
|
23
|
+
records: [],
|
|
24
|
+
watermarks: new Map(),
|
|
25
|
+
revisions: new Map(),
|
|
26
|
+
backfill: { status: 'idle', total: 0, done: 0 },
|
|
27
|
+
prices: priceStore,
|
|
28
|
+
lastSample: new Map(),
|
|
29
|
+
route: new Map(),
|
|
30
|
+
index: null, // wired by apply() after createUsageIndex(state)
|
|
31
|
+
};
|
|
32
|
+
}
|
package/lib/host/time.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time helpers for usage bucketing — pure, dependency-free.
|
|
3
|
+
* Granularity keys: 'day' | 'hour' | 'minute'.
|
|
4
|
+
*
|
|
5
|
+
* User-facing buckets (dayKey / alignFloor / bucketKeyFor non-day cases) use
|
|
6
|
+
* LOCAL calendar time. dayIndex is a local-midnight-based ordinal used to
|
|
7
|
+
* align step-day blocks. DST-consistent handling is owned by this module.
|
|
8
|
+
*/
|
|
9
|
+
export const DAY_MS = 86400000;
|
|
10
|
+
export function dayKey(t) {
|
|
11
|
+
const d = new Date(t);
|
|
12
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
13
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
14
|
+
}
|
|
15
|
+
/** Ordinal day number (local-midnight based), for step alignment. */
|
|
16
|
+
export function dayIndex(t) {
|
|
17
|
+
return Math.floor(alignFloor(t, 'day') / DAY_MS);
|
|
18
|
+
}
|
|
19
|
+
/** Local-midnight epoch of the day whose dayIndex ordinal is `block`. */
|
|
20
|
+
function localMidnightForOrdinal(block) {
|
|
21
|
+
let epoch = alignFloor(block * DAY_MS, 'day');
|
|
22
|
+
let idx = Math.floor(epoch / DAY_MS);
|
|
23
|
+
while (idx < block) {
|
|
24
|
+
epoch = alignFloor(epoch + DAY_MS, 'day');
|
|
25
|
+
idx = Math.floor(epoch / DAY_MS);
|
|
26
|
+
}
|
|
27
|
+
while (idx > block) {
|
|
28
|
+
epoch = alignFloor(epoch - DAY_MS, 'day');
|
|
29
|
+
idx = Math.floor(epoch / DAY_MS);
|
|
30
|
+
}
|
|
31
|
+
return epoch;
|
|
32
|
+
}
|
|
33
|
+
/** Bucket key for one instant under a granularity (day buckets may span stepDays). */
|
|
34
|
+
export function bucketKeyFor(t, g, stepDays) {
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
36
|
+
if (g === 'day') {
|
|
37
|
+
// Single-day buckets are the local calendar day itself (DST-safe by construction).
|
|
38
|
+
if (stepDays === 1)
|
|
39
|
+
return dayKey(t);
|
|
40
|
+
const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
|
|
41
|
+
return dayKey(localMidnightForOrdinal(block));
|
|
42
|
+
}
|
|
43
|
+
const d = new Date(t);
|
|
44
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
45
|
+
const hm = pad(d.getHours()) + ':' + (g === 'hour' ? '00' : pad(d.getMinutes()));
|
|
46
|
+
return date + ' ' + hm;
|
|
47
|
+
}
|
|
48
|
+
export function stepMs(g) {
|
|
49
|
+
return g === 'day' ? DAY_MS : g === 'hour' ? 3600000 : 60000;
|
|
50
|
+
}
|
|
51
|
+
/** Floor an instant to its granularity boundary. */
|
|
52
|
+
export function alignFloor(t, g) {
|
|
53
|
+
const d = new Date(t);
|
|
54
|
+
if (g === 'day') {
|
|
55
|
+
d.setHours(0, 0, 0, 0);
|
|
56
|
+
return d.getTime();
|
|
57
|
+
}
|
|
58
|
+
if (g === 'hour') {
|
|
59
|
+
d.setMinutes(0, 0, 0);
|
|
60
|
+
return d.getTime();
|
|
61
|
+
}
|
|
62
|
+
d.setSeconds(0, 0);
|
|
63
|
+
return d.getTime();
|
|
64
|
+
}
|
|
65
|
+
/** Floor an instant to its stepDays block start (local midnight of the block's anchor day). */
|
|
66
|
+
export function alignBlock(t, stepDays) {
|
|
67
|
+
if (stepDays === 1)
|
|
68
|
+
return alignFloor(t, 'day');
|
|
69
|
+
const block = Math.floor(dayIndex(t) / stepDays) * stepDays;
|
|
70
|
+
return localMidnightForOrdinal(block);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Bucket key under a day granularity with a range-anchored step grid:
|
|
74
|
+
* blocks start at the range's first day, so a custom range reads naturally
|
|
75
|
+
* (e.g. 90 days -> 18 x 5-day buckets starting on the range's first day).
|
|
76
|
+
*/
|
|
77
|
+
export function stepDayKey(r, dayStart, stepDays) {
|
|
78
|
+
const k = Math.floor((alignFloor(r.time, 'day') - dayStart) / (stepDays * DAY_MS));
|
|
79
|
+
return dayKey(dayStart + k * stepDays * DAY_MS);
|
|
80
|
+
}
|
|
81
|
+
/** Cap on zero-filled output buckets; beyond it we emit present-only buckets. */
|
|
82
|
+
export const MAX_BUCKETS = 2000;
|