@jxgame2020/dsh-token-quota 0.1.5 → 0.1.7
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 +60 -59
- package/lib/index.js +88 -3
- package/lib/types/client/TokenQuotaPanel.js +0 -1
- package/lib/types/client/index.js +34 -14
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.js +2 -4
- package/lib/types/index.d.ts +17 -0
- package/lib/types/index.js +125 -5
- package/lib/types/types.d.ts +5 -4
- package/lib/types/types.js +2 -2
- 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
|
-
"collapse": "NPLLIq_collapse",
|
|
135
|
-
"radioRow": "NPLLIq_radioRow",
|
|
136
|
-
"rowHeader": "NPLLIq_rowHeader",
|
|
137
|
-
"dialogClose": "NPLLIq_dialogClose",
|
|
138
|
-
"dialogActions": "NPLLIq_dialogActions",
|
|
139
|
-
"logHeader": "NPLLIq_logHeader",
|
|
140
|
-
"monitorRow": "NPLLIq_monitorRow",
|
|
141
|
-
"dialogTitle": "NPLLIq_dialogTitle",
|
|
142
|
-
"bar": "NPLLIq_bar",
|
|
143
|
-
"monitorHint": "NPLLIq_monitorHint",
|
|
132
|
+
"fillOver": "NPLLIq_fillOver",
|
|
133
|
+
"fillIdle": "NPLLIq_fillIdle",
|
|
144
134
|
"monitorList": "NPLLIq_monitorList",
|
|
145
|
-
"dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
|
|
146
|
-
"selectBtn": "NPLLIq_selectBtn",
|
|
147
|
-
"headerActions": "NPLLIq_headerActions",
|
|
148
135
|
"switch": "NPLLIq_switch",
|
|
149
|
-
"input": "NPLLIq_input",
|
|
150
|
-
"logModelCol": "NPLLIq_logModelCol",
|
|
151
|
-
"rowName": "NPLLIq_rowName",
|
|
152
|
-
"fillIdle": "NPLLIq_fillIdle",
|
|
153
136
|
"subtitle": "NPLLIq_subtitle",
|
|
154
|
-
"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
|
-
"header": "NPLLIq_header",
|
|
163
|
-
"tab": "NPLLIq_tab",
|
|
164
|
-
"gearBtn": "NPLLIq_gearBtn",
|
|
165
|
-
"dialogLabel": "NPLLIq_dialogLabel",
|
|
166
137
|
"monitorName": "NPLLIq_monitorName",
|
|
138
|
+
"currentBadge": "NPLLIq_currentBadge",
|
|
139
|
+
"collapse": "NPLLIq_collapse",
|
|
140
|
+
"row": "NPLLIq_row",
|
|
141
|
+
"fillWarn": "NPLLIq_fillWarn",
|
|
142
|
+
"rowHeader": "NPLLIq_rowHeader",
|
|
143
|
+
"input": "NPLLIq_input",
|
|
144
|
+
"radioRow": "NPLLIq_radioRow",
|
|
145
|
+
"logHeader": "NPLLIq_logHeader",
|
|
146
|
+
"logModelCol": "NPLLIq_logModelCol",
|
|
147
|
+
"logDayCol": "NPLLIq_logDayCol",
|
|
167
148
|
"dialogBtn": "NPLLIq_dialogBtn",
|
|
168
|
-
"
|
|
149
|
+
"dialogActions": "NPLLIq_dialogActions",
|
|
150
|
+
"logDialog": "NPLLIq_logDialog",
|
|
151
|
+
"notice": "NPLLIq_notice",
|
|
152
|
+
"tabLabel": "NPLLIq_tabLabel",
|
|
153
|
+
"dialog": "NPLLIq_dialog",
|
|
169
154
|
"logScroll": "NPLLIq_logScroll",
|
|
170
155
|
"resetRow": "NPLLIq_resetRow",
|
|
171
|
-
"
|
|
172
|
-
"
|
|
156
|
+
"rowMeta": "NPLLIq_rowMeta",
|
|
157
|
+
"tab": "NPLLIq_tab",
|
|
158
|
+
"monitorHint": "NPLLIq_monitorHint",
|
|
173
159
|
"title": "NPLLIq_title",
|
|
174
|
-
"logUsedCol": "NPLLIq_logUsedCol",
|
|
175
160
|
"noticeError": "NPLLIq_noticeError",
|
|
161
|
+
"monitorRow": "NPLLIq_monitorRow",
|
|
162
|
+
"dialogBtnPrimary": "NPLLIq_dialogBtnPrimary",
|
|
163
|
+
"tabIcon": "NPLLIq_tabIcon",
|
|
164
|
+
"rowName": "NPLLIq_rowName",
|
|
165
|
+
"save": "NPLLIq_save",
|
|
166
|
+
"fullNotice": "NPLLIq_fullNotice",
|
|
167
|
+
"dialogClose": "NPLLIq_dialogClose",
|
|
168
|
+
"header": "NPLLIq_header",
|
|
169
|
+
"bar": "NPLLIq_bar",
|
|
170
|
+
"dialogHeader": "NPLLIq_dialogHeader",
|
|
171
|
+
"headerText": "NPLLIq_headerText",
|
|
172
|
+
"selectBtn": "NPLLIq_selectBtn",
|
|
176
173
|
"resetSelect": "NPLLIq_resetSelect",
|
|
177
|
-
"row": "NPLLIq_row",
|
|
178
|
-
"tabLabel": "NPLLIq_tabLabel",
|
|
179
|
-
"dialogSection": "NPLLIq_dialogSection",
|
|
180
174
|
"logTable": "NPLLIq_logTable",
|
|
181
|
-
"
|
|
182
|
-
"
|
|
175
|
+
"panel": "NPLLIq_panel",
|
|
176
|
+
"controls": "NPLLIq_controls",
|
|
177
|
+
"headerActions": "NPLLIq_headerActions",
|
|
178
|
+
"body": "NPLLIq_body",
|
|
183
179
|
"settingsBtn": "NPLLIq_settingsBtn",
|
|
184
|
-
"
|
|
180
|
+
"gearBtn": "NPLLIq_gearBtn",
|
|
181
|
+
"logUsedCol": "NPLLIq_logUsedCol",
|
|
182
|
+
"dialogTitle": "NPLLIq_dialogTitle",
|
|
183
|
+
"dialogSection": "NPLLIq_dialogSection",
|
|
184
|
+
"dialogLabel": "NPLLIq_dialogLabel"
|
|
185
185
|
};
|
|
186
186
|
//#endregion
|
|
187
187
|
//#region lib/types/client/TokenQuotaPanel.js
|
|
@@ -220,10 +220,6 @@ window.__ModuleLoader__.load({
|
|
|
220
220
|
{
|
|
221
221
|
value: "switchAll",
|
|
222
222
|
labelKey: "fullSwitchAll"
|
|
223
|
-
},
|
|
224
|
-
{
|
|
225
|
-
value: "switchPriority",
|
|
226
|
-
labelKey: "fullSwitchPriority"
|
|
227
223
|
}
|
|
228
224
|
];
|
|
229
225
|
/**
|
|
@@ -765,8 +761,7 @@ window.__ModuleLoader__.load({
|
|
|
765
761
|
fullActionLabel: "满额后处理",
|
|
766
762
|
fullStop: "停止请求并提示",
|
|
767
763
|
fullSwitchQuota: "自动切换到其它限额模型",
|
|
768
|
-
fullSwitchAll: "
|
|
769
|
-
fullSwitchPriority: "自动切换(优先非限额,其次未监控)",
|
|
764
|
+
fullSwitchAll: "自动切换(优先限额模型,其次非限额)",
|
|
770
765
|
fullNotice: "当前模型今日额度已用尽,请求已停止。请选择其它模型或调整限额。",
|
|
771
766
|
fullSwitchFailed: "没有可切换的模型,请手动选择其它模型或调整限额",
|
|
772
767
|
fullSwitchTo: "已自动切换到 {model}",
|
|
@@ -811,8 +806,7 @@ window.__ModuleLoader__.load({
|
|
|
811
806
|
fullActionLabel: "When a model is full",
|
|
812
807
|
fullStop: "Stop and prompt",
|
|
813
808
|
fullSwitchQuota: "Switch to another quota model",
|
|
814
|
-
fullSwitchAll: "Switch
|
|
815
|
-
fullSwitchPriority: "Prefer uncapped, then unmonitored",
|
|
809
|
+
fullSwitchAll: "Switch models (quota models first, then uncapped)",
|
|
816
810
|
fullNotice: "Today’s quota for the current model is exhausted; the request was stopped. Pick another model or raise its limit.",
|
|
817
811
|
fullSwitchFailed: "No switchable model — pick one manually or raise a limit",
|
|
818
812
|
fullSwitchTo: "Auto-switched to {model}",
|
|
@@ -899,14 +893,13 @@ window.__ModuleLoader__.load({
|
|
|
899
893
|
used: entry?.used ?? 0
|
|
900
894
|
});
|
|
901
895
|
}
|
|
902
|
-
const
|
|
903
|
-
if (lastOnFull === "switchQuota") return candidates.filter(
|
|
904
|
-
if (lastOnFull === "switchAll")
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
return candidates.find(available);
|
|
896
|
+
const cappedFree = (candidate) => isMonitoredKey(candidate.key) && candidate.limit > 0 && candidate.used < candidate.limit;
|
|
897
|
+
if (lastOnFull === "switchQuota") return candidates.filter(cappedFree).sort((a, b) => a.used / a.limit - b.used / b.limit)[0];
|
|
898
|
+
if (lastOnFull === "switchAll") {
|
|
899
|
+
const withQuota = candidates.filter(cappedFree).sort((a, b) => a.used / a.limit - b.used / b.limit);
|
|
900
|
+
if (withQuota.length > 0) return withQuota[0];
|
|
901
|
+
return candidates.find((candidate) => isMonitoredKey(candidate.key) && candidate.limit <= 0);
|
|
902
|
+
}
|
|
910
903
|
};
|
|
911
904
|
/** Run the configured full-quota strategy once per exhausted model. */
|
|
912
905
|
const actOnFull = (snapshot) => {
|
|
@@ -943,9 +936,16 @@ window.__ModuleLoader__.load({
|
|
|
943
936
|
if (lastSessionId === void 0) return;
|
|
944
937
|
connection.api.sessions.models({ sessionId: lastSessionId }).then(({ result }) => {
|
|
945
938
|
if (result.ok) {
|
|
939
|
+
const currentChanged = lastCurrent?.provider !== result.value.current?.provider || lastCurrent?.model !== result.value.current?.model;
|
|
946
940
|
lastGroups = result.value.groups;
|
|
947
941
|
lastCurrent = result.value.current;
|
|
948
942
|
bound?.setDirectory(result.value.groups, result.value.current);
|
|
943
|
+
if (currentChanged) fetch("/token-quota", { headers: { accept: "application/json" } }).then((response) => response.ok ? response.json() : void 0, () => void 0).then((snapshot) => {
|
|
944
|
+
if (snapshot !== void 0) {
|
|
945
|
+
bound?.setSnapshot(snapshot);
|
|
946
|
+
actOnFull(snapshot);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
949
|
}
|
|
950
950
|
}, () => {});
|
|
951
951
|
};
|
|
@@ -953,7 +953,8 @@ window.__ModuleLoader__.load({
|
|
|
953
953
|
pullCount += 1;
|
|
954
954
|
const doc = scope.getSnapshot().value;
|
|
955
955
|
lastMonitored = doc?.monitored !== void 0 && doc.monitored.length > 0 ? doc.monitored : null;
|
|
956
|
-
|
|
956
|
+
const rawOnFull = doc?.onFull ?? "stop";
|
|
957
|
+
lastOnFull = rawOnFull === "switchPriority" ? "switchAll" : rawOnFull;
|
|
957
958
|
lastReset = doc?.reset !== void 0 && doc.reset !== null && typeof doc.reset === "object" && typeof doc.reset.offsetHours === "number" ? doc.reset : null;
|
|
958
959
|
bound?.setSettings(lastMonitored, lastOnFull);
|
|
959
960
|
bound?.setReset(lastReset);
|
|
@@ -970,7 +971,7 @@ window.__ModuleLoader__.load({
|
|
|
970
971
|
bound?.setSnapshot(snapshot);
|
|
971
972
|
actOnFull(snapshot);
|
|
972
973
|
});
|
|
973
|
-
if (pullCount %
|
|
974
|
+
if (pullCount % 2 === 0) refreshCurrent();
|
|
974
975
|
};
|
|
975
976
|
ctx.effect(() => {
|
|
976
977
|
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`). */
|
|
@@ -152,7 +157,7 @@ var TokenQuotaService = class extends Service {
|
|
|
152
157
|
return {
|
|
153
158
|
limits: doc.limits ?? {},
|
|
154
159
|
monitored: doc.monitored ?? [],
|
|
155
|
-
onFull: doc.onFull ?? "stop",
|
|
160
|
+
onFull: doc.onFull === "switchPriority" ? "switchAll" : doc.onFull ?? "stop",
|
|
156
161
|
reset: doc.reset ?? void 0
|
|
157
162
|
};
|
|
158
163
|
};
|
|
@@ -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,71 @@ 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 candidates = this.cachedModels.filter(({ provider, model }) => tokenQuotaKey(provider, model) !== currentKey).map(({ provider, model }) => {
|
|
362
|
+
const key = tokenQuotaKey(provider, model);
|
|
363
|
+
const { limit, used } = availability(key);
|
|
364
|
+
return {
|
|
365
|
+
provider,
|
|
366
|
+
model,
|
|
367
|
+
key,
|
|
368
|
+
limit,
|
|
369
|
+
used,
|
|
370
|
+
monitored: isMonitored(key)
|
|
371
|
+
};
|
|
372
|
+
});
|
|
373
|
+
switch (onFull) {
|
|
374
|
+
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];
|
|
375
|
+
case "switchAll": {
|
|
376
|
+
const cappedFree = candidates.filter((c) => c.monitored && c.limit > 0 && c.used < c.limit).sort((a, b) => a.used / a.limit - b.used / b.limit);
|
|
377
|
+
if (cappedFree.length > 0) return cappedFree[0];
|
|
378
|
+
return candidates.find((c) => c.monitored && c.limit <= 0);
|
|
379
|
+
}
|
|
380
|
+
default: return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
311
383
|
async onRequest(payload, next) {
|
|
312
384
|
const config = await next();
|
|
313
385
|
const { provider, model } = config;
|
|
@@ -319,6 +391,19 @@ var TokenQuotaService = class extends Service {
|
|
|
319
391
|
if (limit <= 0) return config;
|
|
320
392
|
const used = this.usage[key] ?? 0;
|
|
321
393
|
if (used < limit) return config;
|
|
394
|
+
await this.refreshModels();
|
|
395
|
+
const replacement = this.pickReplacementModel(key);
|
|
396
|
+
if (replacement !== void 0) {
|
|
397
|
+
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);
|
|
398
|
+
this.headerKeys.set(payload.agent.session, replacement.key);
|
|
399
|
+
this.modelsCachedAt = 0;
|
|
400
|
+
this.cachedModels = [];
|
|
401
|
+
return {
|
|
402
|
+
...config,
|
|
403
|
+
provider: replacement.provider,
|
|
404
|
+
model: replacement.model
|
|
405
|
+
};
|
|
406
|
+
}
|
|
322
407
|
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
408
|
}
|
|
324
409
|
/**
|
|
@@ -34,7 +34,6 @@ const FULL_ACTIONS = [
|
|
|
34
34
|
{ value: 'stop', labelKey: 'fullStop' },
|
|
35
35
|
{ value: 'switchQuota', labelKey: 'fullSwitchQuota' },
|
|
36
36
|
{ value: 'switchAll', labelKey: 'fullSwitchAll' },
|
|
37
|
-
{ value: 'switchPriority', labelKey: 'fullSwitchPriority' },
|
|
38
37
|
];
|
|
39
38
|
/**
|
|
40
39
|
* Pointer-driven drag of a fixed-positioned element. Deltas are applied
|
|
@@ -70,23 +70,23 @@ export function apply(ctx) {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
const
|
|
73
|
+
const cappedFree = (candidate) => isMonitoredKey(candidate.key) && candidate.limit > 0 && candidate.used < candidate.limit;
|
|
74
74
|
if (lastOnFull === 'switchQuota') {
|
|
75
75
|
return candidates
|
|
76
|
-
.filter(
|
|
76
|
+
.filter(cappedFree)
|
|
77
77
|
.sort((a, b) => (a.used / a.limit) - (b.used / b.limit))[0];
|
|
78
78
|
}
|
|
79
79
|
if (lastOnFull === 'switchAll') {
|
|
80
|
-
|
|
80
|
+
// Monitored only: capped models with headroom first (lowest fill ratio),
|
|
81
|
+
// then uncapped monitored models as fallback.
|
|
82
|
+
const withQuota = candidates
|
|
83
|
+
.filter(cappedFree)
|
|
84
|
+
.sort((a, b) => (a.used / a.limit) - (b.used / b.limit));
|
|
85
|
+
if (withQuota.length > 0)
|
|
86
|
+
return withQuota[0];
|
|
87
|
+
return candidates.find(candidate => isMonitoredKey(candidate.key) && candidate.limit <= 0);
|
|
81
88
|
}
|
|
82
|
-
|
|
83
|
-
const uncapped = candidates.find(candidate => isMonitoredKey(candidate.key) && candidate.limit <= 0);
|
|
84
|
-
if (uncapped !== undefined)
|
|
85
|
-
return uncapped;
|
|
86
|
-
const unmonitored = candidates.find(candidate => !isMonitoredKey(candidate.key));
|
|
87
|
-
if (unmonitored !== undefined)
|
|
88
|
-
return unmonitored;
|
|
89
|
-
return candidates.find(available);
|
|
89
|
+
return undefined;
|
|
90
90
|
};
|
|
91
91
|
/** Run the configured full-quota strategy once per exhausted model. */
|
|
92
92
|
const actOnFull = (snapshot) => {
|
|
@@ -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
|
};
|
|
@@ -143,7 +159,11 @@ export function apply(ctx) {
|
|
|
143
159
|
pullCount += 1;
|
|
144
160
|
const doc = scope.getSnapshot().value;
|
|
145
161
|
lastMonitored = doc?.monitored !== undefined && doc.monitored.length > 0 ? doc.monitored : null;
|
|
146
|
-
|
|
162
|
+
// Legacy `switchPriority` (removed in 0.1.7) maps to `switchAll`.
|
|
163
|
+
// The runtime document may still hold the old value until the user
|
|
164
|
+
// re-saves the settings dialog.
|
|
165
|
+
const rawOnFull = (doc?.onFull ?? 'stop');
|
|
166
|
+
lastOnFull = rawOnFull === 'switchPriority' ? 'switchAll' : rawOnFull;
|
|
147
167
|
lastReset = doc?.reset !== undefined && doc.reset !== null
|
|
148
168
|
&& typeof doc.reset === 'object'
|
|
149
169
|
&& typeof doc.reset.offsetHours === 'number'
|
|
@@ -165,7 +185,7 @@ export function apply(ctx) {
|
|
|
165
185
|
bound?.setSnapshot(snapshot);
|
|
166
186
|
actOnFull(snapshot);
|
|
167
187
|
});
|
|
168
|
-
if (pullCount %
|
|
188
|
+
if (pullCount % 2 === 0)
|
|
169
189
|
refreshCurrent();
|
|
170
190
|
};
|
|
171
191
|
ctx.effect(() => {
|
|
@@ -34,7 +34,6 @@ export declare const zh: {
|
|
|
34
34
|
fullStop: string;
|
|
35
35
|
fullSwitchQuota: string;
|
|
36
36
|
fullSwitchAll: string;
|
|
37
|
-
fullSwitchPriority: string;
|
|
38
37
|
fullNotice: string;
|
|
39
38
|
fullSwitchFailed: string;
|
|
40
39
|
fullSwitchTo: string;
|
|
@@ -82,7 +81,6 @@ export declare const en: {
|
|
|
82
81
|
fullStop: string;
|
|
83
82
|
fullSwitchQuota: string;
|
|
84
83
|
fullSwitchAll: string;
|
|
85
|
-
fullSwitchPriority: string;
|
|
86
84
|
fullNotice: string;
|
|
87
85
|
fullSwitchFailed: string;
|
|
88
86
|
fullSwitchTo: string;
|
|
@@ -33,8 +33,7 @@ export const zh = {
|
|
|
33
33
|
fullActionLabel: '满额后处理',
|
|
34
34
|
fullStop: '停止请求并提示',
|
|
35
35
|
fullSwitchQuota: '自动切换到其它限额模型',
|
|
36
|
-
fullSwitchAll: '
|
|
37
|
-
fullSwitchPriority: '自动切换(优先非限额,其次未监控)',
|
|
36
|
+
fullSwitchAll: '自动切换(优先限额模型,其次非限额)',
|
|
38
37
|
fullNotice: '当前模型今日额度已用尽,请求已停止。请选择其它模型或调整限额。',
|
|
39
38
|
fullSwitchFailed: '没有可切换的模型,请手动选择其它模型或调整限额',
|
|
40
39
|
fullSwitchTo: '已自动切换到 {model}',
|
|
@@ -79,8 +78,7 @@ export const en = {
|
|
|
79
78
|
fullActionLabel: 'When a model is full',
|
|
80
79
|
fullStop: 'Stop and prompt',
|
|
81
80
|
fullSwitchQuota: 'Switch to another quota model',
|
|
82
|
-
fullSwitchAll: 'Switch
|
|
83
|
-
fullSwitchPriority: 'Prefer uncapped, then unmonitored',
|
|
81
|
+
fullSwitchAll: 'Switch models (quota models first, then uncapped)',
|
|
84
82
|
fullNotice: 'Today\u2019s quota for the current model is exhausted; the request was stopped. Pick another model or raise its limit.',
|
|
85
83
|
fullSwitchFailed: 'No switchable model \u2014 pick one manually or raise a limit',
|
|
86
84
|
fullSwitchTo: 'Auto-switched to {model}',
|
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
|
@@ -45,6 +45,10 @@ import { assertTokenQuotaLimit, splitTokenQuotaKey } from "./invariant.js";
|
|
|
45
45
|
const TOKEN_QUOTA_SETTINGS_SCHEMA = z.object({
|
|
46
46
|
limits: z.dict(z.number().step(1).min(0)).default({}),
|
|
47
47
|
monitored: z.array(z.string()).default([]),
|
|
48
|
+
// `switchPriority` is a legacy value accepted for documents saved by older
|
|
49
|
+
// plugin versions; the runtime normalizes it to `switchAll` (see
|
|
50
|
+
// settingsSource below) so existing users keep auto-switching behavior
|
|
51
|
+
// without reconfiguring.
|
|
48
52
|
onFull: z.union(['stop', 'switchQuota', 'switchAll', 'switchPriority']).default('stop'),
|
|
49
53
|
// `reset` is a user-facing preference outside the validated surface: the
|
|
50
54
|
// panel writes it and the host validates the shape at runtime. `z.any` with
|
|
@@ -114,6 +118,11 @@ export class TokenQuotaService extends Service {
|
|
|
114
118
|
/** Per-session folded model key from the latest `request/header`. */
|
|
115
119
|
headerKeys = new WeakMap();
|
|
116
120
|
writeTimer;
|
|
121
|
+
/** Cached model directory: list of providers and their models, refreshed lazily. */
|
|
122
|
+
cachedModels = [];
|
|
123
|
+
modelsCachedAt = 0;
|
|
124
|
+
/** How long to reuse the cached model directory before refreshing (30s). */
|
|
125
|
+
static MODEL_CACHE_TTL_MS = 30_000;
|
|
117
126
|
/** Disposer for the optional HTTP snapshot route (`GET /token-quota`). */
|
|
118
127
|
disposeRoute;
|
|
119
128
|
/** Disposer for the optional usage-history route (`GET /token-quota/log`). */
|
|
@@ -142,7 +151,9 @@ export class TokenQuotaService extends Service {
|
|
|
142
151
|
return {
|
|
143
152
|
limits: doc.limits ?? {},
|
|
144
153
|
monitored: doc.monitored ?? [],
|
|
145
|
-
|
|
154
|
+
// Legacy `switchPriority` (removed in 0.1.7) maps to `switchAll`:
|
|
155
|
+
// capped models first, then uncapped — both monitored-only.
|
|
156
|
+
onFull: doc.onFull === 'switchPriority' ? 'switchAll' : (doc.onFull ?? 'stop'),
|
|
146
157
|
reset: doc.reset ?? undefined,
|
|
147
158
|
};
|
|
148
159
|
};
|
|
@@ -194,10 +205,16 @@ export class TokenQuotaService extends Service {
|
|
|
194
205
|
ctx.on('session/event', (session, event) => {
|
|
195
206
|
this.onSessionEvent(session, event);
|
|
196
207
|
});
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
208
|
+
// For every newly created agent, install an agent-scoped enforcement
|
|
209
|
+
// listener that runs AFTER the api-proxy's model-selection listener (so
|
|
210
|
+
// user selections always apply first), and is therefore the last word on
|
|
211
|
+
// the chosen provider/model. When the resolved model is at its cap and
|
|
212
|
+
// the configured strategy allows an automatic switch, it rewrites the
|
|
213
|
+
// call config to a suitable replacement model instead of throwing, so
|
|
214
|
+
// the turn proceeds uninterrupted.
|
|
215
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
216
|
+
agent.ctx.on('agent/request', async (payload, next) => this.onRequest(payload, next));
|
|
217
|
+
});
|
|
201
218
|
ctx.effect(() => () => { this.disposeLocal(); }, 'token-quota: flush on unload');
|
|
202
219
|
}
|
|
203
220
|
/** Serve the current snapshot over the plugin-owned HTTP route. */
|
|
@@ -300,6 +317,90 @@ export class TokenQuotaService extends Service {
|
|
|
300
317
|
this.scheduleWrite();
|
|
301
318
|
}
|
|
302
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Refresh the cached list of all registered providers and their advertised
|
|
322
|
+
* models. Re-uses a still-fresh cache; any provider discovery failure is
|
|
323
|
+
* swallowed — we'd rather miss a candidate than crash the request waterfall.
|
|
324
|
+
*/
|
|
325
|
+
async refreshModels() {
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (this.cachedModels.length > 0 && now - this.modelsCachedAt < TokenQuotaService.MODEL_CACHE_TTL_MS) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const llm = this.ctx.get('llm');
|
|
331
|
+
if (llm === undefined) {
|
|
332
|
+
this.cachedModels = [];
|
|
333
|
+
this.modelsCachedAt = now;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const next = [];
|
|
337
|
+
for (const provider of llm.listProviders()) {
|
|
338
|
+
try {
|
|
339
|
+
const models = await llm.listModels(provider.id);
|
|
340
|
+
for (const model of models) {
|
|
341
|
+
next.push({ provider: provider.id, model: model.id });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
// Transient provider failure — keep whatever we already have for that
|
|
346
|
+
// provider and continue with the others.
|
|
347
|
+
this.ctx.logger.debug?.('token-quota: failed to list models for provider "%s": %o', provider.id, error);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
this.cachedModels = next;
|
|
351
|
+
this.modelsCachedAt = now;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Choose a replacement model for the exhausted current model according to
|
|
355
|
+
* the configured `onFull` strategy. Returns `undefined` when no eligible
|
|
356
|
+
* candidate exists (caller falls back to the historical stop-and-throw).
|
|
357
|
+
*/
|
|
358
|
+
pickReplacementModel(currentKey) {
|
|
359
|
+
const doc = this.settingsSource();
|
|
360
|
+
const onFull = doc.onFull ?? 'stop';
|
|
361
|
+
if (onFull === 'stop')
|
|
362
|
+
return undefined;
|
|
363
|
+
if (this.cachedModels.length === 0)
|
|
364
|
+
return undefined;
|
|
365
|
+
const availability = (key) => {
|
|
366
|
+
const limit = this.limitOf(key);
|
|
367
|
+
const used = this.usage[key] ?? 0;
|
|
368
|
+
return { limit, used };
|
|
369
|
+
};
|
|
370
|
+
const isMonitored = (key) => this.isMonitored(key);
|
|
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
|
+
// Monitored models only. Capped models with headroom first (lowest
|
|
390
|
+
// fill ratio wins, spreading load), then uncapped monitored models as
|
|
391
|
+
// fallback — quotas are preferred, uncapped only when every capped
|
|
392
|
+
// monitored model is exhausted.
|
|
393
|
+
const cappedFree = candidates
|
|
394
|
+
.filter(c => c.monitored && c.limit > 0 && c.used < c.limit)
|
|
395
|
+
.sort((a, b) => (a.used / a.limit) - (b.used / b.limit));
|
|
396
|
+
if (cappedFree.length > 0)
|
|
397
|
+
return cappedFree[0];
|
|
398
|
+
return candidates.find(c => c.monitored && c.limit <= 0);
|
|
399
|
+
}
|
|
400
|
+
default:
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
303
404
|
async onRequest(payload, next) {
|
|
304
405
|
const config = await next();
|
|
305
406
|
const { provider, model } = config;
|
|
@@ -318,6 +419,25 @@ export class TokenQuotaService extends Service {
|
|
|
318
419
|
const used = this.usage[key] ?? 0;
|
|
319
420
|
if (used < limit)
|
|
320
421
|
return config;
|
|
422
|
+
// Current model is at or over cap. Try an automatic switch when the
|
|
423
|
+
// configured strategy allows it and a candidate exists; otherwise fall
|
|
424
|
+
// through to the historical hard stop.
|
|
425
|
+
await this.refreshModels();
|
|
426
|
+
const replacement = this.pickReplacementModel(key);
|
|
427
|
+
if (replacement !== undefined) {
|
|
428
|
+
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);
|
|
429
|
+
this.headerKeys.set(payload.agent.session, replacement.key);
|
|
430
|
+
// Invalidate the cached directory so the next request re-discovers any
|
|
431
|
+
// newly-registered models and so the next client-side poll sees the
|
|
432
|
+
// switch reflected without waiting a full TTL.
|
|
433
|
+
this.modelsCachedAt = 0;
|
|
434
|
+
this.cachedModels = [];
|
|
435
|
+
return {
|
|
436
|
+
...config,
|
|
437
|
+
provider: replacement.provider,
|
|
438
|
+
model: replacement.model,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
321
441
|
throw new LlmError(`Daily token limit reached for "${provider}/${model}": ${used}/${limit} tokens used today. `
|
|
322
442
|
+ 'Switch model in the quota panel or raise its limit.', TOKEN_QUOTA_EXCEEDED_CODE);
|
|
323
443
|
}
|
package/lib/types/types.d.ts
CHANGED
|
@@ -16,10 +16,10 @@ export declare const TOKEN_QUOTA_NAMESPACE = "token-quota";
|
|
|
16
16
|
export declare const TOKEN_QUOTA_EXCEEDED_CODE = "TOKEN_QUOTA_EXCEEDED";
|
|
17
17
|
/**
|
|
18
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
|
|
19
|
+
* Selected in the panel's settings dialog; the panel acts on it (b/c auto
|
|
20
20
|
* switch, a stops and prompts).
|
|
21
21
|
*/
|
|
22
|
-
export declare const TOKEN_QUOTA_FULL_ACTIONS: readonly ["stop", "switchQuota", "switchAll"
|
|
22
|
+
export declare const TOKEN_QUOTA_FULL_ACTIONS: readonly ["stop", "switchQuota", "switchAll"];
|
|
23
23
|
/** One of the {@link TOKEN_QUOTA_FULL_ACTIONS} values. */
|
|
24
24
|
export type TokenQuotaFullAction = typeof TOKEN_QUOTA_FULL_ACTIONS[number];
|
|
25
25
|
/**
|
|
@@ -58,8 +58,9 @@ export interface TokenQuotaSettings {
|
|
|
58
58
|
/**
|
|
59
59
|
* Behavior when a monitored, capped model hits its daily cap:
|
|
60
60
|
* `'stop'` stops and prompts, `'switchQuota'` auto-switches to another
|
|
61
|
-
* capped-but-available model, `'switchAll'` switches to
|
|
62
|
-
* model
|
|
61
|
+
* monitored capped-but-available model, `'switchAll'` auto-switches to
|
|
62
|
+
* another monitored available model — capped models first, uncapped ones
|
|
63
|
+
* as fallback. Only monitored models ever participate in a switch.
|
|
63
64
|
*/
|
|
64
65
|
onFull: TokenQuotaFullAction;
|
|
65
66
|
/**
|
package/lib/types/types.js
CHANGED
|
@@ -16,10 +16,10 @@ export const TOKEN_QUOTA_NAMESPACE = 'token-quota';
|
|
|
16
16
|
export const TOKEN_QUOTA_EXCEEDED_CODE = 'TOKEN_QUOTA_EXCEEDED';
|
|
17
17
|
/**
|
|
18
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
|
|
19
|
+
* Selected in the panel's settings dialog; the panel acts on it (b/c auto
|
|
20
20
|
* switch, a stops and prompts).
|
|
21
21
|
*/
|
|
22
|
-
export const TOKEN_QUOTA_FULL_ACTIONS = ['stop', 'switchQuota', 'switchAll'
|
|
22
|
+
export const TOKEN_QUOTA_FULL_ACTIONS = ['stop', 'switchQuota', 'switchAll'];
|
|
23
23
|
/** Default reset: machine-local midnight (offset = host timezone, 00:00). */
|
|
24
24
|
export const TOKEN_QUOTA_DEFAULT_RESET = { offsetHours: 0, hour: 0, minute: 0 };
|
|
25
25
|
/** Build the stable per-model key shared by the counter, settings, and snapshot. */
|
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.7",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|