dsh-decision-engine 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -17
- package/config.example.yml +49 -19
- package/lib/adapters/tools.js +1 -1
- package/lib/client.js +168 -133
- package/lib/decision-cache.js +1 -1
- package/lib/endpoint-fallback.js +143 -0
- package/lib/engine.js +3 -3
- package/lib/index.js +247 -97
- package/lib/providers.js +166 -7
- package/package.json +5 -2
- package/lib/budget.js +0 -92
package/lib/providers.js
CHANGED
|
@@ -32,10 +32,13 @@
|
|
|
32
32
|
/**
|
|
33
33
|
* 可选的 provider 标识。
|
|
34
34
|
*
|
|
35
|
-
* `systemone` / `bamboonode`
|
|
36
|
-
|
|
35
|
+
* `systemone` / `bamboonode` / `siliconflow` / `siliconflow-intl` 是内置预设
|
|
36
|
+
*(默认地址与参数由本模块提供);`custom` 使用用户在设置界面填写的
|
|
37
|
+
* base URL 与鉴权信息。
|
|
38
|
+
*
|
|
39
|
+
* 顺序即设置界面下拉的顺序:常用项在前,`custom` 收尾(它要填的东西最多)。
|
|
37
40
|
*/
|
|
38
|
-
export const PROVIDER_IDS = ["systemone", "bamboonode", "custom"];
|
|
41
|
+
export const PROVIDER_IDS = ["systemone", "bamboonode", "siliconflow", "siliconflow-intl", "custom"];
|
|
39
42
|
|
|
40
43
|
/** 默认 provider。刻意选 bamboonode:老用户的 `startlux.yml` 里没有 provider 键,
|
|
41
44
|
* 必须继续走原来的地址与端点风格,升级不能改变既有行为。 */
|
|
@@ -44,15 +47,26 @@ export const DEFAULT_PROVIDER = "bamboonode";
|
|
|
44
47
|
/**
|
|
45
48
|
* 自定义 base URL 的端点风格。
|
|
46
49
|
*
|
|
47
|
-
* auto 已指向 /decisions 或 /systemone
|
|
50
|
+
* auto 已指向 /decisions 或 /systemone 就原样用,否则先试 decisions
|
|
48
51
|
* decisions POST <base>/decisions
|
|
49
52
|
* systemone POST <base>/systemone
|
|
53
|
+
*
|
|
54
|
+
* 注意:这是**首选**路径,不是唯一路径。路径后缀由服务商决定(实测
|
|
55
|
+
* SiliconFlow 的 Kev-4B 只通 /v1/systemone,bamboonode 两条都通),
|
|
56
|
+
* 因此 404 时会换另一条再试——见 endpointCandidates()。
|
|
50
57
|
*/
|
|
51
58
|
export const ENDPOINT_STYLES = ["auto", "decisions", "systemone"];
|
|
52
59
|
|
|
53
60
|
/** 端点风格 → 追加的路径段。 */
|
|
54
61
|
const STYLE_SEGMENT = { decisions: "decisions", systemone: "systemone" };
|
|
55
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 两条路径风格互为「另一条」。
|
|
65
|
+
*
|
|
66
|
+
* 用于 404 回退:路径不存在时换另一条再试一次。
|
|
67
|
+
*/
|
|
68
|
+
const ALTERNATE_STYLE = { decisions: "systemone", systemone: "decisions" };
|
|
69
|
+
|
|
56
70
|
/**
|
|
57
71
|
* 合法的思考模式(与 startlux 服务的 `think_mode` 取值一致)。
|
|
58
72
|
*
|
|
@@ -91,7 +105,22 @@ export function normalizeThinkMode(raw) {
|
|
|
91
105
|
* 内置预设。
|
|
92
106
|
*
|
|
93
107
|
* 每个预设必须给出:显示名称、默认 base URL、端点风格、环境变量名、
|
|
94
|
-
*
|
|
108
|
+
* 默认模型与三个配置字段名。用户选了预设就用这里的值,不需要在设置界面
|
|
109
|
+
* 重复填写。
|
|
110
|
+
*
|
|
111
|
+
* 两个可选标记(都是「按服务商实测」得出的,不是风格偏好):
|
|
112
|
+
*
|
|
113
|
+
* - `requiresModel: true`:该端点**不接受**省略 model 的请求(实测
|
|
114
|
+
* SiliconFlow 的 /v1/systemone 省略或空串都回 400
|
|
115
|
+
* `Model field is required.`)。这类预设在模型留空时回落到
|
|
116
|
+
* `defaultModel`,而不是沿用「留空 = 省略字段」的通用规则——
|
|
117
|
+
* 那会导致一次注定 400 的请求,而守卫是 fail-open 的,表现为静默失效。
|
|
118
|
+
* 其余服务商(bamboonode / system one)省略 model 会由服务端回填默认
|
|
119
|
+
* 模型,保留省略语义才能在它们改名时不失效。
|
|
120
|
+
* - `knownModels`:该服务 `/v1/models` 会一并返回大量**非决策**模型且不带
|
|
121
|
+
* `supported_endpoint_types`(实测 SiliconFlow 返回 98 个,其中 chat
|
|
122
|
+
* 模型打到 /v1/systemone 是 400 `Model does not exist.`)。
|
|
123
|
+
* 列了这份清单就按它筛,不让下拉里出现「选了就失败」的选项。
|
|
95
124
|
*/
|
|
96
125
|
export const PROVIDER_PRESETS = {
|
|
97
126
|
systemone: {
|
|
@@ -132,6 +161,55 @@ export const PROVIDER_PRESETS = {
|
|
|
132
161
|
hint: "StartLux 官方端点(POST /v1/decisions),本插件的原有默认值",
|
|
133
162
|
hintEn: "StartLux official endpoint (POST /v1/decisions); the original default",
|
|
134
163
|
},
|
|
164
|
+
siliconflow: {
|
|
165
|
+
id: "siliconflow",
|
|
166
|
+
label: "硅基流动",
|
|
167
|
+
labelEn: "SiliconFlow",
|
|
168
|
+
baseUrl: "https://api.siliconflow.cn/v1",
|
|
169
|
+
// 实测(2026-10-25,真实 key):该站只认 /systemone,/v1/decisions 回 404。
|
|
170
|
+
// 因此这里直接给 systemone,不必让 404 回退先烧一次请求。
|
|
171
|
+
endpointStyle: "systemone",
|
|
172
|
+
// 两个 SiliconFlow 站点是**两套账号**,key 互不通用,因此各占一格。
|
|
173
|
+
apiKeyEnv: "SILICONFLOW_API_KEY",
|
|
174
|
+
legacyApiKeyEnvs: [],
|
|
175
|
+
keyField: "siliconflowApiKey",
|
|
176
|
+
// 实测省略 model 会 400 `Model field is required.`,而 bamboonode 省略会
|
|
177
|
+
// 由服务端回填默认模型。因此这个 provider 必须**总带着一个模型名**——
|
|
178
|
+
// 留空时回落到下面的 defaultModel,而不是省略字段(见 resolveProvider)。
|
|
179
|
+
// 这正是「只填一个 API key 就能用」的前提:模型名是预设给好的。
|
|
180
|
+
requiresModel: true,
|
|
181
|
+
// /v1/models 里这条就叫 Kev-4B(官方文档两处分别写作 Kev-4b 与 Kev-4B,
|
|
182
|
+
// 实测三种大小写都 200),取服务返回的写法,免得下拉里出现两条同模型。
|
|
183
|
+
defaultModel: "Kev-4B",
|
|
184
|
+
// 官方只支持这两个决策模型;/v1/models 却会一并返回 98 个 chat 模型且
|
|
185
|
+
// 不带 supported_endpoint_types(实测把 chat 模型打过来是 400
|
|
186
|
+
// `Model does not exist.`),所以按这份清单筛,不让下拉里出现选了就坏的项。
|
|
187
|
+
knownModels: ["Kev-4B", "SemIf"],
|
|
188
|
+
modelField: "siliconflowModel",
|
|
189
|
+
keyEnvField: "siliconflowApiKeyEnv",
|
|
190
|
+
hint: "硅基流动(国内站)的 System One 端点(POST /v1/systemone);可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
|
|
191
|
+
hintEn: "SiliconFlow China (POST /v1/systemone); models Kev-4B / SemIf, default Kev-4B — just add an API key",
|
|
192
|
+
},
|
|
193
|
+
"siliconflow-intl": {
|
|
194
|
+
id: "siliconflow-intl",
|
|
195
|
+
label: "Silicon Flow",
|
|
196
|
+
labelEn: "Silicon Flow",
|
|
197
|
+
baseUrl: "https://api.siliconflow.com/v1",
|
|
198
|
+
// 同样只认 /systemone:实测打 /v1/decisions 回 404 Not Found。
|
|
199
|
+
endpointStyle: "systemone",
|
|
200
|
+
// 与国内站分开:两套账号的 key 不能共用一格,否则切过去就 401,
|
|
201
|
+
// 而用户看不出是「拿错了 key」还是「key 失效」。
|
|
202
|
+
apiKeyEnv: "SILICONFLOW_INTL_API_KEY",
|
|
203
|
+
legacyApiKeyEnvs: [],
|
|
204
|
+
keyField: "siliconflowIntlApiKey",
|
|
205
|
+
requiresModel: true,
|
|
206
|
+
defaultModel: "Kev-4B",
|
|
207
|
+
knownModels: ["Kev-4B", "SemIf"],
|
|
208
|
+
modelField: "siliconflowIntlModel",
|
|
209
|
+
keyEnvField: "siliconflowIntlApiKeyEnv",
|
|
210
|
+
hint: "Silicon Flow(国际站)的 System One 端点(POST /v1/systemone);与国内站账号不通用,可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
|
|
211
|
+
hintEn: "Silicon Flow global (POST /v1/systemone); separate account from the CN site, models Kev-4B / SemIf, default Kev-4B — just add an API key",
|
|
212
|
+
},
|
|
135
213
|
custom: {
|
|
136
214
|
id: "custom",
|
|
137
215
|
label: "custom",
|
|
@@ -259,6 +337,61 @@ export function buildEndpoint(baseUrl, style = "decisions") {
|
|
|
259
337
|
return url.toString();
|
|
260
338
|
}
|
|
261
339
|
|
|
340
|
+
/**
|
|
341
|
+
* 列出「同一个 base URL 下所有值得一试的端点」,按优先级排列。
|
|
342
|
+
*
|
|
343
|
+
* 背景(2026-10-25 实测):路径后缀由**服务商**决定,不由模型决定——
|
|
344
|
+
* SiliconFlow 的 Kev-4B 只认 `/v1/systemone`(`/v1/decisions` 回 404),
|
|
345
|
+
* 而 bamboonode 的决策模型两条路径都能用。因此「只拼一条路径」在换服务商
|
|
346
|
+
* 时必然踩空,而守卫是 fail-open 的,踩空表现为**静默失效**。
|
|
347
|
+
*
|
|
348
|
+
* 候选规则:
|
|
349
|
+
*
|
|
350
|
+
* - 地址**已自带**端点段(`.../v1/decisions` 或 `.../v1/systemone`)→
|
|
351
|
+
* 只返回它自己。用户填了完整端点就是明确决定,不该被自动改写。
|
|
352
|
+
* - `auto` → 先试 `decisions`(与历史行为一致),再试 `systemone`。
|
|
353
|
+
* - 显式 `decisions` / `systemone` → 先试它自己,**再试另一条**。
|
|
354
|
+
*
|
|
355
|
+
* 最后一条是关键:显式指定只是「首选」,不是「唯一」。显式指定错路径时
|
|
356
|
+
* 同样会 404,届时仍应回退到另一条——否则「选错了就只能手动改」这种
|
|
357
|
+
* 死胡同会被用户当成插件的 bug。
|
|
358
|
+
*
|
|
359
|
+
* @param baseUrl - base URL。
|
|
360
|
+
* @param style - `decisions` / `systemone` / `auto`。
|
|
361
|
+
* @returns 候选端点数组(去重,优先级从高到低)。
|
|
362
|
+
*/
|
|
363
|
+
export function endpointCandidates(baseUrl, style = "auto") {
|
|
364
|
+
const normalized = normalizeBaseUrl(baseUrl);
|
|
365
|
+
if (normalized.length === 0) return [];
|
|
366
|
+
// 用户填了完整端点:原样用,不追加任何候选。
|
|
367
|
+
let path = "";
|
|
368
|
+
try {
|
|
369
|
+
path = new URL(normalized).pathname.replace(/\/+$/, "");
|
|
370
|
+
} catch {
|
|
371
|
+
// 非法 URL:交给调用方去报错,这里不构造候选。
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
if (endpointStyleOfPath(path) !== undefined) {
|
|
375
|
+
return [buildEndpoint(normalized, style)];
|
|
376
|
+
}
|
|
377
|
+
const primary = style === "auto" ? "decisions" : style === "systemone" ? "systemone" : "decisions";
|
|
378
|
+
const secondary = ALTERNATE_STYLE[primary];
|
|
379
|
+
const out = [buildEndpoint(normalized, primary)];
|
|
380
|
+
const other = buildEndpoint(normalized, secondary);
|
|
381
|
+
if (!out.includes(other)) out.push(other);
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 端点风格 → 它对应的路径段(用于回显「当前用的是哪条路径」)。
|
|
387
|
+
*
|
|
388
|
+
* @param style - 风格取值。
|
|
389
|
+
* @returns `decisions` / `systemone`;无法识别时返回 "decisions"。
|
|
390
|
+
*/
|
|
391
|
+
export function styleSegment(style) {
|
|
392
|
+
return STYLE_SEGMENT[style] ?? "decisions";
|
|
393
|
+
}
|
|
394
|
+
|
|
262
395
|
/**
|
|
263
396
|
* 解析 `customHeaders` 配置。
|
|
264
397
|
*
|
|
@@ -324,6 +457,8 @@ export function resolveProvider(cfg = {}) {
|
|
|
324
457
|
source: "custom",
|
|
325
458
|
baseUrl,
|
|
326
459
|
endpoint: checked.ok ? buildEndpoint(baseUrl, style) : "",
|
|
460
|
+
// 候选端点(按优先级):404 时按顺序换下一条再试。
|
|
461
|
+
candidates: checked.ok ? endpointCandidates(baseUrl, style) : [],
|
|
327
462
|
endpointStyle: style,
|
|
328
463
|
model,
|
|
329
464
|
defaultModel: "",
|
|
@@ -349,7 +484,17 @@ export function resolveProvider(cfg = {}) {
|
|
|
349
484
|
// `systemoneModel` 属于 system one。共用一格会把 bamboonode 的模型名发给
|
|
350
485
|
// TypeSafe,得到 400 `model not served`——「切 provider 就坏」的典型来源。
|
|
351
486
|
const modelField = preset.modelField;
|
|
352
|
-
const
|
|
487
|
+
const rawModel = typeof cfg[modelField] === "string" ? cfg[modelField].trim() : "";
|
|
488
|
+
// 模型留空有两种含义,取决于该服务商的端点接不接受「不带 model」的请求:
|
|
489
|
+
//
|
|
490
|
+
// - requiresModel(SiliconFlow 实测):省略或空串都回 400
|
|
491
|
+
// `Model field is required.`,因此留空必须回落到预设的 defaultModel
|
|
492
|
+
// ——不发一个注定失败的请求,守卫才不会又一次静默失效。
|
|
493
|
+
// - 其余服务商:留空 = 省略字段,由服务端回填默认模型。保留省略语义,
|
|
494
|
+
// 服务商将来给模型改名时本配置不会失效(反过来把模型名钉死在这里
|
|
495
|
+
// 才是最脆的那种配法)。
|
|
496
|
+
const model =
|
|
497
|
+
rawModel.length > 0 ? rawModel : preset.requiresModel === true ? preset.defaultModel : "";
|
|
353
498
|
// 密钥变量名:`apiKeyEnv` 是**历史字段**,只对 bamboonode 有覆盖意义
|
|
354
499
|
//(老版本只有这一个 provider,用户可能已经在里面写了自己的变量名)。
|
|
355
500
|
// 若让它参与 system one 的解析,会盖掉 TYPESAFE_API_KEY——切到 system one
|
|
@@ -371,6 +516,9 @@ export function resolveProvider(cfg = {}) {
|
|
|
371
516
|
source: legacyBase.length > 0 ? "legacy" : "preset",
|
|
372
517
|
baseUrl: checked.ok ? checked.value : baseUrl,
|
|
373
518
|
endpoint: checked.ok ? buildEndpoint(baseUrl, preset.endpointStyle) : "",
|
|
519
|
+
// 候选端点(按优先级):预设自己的路径优先,404 时换另一条再试。
|
|
520
|
+
// 实测 bamboonode 两条路径都通,SiliconFlow 只通 /systemone。
|
|
521
|
+
candidates: checked.ok ? endpointCandidates(baseUrl, preset.endpointStyle) : [],
|
|
374
522
|
endpointStyle: preset.endpointStyle,
|
|
375
523
|
model,
|
|
376
524
|
defaultModel: preset.defaultModel,
|
|
@@ -471,8 +619,11 @@ export function buildRequestBody(provider, payload) {
|
|
|
471
619
|
* 供设置界面展示的 provider 清单。
|
|
472
620
|
*
|
|
473
621
|
* 由服务端下发而不是前端硬编码:新增预设只改本文件一处,界面自动多一个选项。
|
|
622
|
+
* `keyField` / `modelField` 也一并下发——界面写回配置时必须知道「这一格在
|
|
623
|
+
* config.yml 里叫什么」,而这个名字只有预设表知道;让前端自己再抄一份,
|
|
624
|
+
* 就是「界面显示存到 A、实际写进 B」这种最难自查的偏差。
|
|
474
625
|
*
|
|
475
|
-
* @returns 每个预设的 id / 显示名 / 默认地址 / 说明。
|
|
626
|
+
* @returns 每个预设的 id / 显示名 / 默认地址 / 字段名 / 说明。
|
|
476
627
|
*/
|
|
477
628
|
export function providerCatalog() {
|
|
478
629
|
return PROVIDER_IDS.map((id) => {
|
|
@@ -489,6 +640,14 @@ export function providerCatalog() {
|
|
|
489
640
|
// 界面用它说明「key 存在配置文件的哪一格」——只报变量名会让人
|
|
490
641
|
// 以为 key 在 .credentials.yaml 里,而实际生效的可能是配置文件。
|
|
491
642
|
keyField: preset.keyField,
|
|
643
|
+
// 该 provider 的模型字段名(`model` / `systemoneModel` / …)。
|
|
644
|
+
// 模型输入框按它写回配置,切 provider 不会把 A 的模型名写进 B 那一格。
|
|
645
|
+
modelField: preset.modelField,
|
|
646
|
+
// 「模型」那一行的语义全靠这两项:端点是否强制要 model(决定留空的
|
|
647
|
+
// 写法是「预设默认」还是「服务端默认」),以及能选的模型有哪些。
|
|
648
|
+
// 都由预设表下发,前端不必知道任何一家服务商的脾气。
|
|
649
|
+
requiresModel: preset.requiresModel === true,
|
|
650
|
+
knownModels: [...(preset.knownModels ?? [])],
|
|
492
651
|
hint: preset.hint,
|
|
493
652
|
hintEn: preset.hintEn,
|
|
494
653
|
customizable: id === "custom",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-decision-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, or a custom base URL).",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, SiliconFlow / Silicon Flow, or a custom base URL).",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"system-1",
|
|
37
37
|
"typesafe",
|
|
38
38
|
"jev",
|
|
39
|
+
"siliconflow",
|
|
40
|
+
"siliconflow-cn",
|
|
41
|
+
"kev-4b",
|
|
39
42
|
"provider",
|
|
40
43
|
"custom-base-url",
|
|
41
44
|
"tool-approval",
|
package/lib/budget.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 决策模型自身的 token 预算。
|
|
3
|
-
*
|
|
4
|
-
* 引入理由:StartLux 每次决策实测约 1,900 token,p50 延迟约 2 秒。
|
|
5
|
-
* 把这个判断层挂到多个决策点上,调用量会成倍上升——如果不设上限,
|
|
6
|
-
* 「省 token 的插件」反而会变成主要的 token 消耗方。
|
|
7
|
-
*
|
|
8
|
-
* 预算按滑动窗口统计(默认 1 小时)。一旦超限,所有决策点立刻退回
|
|
9
|
-
* 本地启发式并 fail-open,等窗口滚过去自动恢复;不需要重启,也不会
|
|
10
|
-
* 让工具调用被卡住。
|
|
11
|
-
*/
|
|
12
|
-
export class TokenBudget {
|
|
13
|
-
/**
|
|
14
|
-
* @param getLimitTokens - 返回当前上限的取值函数;0 表示不限制。
|
|
15
|
-
* 刻意接收函数而不是数值:本插件的原则是「配置每次现读」,
|
|
16
|
-
* 若在构造时固化,用户在配置页改了预算就得重启才生效。
|
|
17
|
-
* @param getWindowMs - 返回当前窗口长度的取值函数。
|
|
18
|
-
*/
|
|
19
|
-
constructor(getLimitTokens, getWindowMs) {
|
|
20
|
-
this.getLimitTokens = getLimitTokens;
|
|
21
|
-
this.getWindowMs = getWindowMs;
|
|
22
|
-
/** @type {Array<{at: number, tokens: number}>} */
|
|
23
|
-
this.entries = [];
|
|
24
|
-
this.blocked = 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** 当前生效的上限(读不到时按不限制处理,绝不因预算自身出错而卡住决策)。 */
|
|
28
|
-
get limitTokens() {
|
|
29
|
-
const raw = typeof this.getLimitTokens === "function" ? this.getLimitTokens() : this.getLimitTokens;
|
|
30
|
-
const value = Number(raw);
|
|
31
|
-
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 当前生效的窗口长度。 */
|
|
35
|
-
get windowMs() {
|
|
36
|
-
const raw = typeof this.getWindowMs === "function" ? this.getWindowMs() : this.getWindowMs;
|
|
37
|
-
const value = Number(raw);
|
|
38
|
-
return Number.isFinite(value) && value > 0 ? value : 3600000;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 丢弃窗口外的记录。 */
|
|
42
|
-
prune(now = Date.now()) {
|
|
43
|
-
const cutoff = now - this.windowMs;
|
|
44
|
-
let drop = 0;
|
|
45
|
-
while (drop < this.entries.length && this.entries[drop].at < cutoff) drop += 1;
|
|
46
|
-
if (drop > 0) this.entries.splice(0, drop);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** 窗口内已消耗的 token。 */
|
|
50
|
-
used(now = Date.now()) {
|
|
51
|
-
this.prune(now);
|
|
52
|
-
let total = 0;
|
|
53
|
-
for (const entry of this.entries) total += entry.tokens;
|
|
54
|
-
return total;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 预算是否已耗尽(未设置上限时永远 false)。 */
|
|
58
|
-
exhausted(now = Date.now()) {
|
|
59
|
-
if (this.limitTokens <= 0) return false;
|
|
60
|
-
return this.used(now) >= this.limitTokens;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 记一次消耗。
|
|
65
|
-
* @param tokens - 本次估算或实测的 input token 数。
|
|
66
|
-
*/
|
|
67
|
-
spend(tokens) {
|
|
68
|
-
const amount = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
69
|
-
if (amount === 0) return;
|
|
70
|
-
this.entries.push({ at: Date.now(), tokens: amount });
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** 记一次「因为预算耗尽而放弃判断」,用于配置页展示。 */
|
|
74
|
-
noteBlocked() {
|
|
75
|
-
this.blocked += 1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 供 UI 展示的快照。 */
|
|
79
|
-
get report() {
|
|
80
|
-
const now = Date.now();
|
|
81
|
-
const used = this.used(now);
|
|
82
|
-
const limit = this.limitTokens;
|
|
83
|
-
return {
|
|
84
|
-
used,
|
|
85
|
-
limit,
|
|
86
|
-
windowMs: this.windowMs,
|
|
87
|
-
remaining: limit <= 0 ? null : Math.max(0, limit - used),
|
|
88
|
-
exhausted: this.exhausted(now),
|
|
89
|
-
blocked: this.blocked,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
}
|