@jxgame2020/dsh-token-quota 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/lib/client.js +965 -0
- package/lib/index.js +387 -0
- package/lib/invariant.js +25 -0
- package/lib/types/client/TokenQuotaPanel.d.ts +28 -0
- package/lib/types/client/TokenQuotaPanel.js +147 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +232 -0
- package/lib/types/client/locales.d.ts +97 -0
- package/lib/types/client/locales.js +95 -0
- package/lib/types/client/store.d.ts +89 -0
- package/lib/types/client/store.js +92 -0
- package/lib/types/index.d.ts +99 -0
- package/lib/types/index.js +410 -0
- package/lib/types/invariant.d.ts +17 -0
- package/lib/types/invariant.js +24 -0
- package/lib/types/types.d.ts +125 -0
- package/lib/types/types.js +29 -0
- package/package.json +153 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily per-model token quota enforcement and accounting for the harness.
|
|
3
|
+
*
|
|
4
|
+
* The service owns three responsibilities, all driven by the existing agent
|
|
5
|
+
* and session extension points — no loop modification:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Accounting** — folds each live session's `request/header` to learn the
|
|
8
|
+
* `provider/model` in use, and credits the combined input + output + cache
|
|
9
|
+
* token count of every `assistant/message` that reports provider usage into
|
|
10
|
+
* that model's current-UTC-day bucket. Counters persist to a JSON file
|
|
11
|
+
* under the Harness home and roll over automatically at UTC midnight.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Enforcement** — on the `agent/request` waterfall it awaits the resolved
|
|
14
|
+
* call config, and when the selected model's daily usage is at or above its
|
|
15
|
+
* configured cap (a positive limit), it throws an {@link LlmError} with
|
|
16
|
+
* `TOKEN_QUOTA_EXCEEDED`, ending the turn before any provider request is
|
|
17
|
+
* dispatched. A limit of `0` (or no entry) leaves the model unlimited.
|
|
18
|
+
*
|
|
19
|
+
* 3. **Limits + pull** — per-model limits live in the settings document
|
|
20
|
+
* (`token-quota` namespace, written by the Web panel through the settings
|
|
21
|
+
* scope); every change re-reads them. The current snapshot is served to
|
|
22
|
+
* consumers over a plugin-owned HTTP route (`GET /token-quota`, registered
|
|
23
|
+
* on the existing `webServer` service when one exists); the browser panel
|
|
24
|
+
* polls it, so no core-Harness wiring or generated RPC contract is needed.
|
|
25
|
+
*
|
|
26
|
+
* The browser half (`src/client/`) renders a floating panel from the polled
|
|
27
|
+
* snapshot and writes limits back through the settings scope; this module
|
|
28
|
+
* stays browser-free (the optional route is plain node:http).
|
|
29
|
+
*
|
|
30
|
+
* @module @jxgame2020/dsh-token-quota
|
|
31
|
+
*/
|
|
32
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
33
|
+
import z from '@deepseek-ai/schemastery';
|
|
34
|
+
import '@deepseek-ai/dsh-agent';
|
|
35
|
+
import { type TokenQuotaConfig, type TokenQuotaLog, type TokenQuotaSnapshot } from './types.ts';
|
|
36
|
+
declare module '@deepseek-ai/cordis' {
|
|
37
|
+
interface Context {
|
|
38
|
+
/** The token-quota service (`@jxgame2020/dsh-token-quota`). */
|
|
39
|
+
tokenQuota: TokenQuotaService;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Daily token-quota service.
|
|
44
|
+
*
|
|
45
|
+
* Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
|
|
46
|
+
* per-model limits through the `token-quota` settings namespace.
|
|
47
|
+
*/
|
|
48
|
+
export declare class TokenQuotaService extends Service {
|
|
49
|
+
static Config: z<TokenQuotaConfig>;
|
|
50
|
+
private readonly storagePath;
|
|
51
|
+
private reset;
|
|
52
|
+
private cycle;
|
|
53
|
+
private usage;
|
|
54
|
+
private history;
|
|
55
|
+
private limits;
|
|
56
|
+
private monitored;
|
|
57
|
+
private settingsSource;
|
|
58
|
+
/** Per-session folded model key from the latest `request/header`. */
|
|
59
|
+
private readonly headerKeys;
|
|
60
|
+
private writeTimer;
|
|
61
|
+
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
62
|
+
private disposeRoute;
|
|
63
|
+
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
64
|
+
private disposeRouteLog;
|
|
65
|
+
/** Disposer for the webServer-arrival watcher when the service mounts later. */
|
|
66
|
+
private disposeRouteWatcher;
|
|
67
|
+
/**
|
|
68
|
+
* @param ctx - owning context (events and the settings section register on it).
|
|
69
|
+
* @param config - optional counter path.
|
|
70
|
+
*/
|
|
71
|
+
constructor(ctx: Context, config?: TokenQuotaConfig);
|
|
72
|
+
/** Serve the current snapshot over the plugin-owned HTTP route. */
|
|
73
|
+
private tryMountRoute;
|
|
74
|
+
/** Read the full per-cycle usage history (log dialog data). */
|
|
75
|
+
readLog(): TokenQuotaLog;
|
|
76
|
+
/** Read the current snapshot (also used by tests and inspection). */
|
|
77
|
+
readSnapshot(): TokenQuotaSnapshot;
|
|
78
|
+
/** Today's used tokens for one model key, or `0`. */
|
|
79
|
+
usedToday(key: string): number;
|
|
80
|
+
/** Whether a model is under active monitoring (undefined = every model). */
|
|
81
|
+
isMonitored(key: string): boolean;
|
|
82
|
+
/** Resolve one model's daily cap: positive = capped, `0` = unlimited. */
|
|
83
|
+
limitOf(key: string): number;
|
|
84
|
+
private onSessionEvent;
|
|
85
|
+
private onRequest;
|
|
86
|
+
/**
|
|
87
|
+
* Roll over to a new reset cycle, archiving the finished cycle's counters
|
|
88
|
+
* into the history log, when the cycle key changed.
|
|
89
|
+
* @returns whether a rollover happened.
|
|
90
|
+
*/
|
|
91
|
+
private rollCycleIfNeeded;
|
|
92
|
+
private scheduleWrite;
|
|
93
|
+
private flush;
|
|
94
|
+
private load;
|
|
95
|
+
private disposeLocal;
|
|
96
|
+
private safeSplit;
|
|
97
|
+
}
|
|
98
|
+
export default TokenQuotaService;
|
|
99
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily per-model token quota enforcement and accounting for the harness.
|
|
3
|
+
*
|
|
4
|
+
* The service owns three responsibilities, all driven by the existing agent
|
|
5
|
+
* and session extension points — no loop modification:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Accounting** — folds each live session's `request/header` to learn the
|
|
8
|
+
* `provider/model` in use, and credits the combined input + output + cache
|
|
9
|
+
* token count of every `assistant/message` that reports provider usage into
|
|
10
|
+
* that model's current-UTC-day bucket. Counters persist to a JSON file
|
|
11
|
+
* under the Harness home and roll over automatically at UTC midnight.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Enforcement** — on the `agent/request` waterfall it awaits the resolved
|
|
14
|
+
* call config, and when the selected model's daily usage is at or above its
|
|
15
|
+
* configured cap (a positive limit), it throws an {@link LlmError} with
|
|
16
|
+
* `TOKEN_QUOTA_EXCEEDED`, ending the turn before any provider request is
|
|
17
|
+
* dispatched. A limit of `0` (or no entry) leaves the model unlimited.
|
|
18
|
+
*
|
|
19
|
+
* 3. **Limits + pull** — per-model limits live in the settings document
|
|
20
|
+
* (`token-quota` namespace, written by the Web panel through the settings
|
|
21
|
+
* scope); every change re-reads them. The current snapshot is served to
|
|
22
|
+
* consumers over a plugin-owned HTTP route (`GET /token-quota`, registered
|
|
23
|
+
* on the existing `webServer` service when one exists); the browser panel
|
|
24
|
+
* polls it, so no core-Harness wiring or generated RPC contract is needed.
|
|
25
|
+
*
|
|
26
|
+
* The browser half (`src/client/`) renders a floating panel from the polled
|
|
27
|
+
* snapshot and writes limits back through the settings scope; this module
|
|
28
|
+
* stays browser-free (the optional route is plain node:http).
|
|
29
|
+
*
|
|
30
|
+
* @module @jxgame2020/dsh-token-quota
|
|
31
|
+
*/
|
|
32
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
36
|
+
import z from '@deepseek-ai/schemastery';
|
|
37
|
+
// Type-only side-effect: pulls agent/request and agent/pre-step Events augmentations
|
|
38
|
+
// the service listens to without importing any runtime value.
|
|
39
|
+
import '@deepseek-ai/dsh-agent';
|
|
40
|
+
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
41
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
42
|
+
import { TOKEN_QUOTA_EXCEEDED_CODE, TOKEN_QUOTA_NAMESPACE, tokenQuotaKey, } from "./types.js";
|
|
43
|
+
import { assertTokenQuotaLimit, splitTokenQuotaKey } from "./invariant.js";
|
|
44
|
+
/** Settings schema resolving the per-model daily caps document. */
|
|
45
|
+
const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
|
|
46
|
+
limits: z.dict(z.number().step(1).min(0)).default({}),
|
|
47
|
+
monitored: z.array(z.string()).default([]),
|
|
48
|
+
onFull: z.union(['stop', 'switchQuota', 'switchAll', 'switchPriority']).default('stop'),
|
|
49
|
+
// `reset` is a user-facing preference outside the validated surface: the
|
|
50
|
+
// panel writes it and the host validates the shape at runtime. `z.any` with
|
|
51
|
+
// a null default keeps it out of the strict fields above.
|
|
52
|
+
reset: z.any().default(null),
|
|
53
|
+
});
|
|
54
|
+
/** Two-digit zero-pad helper. */
|
|
55
|
+
function pad2(value) {
|
|
56
|
+
return String(value).padStart(2, '0');
|
|
57
|
+
}
|
|
58
|
+
/** The machine's own UTC offset in whole hours (default reset timezone). */
|
|
59
|
+
function localOffsetHours(now = new Date()) {
|
|
60
|
+
return -now.getTimezoneOffset() / 60;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the reset-cycle key a timestamp belongs to: the `YYYY-MM-DD@HH:MM`
|
|
64
|
+
* label (in the configured timezone) of the cycle whose `hour:minute` reset
|
|
65
|
+
* moment contains the timestamp. With the default config (machine timezone,
|
|
66
|
+
* 00:00) this reproduces the historical "resets at machine midnight".
|
|
67
|
+
*/
|
|
68
|
+
function cycleKey(now, reset) {
|
|
69
|
+
const offsetMs = reset.offsetHours * 3_600_000;
|
|
70
|
+
const zoned = new Date(now.getTime() + offsetMs);
|
|
71
|
+
const zonedDayStartUtc = Date.UTC(zoned.getUTCFullYear(), zoned.getUTCMonth(), zoned.getUTCDate()) - offsetMs;
|
|
72
|
+
const resetMinutes = reset.hour * 60 + reset.minute;
|
|
73
|
+
const cycleStartToday = zonedDayStartUtc + resetMinutes * 60_000;
|
|
74
|
+
const cycleStartUtc = now.getTime() >= cycleStartToday
|
|
75
|
+
? cycleStartToday
|
|
76
|
+
: cycleStartToday - 86_400_000;
|
|
77
|
+
const startZoned = new Date(cycleStartUtc + offsetMs);
|
|
78
|
+
return `${startZoned.getUTCFullYear()}-${pad2(startZoned.getUTCMonth() + 1)}-${pad2(startZoned.getUTCDate())}@${pad2(reset.hour)}:${pad2(reset.minute)}`;
|
|
79
|
+
}
|
|
80
|
+
/** Default counter file under the Harness home (overridable through config). */
|
|
81
|
+
function defaultStoragePath() {
|
|
82
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
83
|
+
return join(home, 'token-quota.json');
|
|
84
|
+
}
|
|
85
|
+
/** Combined input + output + cache token count of one provider usage report. */
|
|
86
|
+
function usageTokens(usage) {
|
|
87
|
+
return usage.inputTokens
|
|
88
|
+
+ (usage.cacheReadTokens ?? 0)
|
|
89
|
+
+ (usage.cacheWriteTokens ?? 0)
|
|
90
|
+
+ usage.outputTokens;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Daily token-quota service.
|
|
94
|
+
*
|
|
95
|
+
* Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
|
|
96
|
+
* per-model limits through the `token-quota` settings namespace.
|
|
97
|
+
*/
|
|
98
|
+
export class TokenQuotaService extends Service {
|
|
99
|
+
static Config = z.object({
|
|
100
|
+
storagePath: z.string().default(''),
|
|
101
|
+
});
|
|
102
|
+
storagePath;
|
|
103
|
+
reset = {
|
|
104
|
+
offsetHours: localOffsetHours(),
|
|
105
|
+
hour: 0,
|
|
106
|
+
minute: 0,
|
|
107
|
+
};
|
|
108
|
+
cycle = cycleKey(new Date(), this.reset);
|
|
109
|
+
usage = {};
|
|
110
|
+
history = {};
|
|
111
|
+
limits = {};
|
|
112
|
+
monitored = undefined;
|
|
113
|
+
settingsSource = () => ({ limits: {}, monitored: [], onFull: 'stop' });
|
|
114
|
+
/** Per-session folded model key from the latest `request/header`. */
|
|
115
|
+
headerKeys = new WeakMap();
|
|
116
|
+
writeTimer;
|
|
117
|
+
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
118
|
+
disposeRoute;
|
|
119
|
+
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
120
|
+
disposeRouteLog;
|
|
121
|
+
/** Disposer for the webServer-arrival watcher when the service mounts later. */
|
|
122
|
+
disposeRouteWatcher;
|
|
123
|
+
/**
|
|
124
|
+
* @param ctx - owning context (events and the settings section register on it).
|
|
125
|
+
* @param config - optional counter path.
|
|
126
|
+
*/
|
|
127
|
+
constructor(ctx, config = {}) {
|
|
128
|
+
super(ctx, 'tokenQuota');
|
|
129
|
+
this.storagePath = config.storagePath !== undefined && config.storagePath.length > 0
|
|
130
|
+
? config.storagePath
|
|
131
|
+
: defaultStoragePath();
|
|
132
|
+
this.load();
|
|
133
|
+
// Limits live in the user settings document; the Web panel writes them and
|
|
134
|
+
// the section watcher pushes every change back here.
|
|
135
|
+
installSettingsSection(ctx, settingsNamespace(TOKEN_QUOTA_NAMESPACE), TOKEN_QUOTA_SETTINGS_SCHEMA, { limits: {} }, {
|
|
136
|
+
setSource: (current) => {
|
|
137
|
+
// Normalize the resolved (schema-shaped) document into the plugin's
|
|
138
|
+
// required shape: empty monitored = monitor everything, absent onFull
|
|
139
|
+
// falls back to 'stop'.
|
|
140
|
+
this.settingsSource = () => {
|
|
141
|
+
const doc = current();
|
|
142
|
+
return {
|
|
143
|
+
limits: doc.limits ?? {},
|
|
144
|
+
monitored: doc.monitored ?? [],
|
|
145
|
+
onFull: doc.onFull ?? 'stop',
|
|
146
|
+
reset: doc.reset ?? undefined,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
validate: (value) => {
|
|
151
|
+
for (const [key, limit] of Object.entries(value.limits ?? {})) {
|
|
152
|
+
assertTokenQuotaLimit(limit, key);
|
|
153
|
+
splitTokenQuotaKey(key);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
onChange: () => {
|
|
157
|
+
const doc = this.settingsSource();
|
|
158
|
+
this.limits = { ...doc.limits };
|
|
159
|
+
this.monitored = doc.monitored !== undefined && doc.monitored.length > 0
|
|
160
|
+
? new Set(doc.monitored)
|
|
161
|
+
: undefined;
|
|
162
|
+
if (doc.reset !== undefined
|
|
163
|
+
&& typeof doc.reset === 'object'
|
|
164
|
+
&& doc.reset !== null
|
|
165
|
+
&& typeof doc.reset.offsetHours === 'number'
|
|
166
|
+
&& typeof doc.reset.hour === 'number'
|
|
167
|
+
&& typeof doc.reset.minute === 'number') {
|
|
168
|
+
const configured = doc.reset;
|
|
169
|
+
this.reset = {
|
|
170
|
+
offsetHours: Math.max(-12, Math.min(14, Math.round(configured.offsetHours))),
|
|
171
|
+
hour: Math.max(0, Math.min(23, Math.round(configured.hour))),
|
|
172
|
+
minute: Math.max(0, Math.min(59, Math.round(configured.minute))),
|
|
173
|
+
};
|
|
174
|
+
this.rollCycleIfNeeded();
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// null / malformed (unset) = machine-local midnight — the default.
|
|
178
|
+
this.reset = { offsetHours: localOffsetHours(), hour: 0, minute: 0 };
|
|
179
|
+
this.rollCycleIfNeeded();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
// Snapshot route: optional — only mounted when a webServer service exists
|
|
184
|
+
// (the Web profile); headless deployments keep the enforcement without it.
|
|
185
|
+
this.tryMountRoute();
|
|
186
|
+
if (this.disposeRoute === undefined) {
|
|
187
|
+
this.disposeRouteWatcher = ctx.on('internal/service', (name) => {
|
|
188
|
+
if (name === 'webServer')
|
|
189
|
+
this.tryMountRoute();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// Accounting: learn the model from each request header, credit usage on
|
|
193
|
+
// every provider-reported assistant message for that model.
|
|
194
|
+
ctx.on('session/event', (session, event) => {
|
|
195
|
+
this.onSessionEvent(session, event);
|
|
196
|
+
});
|
|
197
|
+
// Enforcement + accounting lock: the waterfall's resolved config is the
|
|
198
|
+
// authoritative model for this request — bind it to the agent's session
|
|
199
|
+
// so a model switch mid-flight never misattributes the in-flight reply.
|
|
200
|
+
ctx.on('agent/request', async (payload, next) => this.onRequest(payload, next));
|
|
201
|
+
ctx.effect(() => () => { this.disposeLocal(); }, 'token-quota: flush on unload');
|
|
202
|
+
}
|
|
203
|
+
/** Serve the current snapshot over the plugin-owned HTTP route. */
|
|
204
|
+
tryMountRoute() {
|
|
205
|
+
const server = this.ctx.get('webServer');
|
|
206
|
+
if (server === undefined || this.disposeRoute !== undefined)
|
|
207
|
+
return;
|
|
208
|
+
this.disposeRoute = server.register({
|
|
209
|
+
kind: 'exact',
|
|
210
|
+
path: '/token-quota',
|
|
211
|
+
handler: (_req, res) => {
|
|
212
|
+
const body = JSON.stringify(this.readSnapshot());
|
|
213
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
214
|
+
res.end(body);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
this.disposeRouteLog = server.register({
|
|
218
|
+
kind: 'exact',
|
|
219
|
+
path: '/token-quota/log',
|
|
220
|
+
handler: (_req, res) => {
|
|
221
|
+
const body = JSON.stringify(this.readLog());
|
|
222
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
223
|
+
res.end(body);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** Read the full per-cycle usage history (log dialog data). */
|
|
228
|
+
readLog() {
|
|
229
|
+
this.rollCycleIfNeeded();
|
|
230
|
+
const entries = [];
|
|
231
|
+
for (const [cycle, usageByKey] of Object.entries(this.history)) {
|
|
232
|
+
for (const [key, used] of Object.entries(usageByKey)) {
|
|
233
|
+
const parts = this.safeSplit(key);
|
|
234
|
+
if (parts === undefined || used <= 0)
|
|
235
|
+
continue;
|
|
236
|
+
entries.push({ day: cycle, key, provider: parts.provider, model: parts.model, used });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Include the current cycle's live counters too.
|
|
240
|
+
for (const [key, used] of Object.entries(this.usage)) {
|
|
241
|
+
const parts = this.safeSplit(key);
|
|
242
|
+
if (parts === undefined || used <= 0)
|
|
243
|
+
continue;
|
|
244
|
+
entries.push({ day: this.cycle, key, provider: parts.provider, model: parts.model, used });
|
|
245
|
+
}
|
|
246
|
+
entries.sort((left, right) => right.day.localeCompare(left.day) || left.key.localeCompare(right.key));
|
|
247
|
+
return { entries };
|
|
248
|
+
}
|
|
249
|
+
/** Read the current snapshot (also used by tests and inspection). */
|
|
250
|
+
readSnapshot() {
|
|
251
|
+
this.rollCycleIfNeeded();
|
|
252
|
+
const keys = new Set([...Object.keys(this.usage), ...Object.keys(this.limits)]);
|
|
253
|
+
const entries = [];
|
|
254
|
+
for (const key of keys) {
|
|
255
|
+
const parts = this.safeSplit(key);
|
|
256
|
+
if (parts === undefined)
|
|
257
|
+
continue;
|
|
258
|
+
entries.push({
|
|
259
|
+
key,
|
|
260
|
+
provider: parts.provider,
|
|
261
|
+
model: parts.model,
|
|
262
|
+
used: this.usage[key] ?? 0,
|
|
263
|
+
limit: this.limitOf(key),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
267
|
+
return { day: this.cycle, entries };
|
|
268
|
+
}
|
|
269
|
+
/** Today's used tokens for one model key, or `0`. */
|
|
270
|
+
usedToday(key) {
|
|
271
|
+
this.rollCycleIfNeeded();
|
|
272
|
+
return this.usage[key] ?? 0;
|
|
273
|
+
}
|
|
274
|
+
/** Whether a model is under active monitoring (undefined = every model). */
|
|
275
|
+
isMonitored(key) {
|
|
276
|
+
return this.monitored === undefined || this.monitored.has(key);
|
|
277
|
+
}
|
|
278
|
+
/** Resolve one model's daily cap: positive = capped, `0` = unlimited. */
|
|
279
|
+
limitOf(key) {
|
|
280
|
+
const limit = this.limits[key];
|
|
281
|
+
return typeof limit === 'number' && Number.isInteger(limit) && limit > 0 ? limit : 0;
|
|
282
|
+
}
|
|
283
|
+
onSessionEvent(session, event) {
|
|
284
|
+
if (event.type === 'request/header') {
|
|
285
|
+
const { provider, model } = event.data.header.config;
|
|
286
|
+
this.headerKeys.set(session, tokenQuotaKey(provider, model));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
290
|
+
const key = this.headerKeys.get(session);
|
|
291
|
+
if (key === undefined)
|
|
292
|
+
return;
|
|
293
|
+
if (!this.isMonitored(key))
|
|
294
|
+
return;
|
|
295
|
+
const tokens = usageTokens(event.data.usage);
|
|
296
|
+
if (tokens <= 0)
|
|
297
|
+
return;
|
|
298
|
+
this.rollCycleIfNeeded();
|
|
299
|
+
this.usage[key] = (this.usage[key] ?? 0) + tokens;
|
|
300
|
+
this.scheduleWrite();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async onRequest(payload, next) {
|
|
304
|
+
const config = await next();
|
|
305
|
+
const { provider, model } = config;
|
|
306
|
+
if (!provider || !model)
|
|
307
|
+
return config;
|
|
308
|
+
const key = tokenQuotaKey(provider, model);
|
|
309
|
+
// Bind the request's model to its session now (not on the later
|
|
310
|
+
// request/header append): an in-flight reply that lands after a model
|
|
311
|
+
// switch still credits the model that actually produced it.
|
|
312
|
+
this.headerKeys.set(payload.agent.session, key);
|
|
313
|
+
if (!this.isMonitored(key))
|
|
314
|
+
return config;
|
|
315
|
+
const limit = this.limitOf(key);
|
|
316
|
+
if (limit <= 0)
|
|
317
|
+
return config;
|
|
318
|
+
const used = this.usage[key] ?? 0;
|
|
319
|
+
if (used < limit)
|
|
320
|
+
return config;
|
|
321
|
+
throw new LlmError(`Daily token limit reached for "${provider}/${model}": ${used}/${limit} tokens used today. `
|
|
322
|
+
+ 'Switch model in the quota panel or raise its limit.', TOKEN_QUOTA_EXCEEDED_CODE);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Roll over to a new reset cycle, archiving the finished cycle's counters
|
|
326
|
+
* into the history log, when the cycle key changed.
|
|
327
|
+
* @returns whether a rollover happened.
|
|
328
|
+
*/
|
|
329
|
+
rollCycleIfNeeded() {
|
|
330
|
+
const now = cycleKey(new Date(), this.reset);
|
|
331
|
+
if (this.cycle === now)
|
|
332
|
+
return false;
|
|
333
|
+
if (Object.keys(this.usage).length > 0) {
|
|
334
|
+
this.history[this.cycle] = { ...this.usage };
|
|
335
|
+
}
|
|
336
|
+
this.cycle = now;
|
|
337
|
+
this.usage = {};
|
|
338
|
+
this.scheduleWrite();
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
scheduleWrite() {
|
|
342
|
+
if (this.writeTimer !== undefined)
|
|
343
|
+
return;
|
|
344
|
+
this.writeTimer = setTimeout(() => {
|
|
345
|
+
this.writeTimer = undefined;
|
|
346
|
+
this.flush();
|
|
347
|
+
}, 500);
|
|
348
|
+
}
|
|
349
|
+
flush() {
|
|
350
|
+
if (this.writeTimer !== undefined) {
|
|
351
|
+
clearTimeout(this.writeTimer);
|
|
352
|
+
this.writeTimer = undefined;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
mkdirSync(dirname(this.storagePath), { recursive: true });
|
|
356
|
+
writeFileSync(this.storagePath, JSON.stringify({
|
|
357
|
+
cycle: this.cycle,
|
|
358
|
+
usage: this.usage,
|
|
359
|
+
history: this.history,
|
|
360
|
+
}, null, 2));
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
this.ctx.logger.warn('token-quota: failed to persist counters: %o', error);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
load() {
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(readFileSync(this.storagePath, 'utf8'));
|
|
369
|
+
const candidate = parsed;
|
|
370
|
+
this.cycle = typeof candidate?.cycle === 'string' && candidate.cycle.length > 0
|
|
371
|
+
? candidate.cycle
|
|
372
|
+
// Legacy files stored a bare `day` (machine-local date); adopt it as
|
|
373
|
+
// the current cycle so no usage is dropped on upgrade.
|
|
374
|
+
: typeof candidate?.day === 'string' && candidate.day.length > 0
|
|
375
|
+
? candidate.day
|
|
376
|
+
: cycleKey(new Date(), this.reset);
|
|
377
|
+
const usage = candidate?.usage;
|
|
378
|
+
this.usage = typeof usage === 'object' && usage !== null && !Array.isArray(usage)
|
|
379
|
+
? usage
|
|
380
|
+
: {};
|
|
381
|
+
const history = candidate?.history;
|
|
382
|
+
this.history = typeof history === 'object' && history !== null && !Array.isArray(history)
|
|
383
|
+
? history
|
|
384
|
+
: {};
|
|
385
|
+
this.rollCycleIfNeeded();
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// Missing or corrupt counter file starts fresh; it must never crash the harness.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
disposeLocal() {
|
|
392
|
+
if (this.disposeRouteWatcher !== undefined)
|
|
393
|
+
this.disposeRouteWatcher();
|
|
394
|
+
if (this.disposeRoute !== undefined)
|
|
395
|
+
this.disposeRoute();
|
|
396
|
+
if (this.disposeRouteLog !== undefined)
|
|
397
|
+
this.disposeRouteLog();
|
|
398
|
+
this.flush();
|
|
399
|
+
}
|
|
400
|
+
safeSplit(key) {
|
|
401
|
+
try {
|
|
402
|
+
return splitTokenQuotaKey(key);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
export default TokenQuotaService;
|
|
410
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime guards for the token-quota plugin's public boundary.
|
|
3
|
+
*
|
|
4
|
+
* A separate companion module keeps these reachable from both the Host and
|
|
5
|
+
* Client build faces without dragging the service implementation into a
|
|
6
|
+
* browser bundle.
|
|
7
|
+
*
|
|
8
|
+
* @module @jxgame2020/dsh-token-quota/invariant
|
|
9
|
+
*/
|
|
10
|
+
/** Accept one per-model daily limit: `0` (unlimited) or a positive integer. */
|
|
11
|
+
export declare function assertTokenQuotaLimit(value: unknown, key: string): asserts value is number;
|
|
12
|
+
/** Resolve a model key of the form `provider/model` into its two parts. */
|
|
13
|
+
export declare function splitTokenQuotaKey(key: string): {
|
|
14
|
+
provider: string;
|
|
15
|
+
model: string;
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime guards for the token-quota plugin's public boundary.
|
|
3
|
+
*
|
|
4
|
+
* A separate companion module keeps these reachable from both the Host and
|
|
5
|
+
* Client build faces without dragging the service implementation into a
|
|
6
|
+
* browser bundle.
|
|
7
|
+
*
|
|
8
|
+
* @module @jxgame2020/dsh-token-quota/invariant
|
|
9
|
+
*/
|
|
10
|
+
/** Accept one per-model daily limit: `0` (unlimited) or a positive integer. */
|
|
11
|
+
export function assertTokenQuotaLimit(value, key) {
|
|
12
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
13
|
+
throw new TypeError(`token-quota: limit for "${key}" must be a non-negative integer, received ${String(value)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Resolve a model key of the form `provider/model` into its two parts. */
|
|
17
|
+
export function splitTokenQuotaKey(key) {
|
|
18
|
+
const slash = key.indexOf('/');
|
|
19
|
+
if (slash <= 0 || slash === key.length - 1) {
|
|
20
|
+
throw new TypeError(`token-quota: invalid model key "${key}" — expected "provider/model"`);
|
|
21
|
+
}
|
|
22
|
+
return { provider: key.slice(0, slash), model: key.slice(slash + 1) };
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=invariant.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire/type vocabulary for the daily token-quota plugin.
|
|
3
|
+
*
|
|
4
|
+
* The Host half owns the durable per-model daily counter and the enforcement
|
|
5
|
+
* gate; the browser half renders a floating panel from the snapshot it pulls
|
|
6
|
+
* over a plugin-owned HTTP route, and writes per-model limits and panel
|
|
7
|
+
* preferences through the settings document. This module is the one meeting
|
|
8
|
+
* point of the two planes: it carries strings and shapes only, never a
|
|
9
|
+
* runtime import from the other half.
|
|
10
|
+
*
|
|
11
|
+
* @module @jxgame2020/dsh-token-quota/types
|
|
12
|
+
*/
|
|
13
|
+
/** Settings namespace owning the per-model daily limits document. */
|
|
14
|
+
export declare const TOKEN_QUOTA_NAMESPACE = "token-quota";
|
|
15
|
+
/** Stable machine code thrown from `agent/request` when a model is over its daily cap. */
|
|
16
|
+
export declare const TOKEN_QUOTA_EXCEEDED_CODE = "TOKEN_QUOTA_EXCEEDED";
|
|
17
|
+
/**
|
|
18
|
+
* What to do once a monitored, quota-capped model reaches its daily cap.
|
|
19
|
+
* Selected in the panel's settings dialog; the panel acts on it (b/c/d auto
|
|
20
|
+
* switch, a stops and prompts).
|
|
21
|
+
*/
|
|
22
|
+
export declare const TOKEN_QUOTA_FULL_ACTIONS: readonly ["stop", "switchQuota", "switchAll", "switchPriority"];
|
|
23
|
+
/** One of the {@link TOKEN_QUOTA_FULL_ACTIONS} values. */
|
|
24
|
+
export type TokenQuotaFullAction = typeof TOKEN_QUOTA_FULL_ACTIONS[number];
|
|
25
|
+
/**
|
|
26
|
+
* Daily reset moment: every day at `hour:minute` in the chosen timezone
|
|
27
|
+
* (a fixed UTC offset in hours, -12 through +14) the counters roll over.
|
|
28
|
+
* Absent from settings (or host default) means the machine's own timezone at
|
|
29
|
+
* midnight — the historical behaviour.
|
|
30
|
+
*/
|
|
31
|
+
export interface TokenQuotaReset {
|
|
32
|
+
/** Fixed UTC offset in whole hours, -12 .. +14. */
|
|
33
|
+
offsetHours: number;
|
|
34
|
+
/** Reset hour in that timezone, 0..23. */
|
|
35
|
+
hour: number;
|
|
36
|
+
/** Reset minute in that timezone, 0..59. */
|
|
37
|
+
minute: number;
|
|
38
|
+
}
|
|
39
|
+
/** Default reset: machine-local midnight (offset = host timezone, 00:00). */
|
|
40
|
+
export declare const TOKEN_QUOTA_DEFAULT_RESET: TokenQuotaReset;
|
|
41
|
+
/**
|
|
42
|
+
* Per-model daily-limit settings document shape. Keys are `provider/model`
|
|
43
|
+
* and a value of `0` (or an absent key) means unlimited for that model.
|
|
44
|
+
*/
|
|
45
|
+
export interface TokenQuotaSettings {
|
|
46
|
+
/**
|
|
47
|
+
* Daily token caps keyed by `provider/model`. `0` or absent = unlimited.
|
|
48
|
+
* Positive integers cap the combined input + output + cache token count
|
|
49
|
+
* for the current UTC day.
|
|
50
|
+
*/
|
|
51
|
+
limits: Record<string, number>;
|
|
52
|
+
/**
|
|
53
|
+
* Model keys (`provider/model`) under active monitoring. An empty array
|
|
54
|
+
* means EVERY model is monitored (the install default). Unmonitored models
|
|
55
|
+
* are hidden from the panel, not metered, and never capped.
|
|
56
|
+
*/
|
|
57
|
+
monitored: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Behavior when a monitored, capped model hits its daily cap:
|
|
60
|
+
* `'stop'` stops and prompts, `'switchQuota'` auto-switches to another
|
|
61
|
+
* capped-but-available model, `'switchAll'` switches to any other available
|
|
62
|
+
* model, `'switchPriority'` prefers uncapped models then unmonitored ones.
|
|
63
|
+
*/
|
|
64
|
+
onFull: TokenQuotaFullAction;
|
|
65
|
+
/**
|
|
66
|
+
* Daily reset moment (timezone + clock time). Absent = machine-local
|
|
67
|
+
* midnight (the historical behaviour).
|
|
68
|
+
*/
|
|
69
|
+
reset?: TokenQuotaReset;
|
|
70
|
+
}
|
|
71
|
+
/** Token-quota plugin configuration. */
|
|
72
|
+
export interface TokenQuotaConfig {
|
|
73
|
+
/**
|
|
74
|
+
* Counter JSON file path. Defaults to `token-quota.json` under the Harness
|
|
75
|
+
* home (`DSH_HOME` or `~/.dsh`).
|
|
76
|
+
*/
|
|
77
|
+
storagePath?: string;
|
|
78
|
+
}
|
|
79
|
+
/** One model's live quota row, as rendered in the floating panel. */
|
|
80
|
+
export interface TokenQuotaEntry {
|
|
81
|
+
/** Stable key `provider/model`; also the settings-document key. */
|
|
82
|
+
key: string;
|
|
83
|
+
/** Registered provider route. */
|
|
84
|
+
provider: string;
|
|
85
|
+
/** Provider-owned model id. */
|
|
86
|
+
model: string;
|
|
87
|
+
/** Tokens used today (input + output + cache). */
|
|
88
|
+
used: number;
|
|
89
|
+
/** Daily cap; `0` means unlimited. */
|
|
90
|
+
limit: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Full quota snapshot. The Host is the single fact source; the panel replaces
|
|
94
|
+
* its whole view on each pull, so replay is order-independent.
|
|
95
|
+
*/
|
|
96
|
+
export interface TokenQuotaSnapshot {
|
|
97
|
+
/** Reset-cycle key the counters belong to (`YYYY-MM-DD@HH:MM` in the reset timezone). */
|
|
98
|
+
day: string;
|
|
99
|
+
/** Per-model rows, sorted by key. */
|
|
100
|
+
entries: TokenQuotaEntry[];
|
|
101
|
+
}
|
|
102
|
+
/** One historical daily usage record, shown in the log dialog. */
|
|
103
|
+
export interface TokenQuotaLogEntry {
|
|
104
|
+
/** Reset-cycle key (`YYYY-MM-DD@HH:MM`) the record belongs to. */
|
|
105
|
+
day: string;
|
|
106
|
+
/** Stable key `provider/model`. */
|
|
107
|
+
key: string;
|
|
108
|
+
/** Registered provider route. */
|
|
109
|
+
provider: string;
|
|
110
|
+
/** Provider-owned model id. */
|
|
111
|
+
model: string;
|
|
112
|
+
/** Tokens used that cycle (input + output + cache). */
|
|
113
|
+
used: number;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Full usage history: one entry per model per reset cycle with a record.
|
|
117
|
+
* Independent of the monitored set — every metered model appears.
|
|
118
|
+
*/
|
|
119
|
+
export interface TokenQuotaLog {
|
|
120
|
+
/** Entries sorted by day desc then key. */
|
|
121
|
+
entries: TokenQuotaLogEntry[];
|
|
122
|
+
}
|
|
123
|
+
/** Build the stable per-model key shared by the counter, settings, and snapshot. */
|
|
124
|
+
export declare function tokenQuotaKey(provider: string, model: string): string;
|
|
125
|
+
//# sourceMappingURL=types.d.ts.map
|