@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 CHANGED
@@ -129,59 +129,59 @@ window.__ModuleLoader__.load({
129
129
  document.head.appendChild(tag);
130
130
  }
131
131
  var TokenQuotaPanel_module_css_default = {
132
- "body": "NPLLIq_body",
133
- "notice": "NPLLIq_notice",
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
- "dialogHeader": "NPLLIq_dialogHeader",
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
- "currentBadge": "NPLLIq_currentBadge",
172
- "controls": "NPLLIq_controls",
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
- "logDialog": "NPLLIq_logDialog",
182
- "logDayCol": "NPLLIq_logDayCol",
175
+ "panel": "NPLLIq_panel",
176
+ "controls": "NPLLIq_controls",
177
+ "headerActions": "NPLLIq_headerActions",
178
+ "body": "NPLLIq_body",
183
179
  "settingsBtn": "NPLLIq_settingsBtn",
184
- "fillWarn": "NPLLIq_fillWarn"
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 to any other model (incl. uncapped)",
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 available = (candidate) => isMonitoredKey(candidate.key) && (candidate.limit <= 0 || candidate.used < candidate.limit);
903
- if (lastOnFull === "switchQuota") return candidates.filter((candidate) => isMonitoredKey(candidate.key) && candidate.limit > 0 && candidate.used < candidate.limit).sort((a, b) => a.used / a.limit - b.used / b.limit)[0];
904
- if (lastOnFull === "switchAll") return candidates.filter(available)[0];
905
- const uncapped = candidates.find((candidate) => isMonitoredKey(candidate.key) && candidate.limit <= 0);
906
- if (uncapped !== void 0) return uncapped;
907
- const unmonitored = candidates.find((candidate) => !isMonitoredKey(candidate.key));
908
- if (unmonitored !== void 0) return unmonitored;
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
- lastOnFull = doc?.onFull ?? "stop";
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 % 4 === 0) refreshCurrent();
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/request", async (payload, next) => this.onRequest(payload, next));
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 available = (candidate) => isMonitoredKey(candidate.key) && (candidate.limit <= 0 || candidate.used < candidate.limit);
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(candidate => isMonitoredKey(candidate.key) && candidate.limit > 0 && candidate.used < candidate.limit)
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
- return candidates.filter(available)[0];
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
- // switchPriority: uncapped first, then unmonitored, then capped-but-free.
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) moves the 「当前」 badge too.
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
- lastOnFull = doc?.onFull ?? 'stop';
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 % 4 === 0)
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 to any other model (incl. uncapped)',
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}',
@@ -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
@@ -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
- onFull: doc.onFull ?? 'stop',
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
- // 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));
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
  }
@@ -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/d auto
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", "switchPriority"];
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 any other available
62
- * model, `'switchPriority'` prefers uncapped models then unmonitored ones.
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
  /**
@@ -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/d auto
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', 'switchPriority'];
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.5",
4
+ "version": "0.1.7",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },