@jxgame2020/dsh-token-quota 0.1.5 → 0.1.6
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 +51 -44
- package/lib/index.js +95 -2
- package/lib/types/client/index.js +18 -2
- package/lib/types/index.d.ts +17 -0
- package/lib/types/index.js +129 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -129,59 +129,59 @@ window.__ModuleLoader__.load({
|
|
|
129
129
|
document.head.appendChild(tag);
|
|
130
130
|
}
|
|
131
131
|
var TokenQuotaPanel_module_css_default = {
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"radioRow": "NPLLIq_radioRow",
|
|
136
|
-
"rowHeader": "NPLLIq_rowHeader",
|
|
137
|
-
"dialogClose": "NPLLIq_dialogClose",
|
|
138
|
-
"dialogActions": "NPLLIq_dialogActions",
|
|
139
|
-
"logHeader": "NPLLIq_logHeader",
|
|
132
|
+
"dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
|
|
133
|
+
"tabLabel": "NPLLIq_tabLabel",
|
|
134
|
+
"monitorHint": "NPLLIq_monitorHint",
|
|
140
135
|
"monitorRow": "NPLLIq_monitorRow",
|
|
141
|
-
"
|
|
136
|
+
"fillOver": "NPLLIq_fillOver",
|
|
142
137
|
"bar": "NPLLIq_bar",
|
|
143
|
-
"monitorHint": "NPLLIq_monitorHint",
|
|
144
|
-
"monitorList": "NPLLIq_monitorList",
|
|
145
|
-
"dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
|
|
146
|
-
"selectBtn": "NPLLIq_selectBtn",
|
|
147
|
-
"headerActions": "NPLLIq_headerActions",
|
|
148
|
-
"switch": "NPLLIq_switch",
|
|
149
|
-
"input": "NPLLIq_input",
|
|
150
|
-
"logModelCol": "NPLLIq_logModelCol",
|
|
151
|
-
"rowName": "NPLLIq_rowName",
|
|
152
|
-
"fillIdle": "NPLLIq_fillIdle",
|
|
153
|
-
"subtitle": "NPLLIq_subtitle",
|
|
154
138
|
"panel": "NPLLIq_panel",
|
|
155
|
-
"dialog": "NPLLIq_dialog",
|
|
156
|
-
"fullNotice": "NPLLIq_fullNotice",
|
|
157
|
-
"headerText": "NPLLIq_headerText",
|
|
158
|
-
"save": "NPLLIq_save",
|
|
159
|
-
"rowMeta": "NPLLIq_rowMeta",
|
|
160
|
-
"fillOver": "NPLLIq_fillOver",
|
|
161
|
-
"tabIcon": "NPLLIq_tabIcon",
|
|
162
139
|
"header": "NPLLIq_header",
|
|
163
|
-
"tab": "NPLLIq_tab",
|
|
164
|
-
"gearBtn": "NPLLIq_gearBtn",
|
|
165
|
-
"dialogLabel": "NPLLIq_dialogLabel",
|
|
166
|
-
"monitorName": "NPLLIq_monitorName",
|
|
167
|
-
"dialogBtn": "NPLLIq_dialogBtn",
|
|
168
|
-
"dialogHeader": "NPLLIq_dialogHeader",
|
|
169
|
-
"logScroll": "NPLLIq_logScroll",
|
|
170
|
-
"resetRow": "NPLLIq_resetRow",
|
|
171
|
-
"currentBadge": "NPLLIq_currentBadge",
|
|
172
140
|
"controls": "NPLLIq_controls",
|
|
173
|
-
"
|
|
174
|
-
"logUsedCol": "NPLLIq_logUsedCol",
|
|
175
|
-
"noticeError": "NPLLIq_noticeError",
|
|
141
|
+
"switch": "NPLLIq_switch",
|
|
176
142
|
"resetSelect": "NPLLIq_resetSelect",
|
|
143
|
+
"input": "NPLLIq_input",
|
|
144
|
+
"save": "NPLLIq_save",
|
|
145
|
+
"selectBtn": "NPLLIq_selectBtn",
|
|
146
|
+
"dialogActions": "NPLLIq_dialogActions",
|
|
177
147
|
"row": "NPLLIq_row",
|
|
178
|
-
"
|
|
148
|
+
"fillWarn": "NPLLIq_fillWarn",
|
|
149
|
+
"settingsBtn": "NPLLIq_settingsBtn",
|
|
179
150
|
"dialogSection": "NPLLIq_dialogSection",
|
|
180
|
-
"
|
|
151
|
+
"dialogTitle": "NPLLIq_dialogTitle",
|
|
152
|
+
"dialogClose": "NPLLIq_dialogClose",
|
|
181
153
|
"logDialog": "NPLLIq_logDialog",
|
|
154
|
+
"tab": "NPLLIq_tab",
|
|
155
|
+
"logScroll": "NPLLIq_logScroll",
|
|
156
|
+
"fillIdle": "NPLLIq_fillIdle",
|
|
157
|
+
"logTable": "NPLLIq_logTable",
|
|
158
|
+
"body": "NPLLIq_body",
|
|
159
|
+
"logUsedCol": "NPLLIq_logUsedCol",
|
|
160
|
+
"resetRow": "NPLLIq_resetRow",
|
|
161
|
+
"headerText": "NPLLIq_headerText",
|
|
162
|
+
"notice": "NPLLIq_notice",
|
|
163
|
+
"headerActions": "NPLLIq_headerActions",
|
|
164
|
+
"monitorName": "NPLLIq_monitorName",
|
|
165
|
+
"rowName": "NPLLIq_rowName",
|
|
166
|
+
"rowMeta": "NPLLIq_rowMeta",
|
|
167
|
+
"dialog": "NPLLIq_dialog",
|
|
168
|
+
"currentBadge": "NPLLIq_currentBadge",
|
|
169
|
+
"subtitle": "NPLLIq_subtitle",
|
|
170
|
+
"dialogBtn": "NPLLIq_dialogBtn",
|
|
182
171
|
"logDayCol": "NPLLIq_logDayCol",
|
|
183
|
-
"
|
|
184
|
-
"
|
|
172
|
+
"dialogHeader": "NPLLIq_dialogHeader",
|
|
173
|
+
"dialogLabel": "NPLLIq_dialogLabel",
|
|
174
|
+
"gearBtn": "NPLLIq_gearBtn",
|
|
175
|
+
"logModelCol": "NPLLIq_logModelCol",
|
|
176
|
+
"noticeError": "NPLLIq_noticeError",
|
|
177
|
+
"tabIcon": "NPLLIq_tabIcon",
|
|
178
|
+
"logHeader": "NPLLIq_logHeader",
|
|
179
|
+
"fullNotice": "NPLLIq_fullNotice",
|
|
180
|
+
"monitorList": "NPLLIq_monitorList",
|
|
181
|
+
"radioRow": "NPLLIq_radioRow",
|
|
182
|
+
"title": "NPLLIq_title",
|
|
183
|
+
"rowHeader": "NPLLIq_rowHeader",
|
|
184
|
+
"collapse": "NPLLIq_collapse"
|
|
185
185
|
};
|
|
186
186
|
//#endregion
|
|
187
187
|
//#region lib/types/client/TokenQuotaPanel.js
|
|
@@ -943,9 +943,16 @@ window.__ModuleLoader__.load({
|
|
|
943
943
|
if (lastSessionId === void 0) return;
|
|
944
944
|
connection.api.sessions.models({ sessionId: lastSessionId }).then(({ result }) => {
|
|
945
945
|
if (result.ok) {
|
|
946
|
+
const currentChanged = lastCurrent?.provider !== result.value.current?.provider || lastCurrent?.model !== result.value.current?.model;
|
|
946
947
|
lastGroups = result.value.groups;
|
|
947
948
|
lastCurrent = result.value.current;
|
|
948
949
|
bound?.setDirectory(result.value.groups, result.value.current);
|
|
950
|
+
if (currentChanged) fetch("/token-quota", { headers: { accept: "application/json" } }).then((response) => response.ok ? response.json() : void 0, () => void 0).then((snapshot) => {
|
|
951
|
+
if (snapshot !== void 0) {
|
|
952
|
+
bound?.setSnapshot(snapshot);
|
|
953
|
+
actOnFull(snapshot);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
949
956
|
}
|
|
950
957
|
}, () => {});
|
|
951
958
|
};
|
|
@@ -970,7 +977,7 @@ window.__ModuleLoader__.load({
|
|
|
970
977
|
bound?.setSnapshot(snapshot);
|
|
971
978
|
actOnFull(snapshot);
|
|
972
979
|
});
|
|
973
|
-
if (pullCount %
|
|
980
|
+
if (pullCount % 2 === 0) refreshCurrent();
|
|
974
981
|
};
|
|
975
982
|
ctx.effect(() => {
|
|
976
983
|
pull();
|
package/lib/index.js
CHANGED
|
@@ -110,7 +110,7 @@ function usageTokens(usage) {
|
|
|
110
110
|
* Mount it beside the other rows (`@jxgame2020/dsh-token-quota`) and write
|
|
111
111
|
* per-model limits through the `token-quota` settings namespace.
|
|
112
112
|
*/
|
|
113
|
-
var TokenQuotaService = class extends Service {
|
|
113
|
+
var TokenQuotaService = class TokenQuotaService extends Service {
|
|
114
114
|
static Config = z.object({ storagePath: z.string().default("") });
|
|
115
115
|
storagePath;
|
|
116
116
|
reset = {
|
|
@@ -131,6 +131,11 @@ var TokenQuotaService = class extends Service {
|
|
|
131
131
|
/** Per-session folded model key from the latest `request/header`. */
|
|
132
132
|
headerKeys = /* @__PURE__ */ new WeakMap();
|
|
133
133
|
writeTimer;
|
|
134
|
+
/** Cached model directory: list of providers and their models, refreshed lazily. */
|
|
135
|
+
cachedModels = [];
|
|
136
|
+
modelsCachedAt = 0;
|
|
137
|
+
/** How long to reuse the cached model directory before refreshing (30s). */
|
|
138
|
+
static MODEL_CACHE_TTL_MS = 3e4;
|
|
134
139
|
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
135
140
|
disposeRoute;
|
|
136
141
|
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
@@ -192,7 +197,9 @@ var TokenQuotaService = class extends Service {
|
|
|
192
197
|
ctx.on("session/event", (session, event) => {
|
|
193
198
|
this.onSessionEvent(session, event);
|
|
194
199
|
});
|
|
195
|
-
ctx.on("agent/
|
|
200
|
+
ctx.on("agent/created", ({ agent }) => {
|
|
201
|
+
agent.ctx.on("agent/request", async (payload, next) => this.onRequest(payload, next));
|
|
202
|
+
});
|
|
196
203
|
ctx.effect(() => () => {
|
|
197
204
|
this.disposeLocal();
|
|
198
205
|
}, "token-quota: flush on unload");
|
|
@@ -308,6 +315,79 @@ var TokenQuotaService = class extends Service {
|
|
|
308
315
|
this.scheduleWrite();
|
|
309
316
|
}
|
|
310
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Refresh the cached list of all registered providers and their advertised
|
|
320
|
+
* models. Re-uses a still-fresh cache; any provider discovery failure is
|
|
321
|
+
* swallowed — we'd rather miss a candidate than crash the request waterfall.
|
|
322
|
+
*/
|
|
323
|
+
async refreshModels() {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
if (this.cachedModels.length > 0 && now - this.modelsCachedAt < TokenQuotaService.MODEL_CACHE_TTL_MS) return;
|
|
326
|
+
const llm = this.ctx.get("llm");
|
|
327
|
+
if (llm === void 0) {
|
|
328
|
+
this.cachedModels = [];
|
|
329
|
+
this.modelsCachedAt = now;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const next = [];
|
|
333
|
+
for (const provider of llm.listProviders()) try {
|
|
334
|
+
const models = await llm.listModels(provider.id);
|
|
335
|
+
for (const model of models) next.push({
|
|
336
|
+
provider: provider.id,
|
|
337
|
+
model: model.id
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.ctx.logger.debug?.("token-quota: failed to list models for provider \"%s\": %o", provider.id, error);
|
|
341
|
+
}
|
|
342
|
+
this.cachedModels = next;
|
|
343
|
+
this.modelsCachedAt = now;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Choose a replacement model for the exhausted current model according to
|
|
347
|
+
* the configured `onFull` strategy. Returns `undefined` when no eligible
|
|
348
|
+
* candidate exists (caller falls back to the historical stop-and-throw).
|
|
349
|
+
*/
|
|
350
|
+
pickReplacementModel(currentKey) {
|
|
351
|
+
const onFull = this.settingsSource().onFull ?? "stop";
|
|
352
|
+
if (onFull === "stop") return void 0;
|
|
353
|
+
if (this.cachedModels.length === 0) return void 0;
|
|
354
|
+
const availability = (key) => {
|
|
355
|
+
return {
|
|
356
|
+
limit: this.limitOf(key),
|
|
357
|
+
used: this.usage[key] ?? 0
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
const isMonitored = (key) => this.isMonitored(key);
|
|
361
|
+
const isAvailable = (key) => {
|
|
362
|
+
if (!isMonitored(key)) return false;
|
|
363
|
+
const { limit, used } = availability(key);
|
|
364
|
+
return limit <= 0 || used < limit;
|
|
365
|
+
};
|
|
366
|
+
const candidates = this.cachedModels.filter(({ provider, model }) => tokenQuotaKey(provider, model) !== currentKey).map(({ provider, model }) => {
|
|
367
|
+
const key = tokenQuotaKey(provider, model);
|
|
368
|
+
const { limit, used } = availability(key);
|
|
369
|
+
return {
|
|
370
|
+
provider,
|
|
371
|
+
model,
|
|
372
|
+
key,
|
|
373
|
+
limit,
|
|
374
|
+
used,
|
|
375
|
+
monitored: isMonitored(key)
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
switch (onFull) {
|
|
379
|
+
case "switchQuota": return candidates.filter((c) => c.monitored && c.limit > 0 && c.used < c.limit).sort((a, b) => a.used / a.limit - b.used / b.limit)[0];
|
|
380
|
+
case "switchAll": return candidates.filter((c) => isAvailable(c.key))[0];
|
|
381
|
+
case "switchPriority": {
|
|
382
|
+
const uncapped = candidates.find((c) => c.monitored && c.limit <= 0);
|
|
383
|
+
if (uncapped !== void 0) return uncapped;
|
|
384
|
+
const unmonitored = candidates.find((c) => !c.monitored);
|
|
385
|
+
if (unmonitored !== void 0) return unmonitored;
|
|
386
|
+
return candidates.find((c) => isAvailable(c.key));
|
|
387
|
+
}
|
|
388
|
+
default: return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
311
391
|
async onRequest(payload, next) {
|
|
312
392
|
const config = await next();
|
|
313
393
|
const { provider, model } = config;
|
|
@@ -319,6 +399,19 @@ var TokenQuotaService = class extends Service {
|
|
|
319
399
|
if (limit <= 0) return config;
|
|
320
400
|
const used = this.usage[key] ?? 0;
|
|
321
401
|
if (used < limit) return config;
|
|
402
|
+
await this.refreshModels();
|
|
403
|
+
const replacement = this.pickReplacementModel(key);
|
|
404
|
+
if (replacement !== void 0) {
|
|
405
|
+
this.ctx.logger.info("token-quota: \"%s\" is full (%d/%d); auto-switching to \"%s\" (%s strategy).", key, used, limit, replacement.key, this.settingsSource().onFull);
|
|
406
|
+
this.headerKeys.set(payload.agent.session, replacement.key);
|
|
407
|
+
this.modelsCachedAt = 0;
|
|
408
|
+
this.cachedModels = [];
|
|
409
|
+
return {
|
|
410
|
+
...config,
|
|
411
|
+
provider: replacement.provider,
|
|
412
|
+
model: replacement.model
|
|
413
|
+
};
|
|
414
|
+
}
|
|
322
415
|
throw new LlmError(`Daily token limit reached for "${provider}/${model}": ${used}/${limit} tokens used today. Switch model in the quota panel or raise its limit.`, TOKEN_QUOTA_EXCEEDED_CODE);
|
|
323
416
|
}
|
|
324
417
|
/**
|
|
@@ -126,16 +126,32 @@ export function apply(ctx) {
|
|
|
126
126
|
// single fact source for usage AND the resolved per-model caps; the panel
|
|
127
127
|
// converges within one poll interval (plus an immediate first pull). Every
|
|
128
128
|
// few pulls we re-read the model directory so a model switch made OUTSIDE
|
|
129
|
-
// this panel (the official selector, /model
|
|
129
|
+
// this panel (the official selector, /model, or an automatic server-side
|
|
130
|
+
// switch on full quota) moves the 「当前」 badge without waiting the full
|
|
131
|
+
// directory-refresh cadence.
|
|
130
132
|
let pullCount = 0;
|
|
131
133
|
const refreshCurrent = () => {
|
|
132
134
|
if (lastSessionId === undefined)
|
|
133
135
|
return;
|
|
134
136
|
void connection.api.sessions.models({ sessionId: lastSessionId }).then(({ result }) => {
|
|
135
137
|
if (result.ok) {
|
|
138
|
+
const currentChanged = lastCurrent?.provider !== result.value.current?.provider
|
|
139
|
+
|| lastCurrent?.model !== result.value.current?.model;
|
|
136
140
|
lastGroups = result.value.groups;
|
|
137
141
|
lastCurrent = result.value.current;
|
|
138
142
|
bound?.setDirectory(result.value.groups, result.value.current);
|
|
143
|
+
// If the server silently switched to a different model (e.g. the
|
|
144
|
+
// auto-switch on quota exhaustion), re-run the full-quota strategy
|
|
145
|
+
// immediately against the new current model so any further fallback
|
|
146
|
+
// happens without waiting the next poll.
|
|
147
|
+
if (currentChanged) {
|
|
148
|
+
void fetch('/token-quota', { headers: { accept: 'application/json' } }).then(response => response.ok ? response.json() : undefined, () => undefined).then(snapshot => {
|
|
149
|
+
if (snapshot !== undefined) {
|
|
150
|
+
bound?.setSnapshot(snapshot);
|
|
151
|
+
actOnFull(snapshot);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
139
155
|
}
|
|
140
156
|
}, () => { });
|
|
141
157
|
};
|
|
@@ -165,7 +181,7 @@ export function apply(ctx) {
|
|
|
165
181
|
bound?.setSnapshot(snapshot);
|
|
166
182
|
actOnFull(snapshot);
|
|
167
183
|
});
|
|
168
|
-
if (pullCount %
|
|
184
|
+
if (pullCount % 2 === 0)
|
|
169
185
|
refreshCurrent();
|
|
170
186
|
};
|
|
171
187
|
ctx.effect(() => {
|
package/lib/types/index.d.ts
CHANGED
|
@@ -58,6 +58,11 @@ export declare class TokenQuotaService extends Service {
|
|
|
58
58
|
/** Per-session folded model key from the latest `request/header`. */
|
|
59
59
|
private readonly headerKeys;
|
|
60
60
|
private writeTimer;
|
|
61
|
+
/** Cached model directory: list of providers and their models, refreshed lazily. */
|
|
62
|
+
private cachedModels;
|
|
63
|
+
private modelsCachedAt;
|
|
64
|
+
/** How long to reuse the cached model directory before refreshing (30s). */
|
|
65
|
+
private static readonly MODEL_CACHE_TTL_MS;
|
|
61
66
|
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
62
67
|
private disposeRoute;
|
|
63
68
|
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
@@ -82,6 +87,18 @@ export declare class TokenQuotaService extends Service {
|
|
|
82
87
|
/** Resolve one model's daily cap: positive = capped, `0` = unlimited. */
|
|
83
88
|
limitOf(key: string): number;
|
|
84
89
|
private onSessionEvent;
|
|
90
|
+
/**
|
|
91
|
+
* Refresh the cached list of all registered providers and their advertised
|
|
92
|
+
* models. Re-uses a still-fresh cache; any provider discovery failure is
|
|
93
|
+
* swallowed — we'd rather miss a candidate than crash the request waterfall.
|
|
94
|
+
*/
|
|
95
|
+
private refreshModels;
|
|
96
|
+
/**
|
|
97
|
+
* Choose a replacement model for the exhausted current model according to
|
|
98
|
+
* the configured `onFull` strategy. Returns `undefined` when no eligible
|
|
99
|
+
* candidate exists (caller falls back to the historical stop-and-throw).
|
|
100
|
+
*/
|
|
101
|
+
private pickReplacementModel;
|
|
85
102
|
private onRequest;
|
|
86
103
|
/**
|
|
87
104
|
* Roll over to a new reset cycle, archiving the finished cycle's counters
|
package/lib/types/index.js
CHANGED
|
@@ -114,6 +114,11 @@ export class TokenQuotaService extends Service {
|
|
|
114
114
|
/** Per-session folded model key from the latest `request/header`. */
|
|
115
115
|
headerKeys = new WeakMap();
|
|
116
116
|
writeTimer;
|
|
117
|
+
/** Cached model directory: list of providers and their models, refreshed lazily. */
|
|
118
|
+
cachedModels = [];
|
|
119
|
+
modelsCachedAt = 0;
|
|
120
|
+
/** How long to reuse the cached model directory before refreshing (30s). */
|
|
121
|
+
static MODEL_CACHE_TTL_MS = 30_000;
|
|
117
122
|
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
118
123
|
disposeRoute;
|
|
119
124
|
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
@@ -194,10 +199,16 @@ export class TokenQuotaService extends Service {
|
|
|
194
199
|
ctx.on('session/event', (session, event) => {
|
|
195
200
|
this.onSessionEvent(session, event);
|
|
196
201
|
});
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
202
|
+
// For every newly created agent, install an agent-scoped enforcement
|
|
203
|
+
// listener that runs AFTER the api-proxy's model-selection listener (so
|
|
204
|
+
// user selections always apply first), and is therefore the last word on
|
|
205
|
+
// the chosen provider/model. When the resolved model is at its cap and
|
|
206
|
+
// the configured strategy allows an automatic switch, it rewrites the
|
|
207
|
+
// call config to a suitable replacement model instead of throwing, so
|
|
208
|
+
// the turn proceeds uninterrupted.
|
|
209
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
210
|
+
agent.ctx.on('agent/request', async (payload, next) => this.onRequest(payload, next));
|
|
211
|
+
});
|
|
201
212
|
ctx.effect(() => () => { this.disposeLocal(); }, 'token-quota: flush on unload');
|
|
202
213
|
}
|
|
203
214
|
/** Serve the current snapshot over the plugin-owned HTTP route. */
|
|
@@ -300,6 +311,101 @@ export class TokenQuotaService extends Service {
|
|
|
300
311
|
this.scheduleWrite();
|
|
301
312
|
}
|
|
302
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Refresh the cached list of all registered providers and their advertised
|
|
316
|
+
* models. Re-uses a still-fresh cache; any provider discovery failure is
|
|
317
|
+
* swallowed — we'd rather miss a candidate than crash the request waterfall.
|
|
318
|
+
*/
|
|
319
|
+
async refreshModels() {
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
if (this.cachedModels.length > 0 && now - this.modelsCachedAt < TokenQuotaService.MODEL_CACHE_TTL_MS) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const llm = this.ctx.get('llm');
|
|
325
|
+
if (llm === undefined) {
|
|
326
|
+
this.cachedModels = [];
|
|
327
|
+
this.modelsCachedAt = now;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const next = [];
|
|
331
|
+
for (const provider of llm.listProviders()) {
|
|
332
|
+
try {
|
|
333
|
+
const models = await llm.listModels(provider.id);
|
|
334
|
+
for (const model of models) {
|
|
335
|
+
next.push({ provider: provider.id, model: model.id });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
// Transient provider failure — keep whatever we already have for that
|
|
340
|
+
// provider and continue with the others.
|
|
341
|
+
this.ctx.logger.debug?.('token-quota: failed to list models for provider "%s": %o', provider.id, error);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.cachedModels = next;
|
|
345
|
+
this.modelsCachedAt = now;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Choose a replacement model for the exhausted current model according to
|
|
349
|
+
* the configured `onFull` strategy. Returns `undefined` when no eligible
|
|
350
|
+
* candidate exists (caller falls back to the historical stop-and-throw).
|
|
351
|
+
*/
|
|
352
|
+
pickReplacementModel(currentKey) {
|
|
353
|
+
const doc = this.settingsSource();
|
|
354
|
+
const onFull = doc.onFull ?? 'stop';
|
|
355
|
+
if (onFull === 'stop')
|
|
356
|
+
return undefined;
|
|
357
|
+
if (this.cachedModels.length === 0)
|
|
358
|
+
return undefined;
|
|
359
|
+
const availability = (key) => {
|
|
360
|
+
const limit = this.limitOf(key);
|
|
361
|
+
const used = this.usage[key] ?? 0;
|
|
362
|
+
return { limit, used };
|
|
363
|
+
};
|
|
364
|
+
const isMonitored = (key) => this.isMonitored(key);
|
|
365
|
+
const isAvailable = (key) => {
|
|
366
|
+
if (!isMonitored(key))
|
|
367
|
+
return false;
|
|
368
|
+
const { limit, used } = availability(key);
|
|
369
|
+
return limit <= 0 || used < limit;
|
|
370
|
+
};
|
|
371
|
+
// Build candidate list excluding the currently exhausted model.
|
|
372
|
+
const candidates = this.cachedModels
|
|
373
|
+
.filter(({ provider, model }) => tokenQuotaKey(provider, model) !== currentKey)
|
|
374
|
+
.map(({ provider, model }) => {
|
|
375
|
+
const key = tokenQuotaKey(provider, model);
|
|
376
|
+
const { limit, used } = availability(key);
|
|
377
|
+
return { provider, model, key, limit, used, monitored: isMonitored(key) };
|
|
378
|
+
});
|
|
379
|
+
switch (onFull) {
|
|
380
|
+
case 'switchQuota': {
|
|
381
|
+
// Another monitored, capped model that still has headroom, sorted by
|
|
382
|
+
// lowest fill ratio so we spread load across capped models evenly.
|
|
383
|
+
const eligible = candidates
|
|
384
|
+
.filter(c => c.monitored && c.limit > 0 && c.used < c.limit)
|
|
385
|
+
.sort((a, b) => (a.used / a.limit) - (b.used / b.limit));
|
|
386
|
+
return eligible[0];
|
|
387
|
+
}
|
|
388
|
+
case 'switchAll': {
|
|
389
|
+
// Any monitored model that has headroom (capped or unlimited).
|
|
390
|
+
const eligible = candidates.filter(c => isAvailable(c.key));
|
|
391
|
+
return eligible[0];
|
|
392
|
+
}
|
|
393
|
+
case 'switchPriority': {
|
|
394
|
+
// 1. Unlimited monitored models. 2. Unmonitored models (never capped).
|
|
395
|
+
// 3. Capped-but-free monitored models.
|
|
396
|
+
const uncapped = candidates.find(c => c.monitored && c.limit <= 0);
|
|
397
|
+
if (uncapped !== undefined)
|
|
398
|
+
return uncapped;
|
|
399
|
+
const unmonitored = candidates.find(c => !c.monitored);
|
|
400
|
+
if (unmonitored !== undefined)
|
|
401
|
+
return unmonitored;
|
|
402
|
+
const free = candidates.find(c => isAvailable(c.key));
|
|
403
|
+
return free;
|
|
404
|
+
}
|
|
405
|
+
default:
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
303
409
|
async onRequest(payload, next) {
|
|
304
410
|
const config = await next();
|
|
305
411
|
const { provider, model } = config;
|
|
@@ -318,6 +424,25 @@ export class TokenQuotaService extends Service {
|
|
|
318
424
|
const used = this.usage[key] ?? 0;
|
|
319
425
|
if (used < limit)
|
|
320
426
|
return config;
|
|
427
|
+
// Current model is at or over cap. Try an automatic switch when the
|
|
428
|
+
// configured strategy allows it and a candidate exists; otherwise fall
|
|
429
|
+
// through to the historical hard stop.
|
|
430
|
+
await this.refreshModels();
|
|
431
|
+
const replacement = this.pickReplacementModel(key);
|
|
432
|
+
if (replacement !== undefined) {
|
|
433
|
+
this.ctx.logger.info('token-quota: "%s" is full (%d/%d); auto-switching to "%s" (%s strategy).', key, used, limit, replacement.key, this.settingsSource().onFull);
|
|
434
|
+
this.headerKeys.set(payload.agent.session, replacement.key);
|
|
435
|
+
// Invalidate the cached directory so the next request re-discovers any
|
|
436
|
+
// newly-registered models and so the next client-side poll sees the
|
|
437
|
+
// switch reflected without waiting a full TTL.
|
|
438
|
+
this.modelsCachedAt = 0;
|
|
439
|
+
this.cachedModels = [];
|
|
440
|
+
return {
|
|
441
|
+
...config,
|
|
442
|
+
provider: replacement.provider,
|
|
443
|
+
model: replacement.model,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
321
446
|
throw new LlmError(`Daily token limit reached for "${provider}/${model}": ${used}/${limit} tokens used today. `
|
|
322
447
|
+ 'Switch model in the quota panel or raise its limit.', TOKEN_QUOTA_EXCEEDED_CODE);
|
|
323
448
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxgame2020/dsh-token-quota",
|
|
3
3
|
"description": "Daily per-model token quota accounting, enforcement, and a floating quota panel for the DeepSeek Harness — a fully self-contained plugin (no core-Harness wiring, no generated RPC contract)",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|