dsh-connect-modelscope-token-plan 0.5.0 → 0.6.1

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.
@@ -375,9 +375,12 @@ function enabledCountOf(list, rosterIds) {
375
375
  * 1. `compat.supportsDeveloperRole: false`——pi-ai 缺省会**自动探测**这个标志,
376
376
  * 对非标准 provider 一律猜 `true`,于是每条请求都用 `developer` 角色,魔搭这个
377
377
  * 多模型代理不认识,会 400 或 403。qoder 路由证明必须显式写 false。
378
- * 2. `maxTokens` 只在目录**声明了**上限时声明——harness 对未声明的 maxTokens
379
- * 用 `defaultMaxTokens ?? 32768` 兜底,所以「不声明」是「平台没说」的诚实处理,
380
- * 别猜数字填。
378
+ * 2. `maxTokens` 由 `maxTokensFor` 一处决定,优先级 **`覆盖 > 目录声明 > 不声明`**:
379
+ * 目录没声明时不猜数字(harness 会用它的 `defaultMaxTokens ?? 32768` 兜底,那是
380
+ * harness 的默认值,不是平台说的),但操作者可以在 `maxTokensOverrides` 里显式
381
+ * 填实测值。**本插件的目录(魔搭 `/v1/models`)从不返回这个字段**,所以「目录
382
+ * 说了算」在现实里等于「永远是 32768」——这条退路不是锦上添花,它是唯一出口。
383
+ * 完整回退顺序写在 `maxTokensFor` 上,此处不重复。
381
384
  * 3. `reasoning: false` + **不设** `thinkingLevelMap`——魔搭 API-Inference 是
382
385
  * 多模型代理,是否吃 `reasoning_effort` 取决于背后那个具体模型,本插件无法
383
386
  * 离线得知每个 id 的档位表(sensenova 那份是拿真令牌逐模型探测才钉出来的)。
@@ -685,9 +688,11 @@ function contextWindowSourceOf(entry) {
685
688
  /**
686
689
  * 读目录声明的单次输出上限,0 = 未声明。
687
690
  *
688
- * 这个数**只用于展示/投影**。descriptor 只在 >0 时声明 `maxTokens`(见模块头注
689
- * 第 2 条),所以它从不变成请求参数——它说的是平台至多能吐多少,让用户明白为什么
690
- * 长回答仍会以 `finish_reason: length` 停住。
691
+ * 这个数**是请求参数**,不是纯展示值:`toPiDescriptor` 在它 >0 时声明为
692
+ * `maxTokens`(见模块头注第 2 条)。**但 `0` 有真实的代价**——魔搭目录从不返回
693
+ * 这个字段,所以它对本插件的每一个模型都是 0,harness 于是用 `defaultMaxTokens ??
694
+ * 32768` 兜底。「目录没说」在这里不等于「平台没限制」,只等于**我们不知道**;把它
695
+ * 当「不设上限」读会错得很远。操作者知道时用 `maxTokensFor` 的覆盖参数补上。
691
696
  * @param {object} entry - 一条归一目录条目。
692
697
  * @returns {number} 声明的上限,未声明为 0。
693
698
  */
@@ -703,6 +708,50 @@ function maxOutputLengthOf(entry) {
703
708
  return 0;
704
709
  }
705
710
  /**
711
+ * 该条目**实际生效**的输出上限:`覆盖 > 目录声明 > 不声明(0)`——全插件唯一的
712
+ * 回退顺序,写在这里就只写一遍。
713
+ *
714
+ * 为什么覆盖必须排在目录**之前**(而不是「目录优先、覆盖只补空缺」):目录说的是
715
+ * 「这个平台声称能吐多少」,覆盖说的是「我实测过这个模型能吐多少」。两者冲突时后者
716
+ * 是更新的事实——目录是我们**抓来的**(`/v1/models`,会随上游改动、也可能常年不
717
+ * 更新),覆盖是操作者**写的**。魔搭这条路上目录连这个字段都不返回,所以顺序在
718
+ * 多数情况下没有争议;把它写死仍然重要,因为「谁赢」这件事只能有一个答案。
719
+ *
720
+ * 返回 0 的含义与 `maxOutputLengthOf` 完全一致:**不声明** `maxTokens`,让 harness
721
+ * 用它的兜底值——不是「上限为 0」。`0` 因此不是一个可以配置的上限(
722
+ * `resolveMaxTokensOverrides` 会把 0 丢掉)。
723
+ * @param {object} entry - 一条归一目录条目。
724
+ * @param {object|null} [overrides] - 归一后的 id → 上限映射(`ResolvedSettings`)。
725
+ * @returns {number} 生效上限,未声明为 0。
726
+ */
727
+ function maxTokensFor(entry, overrides) {
728
+ const declared = maxOutputLengthOf(entry);
729
+ if (overrides === void 0 || overrides === null) return declared;
730
+ const id = str(entry?.id, "");
731
+ if (id === "") return declared;
732
+ const override = overrides[id];
733
+ return typeof override === "number" && Number.isInteger(override) && override > 0 ? override : declared;
734
+ }
735
+ /**
736
+ * 这个条目的输出上限数字从真值链哪一级来——与 {@link maxTokensFor} 配对的来源标记,
737
+ * 形状与 {@link contextWindowSourceOf} 刻意保持同一套词汇。
738
+ *
739
+ * 只有两级,因为这里**没有策展表**:窗口有 `context-catalog.ts` 那份实测策展表,
740
+ * 输出上限没有(魔搭从不声明它,策展一张「猜每个模型能吐多少」的表没有依据)。
741
+ * `catalog` 的含义因此是「覆盖没命中」——包括目录没声明、值就是 0 的情况,面板据此
742
+ * 不画徽章。
743
+ * @param {object} entry - 一条归一目录条目。
744
+ * @param {object|null} [overrides] - 归一后的 id → 上限映射。
745
+ * @returns {"catalog"|"override"} 来源标记。
746
+ */
747
+ function maxTokensSourceOf(entry, overrides) {
748
+ if (overrides === void 0 || overrides === null) return "catalog";
749
+ const id = str(entry?.id, "");
750
+ if (id === "") return "catalog";
751
+ const override = overrides[id];
752
+ return typeof override === "number" && Number.isInteger(override) && override > 0 ? "override" : "catalog";
753
+ }
754
+ /**
706
755
  * 把一条目录条目映射成适配器交给 pi-ai 的 model descriptor。
707
756
  *
708
757
  * vision 用的是与快照/roster 完全相同的判定(`isVisionModel`),所以模型选择器
@@ -711,14 +760,17 @@ function maxOutputLengthOf(entry) {
711
760
  * @param {object} [options] - 装配参数。
712
761
  * @param {string} [options.providerId] - 该 descriptor 所属的 provider id。
713
762
  * @param {string} [options.baseUrl] - OpenAI 兼容源站。
763
+ * @param {object} [options.maxTokensOverrides] - 逐模型输出上限覆盖(id → tokens),
764
+ * 经 `maxTokensFor` 决定 `maxTokens`。缺省 = 目录说了算(对本插件的目录即「不声
765
+ * 明」)。
714
766
  * @returns {object} pi-ai descriptor。
715
767
  */
716
768
  function toPiDescriptor(entry, options = {}) {
717
- const { providerId = LLM_PROVIDER_ID, baseUrl } = options;
769
+ const { providerId = LLM_PROVIDER_ID, baseUrl, maxTokensOverrides } = options;
718
770
  const id = str(entry?.id, "");
719
771
  if (id === "") throw new Error("toPiDescriptor: catalog entry has no id");
720
772
  const vision = isVisionModel(entry);
721
- const maxOutputLength = maxOutputLengthOf(entry);
773
+ const effectiveMaxTokens = maxTokensFor(entry, maxTokensOverrides);
722
774
  return {
723
775
  id,
724
776
  name: str(entry.name, id),
@@ -729,7 +781,7 @@ function toPiDescriptor(entry, options = {}) {
729
781
  reasoning: false,
730
782
  cost: { ...NO_COST },
731
783
  contextWindow: contextWindowOf(entry),
732
- ...maxOutputLength > 0 ? { maxTokens: maxOutputLength } : {},
784
+ ...effectiveMaxTokens > 0 ? { maxTokens: effectiveMaxTokens } : {},
733
785
  compat: {
734
786
  maxTokensField: "max_tokens",
735
787
  supportsDeveloperRole: false
@@ -810,11 +862,11 @@ function buildRosterRows(entries, project) {
810
862
  * `rosterWithAvailability` 仍显示它们,灰显加原因,但 picker 不提供注定 429 的
811
863
  * 请求;两处口径不同是故意的,别统一。
812
864
  * @param {object[]} entries - 归一目录条目。
813
- * @param {object} options - `{ providerId, baseUrl, enabledIds, unavailableModelIds }`。
865
+ * @param {object} options - `{ providerId, baseUrl, enabledIds, unavailableModelIds, maxTokensOverrides }`。
814
866
  * @returns {object[]} pi-ai descriptor,按首见顺序。
815
867
  */
816
868
  function buildDescriptors(entries, options = {}) {
817
- const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [] } = options;
869
+ const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [], maxTokensOverrides } = options;
818
870
  const filtered = filterByEnabled(entries, enabledIds).filter((entry) => isChatModel(entry));
819
871
  const blocked = new Set(Array.isArray(unavailableModelIds) ? unavailableModelIds : []);
820
872
  const seen = /* @__PURE__ */ new Map();
@@ -834,7 +886,8 @@ function buildDescriptors(entries, options = {}) {
834
886
  id
835
887
  }, {
836
888
  providerId,
837
- ...baseUrl !== void 0 ? { baseUrl } : {}
889
+ ...baseUrl !== void 0 ? { baseUrl } : {},
890
+ ...maxTokensOverrides !== void 0 ? { maxTokensOverrides } : {}
838
891
  });
839
892
  }
840
893
  return out;
@@ -1001,9 +1054,14 @@ function resolveDrawModelChoice(entries, requested, preferred) {
1001
1054
  * 面板保留它们在清单里、灰显,让用户看得见「为什么模型从选择器里不见了」。
1002
1055
  * @param {object[]} entries - 归一目录条目。
1003
1056
  * @param {string[]} unavailableIds - 额度耗尽的模型 id;空数组 = 全部可用。
1004
- * @returns {{id: string, name: string, vision: boolean, capability: ModelCapability, available: boolean, quotaExhausted: boolean, contextWindow: number, maxOutputLength: number}[]}
1057
+ * @param {object} [maxTokensOverrides] - 逐模型输出上限覆盖(id → tokens)。**位置参数
1058
+ * 而非 options 对象**:本函数的既有调用面就是两个位置参数,第三个在三个调用点都是
1059
+ * 一次机械追加;改成对象会动三处签名与它们各自的测试假件,收益只是好看。窗口那边
1060
+ * (`contextSource`)**不**走这个参数——它在 fetchModels 归一时就被打标了,两条路
1061
+ * 的机制不同,这里不假装统一。
1062
+ * @returns {{id: string, name: string, vision: boolean, capability: ModelCapability, available: boolean, quotaExhausted: boolean, contextWindow: number, contextSource: "catalog"|"override"|"curated", maxOutputLength: number, maxOutputSource: "catalog"|"override"}[]}
1005
1063
  */
1006
- function rosterWithAvailability(entries, unavailableIds) {
1064
+ function rosterWithAvailability(entries, unavailableIds, maxTokensOverrides) {
1007
1065
  const blocked = new Set(Array.isArray(unavailableIds) ? unavailableIds : []);
1008
1066
  return buildRosterRows(entries, (entry, id) => ({
1009
1067
  id,
@@ -1014,7 +1072,8 @@ function rosterWithAvailability(entries, unavailableIds) {
1014
1072
  quotaExhausted: blocked.has(id),
1015
1073
  contextWindow: contextWindowOf(entry),
1016
1074
  contextSource: contextWindowSourceOf(entry),
1017
- maxOutputLength: maxOutputLengthOf(entry)
1075
+ maxOutputLength: maxTokensFor(entry, maxTokensOverrides),
1076
+ maxOutputSource: maxTokensSourceOf(entry, maxTokensOverrides)
1018
1077
  }));
1019
1078
  }
1020
1079
 
@@ -1044,7 +1103,7 @@ const inject = ["webServer"];
1044
1103
  * 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
1045
1104
  * package.json 一致。
1046
1105
  */
1047
- const PLUGIN_VERSION = "0.5.0";
1106
+ const PLUGIN_VERSION = "0.6.1";
1048
1107
  /**
1049
1108
  * 默认值。刻意不含额度常数——理由见本文件头。
1050
1109
  * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
@@ -1080,9 +1139,55 @@ const CONFIG_DEFAULTS = Object.freeze({
1080
1139
  inferenceTimeoutMs: 3e4,
1081
1140
  /** 事件流保留条数(429/错误事件的环形上限)。 */
1082
1141
  maxEvents: 50,
1142
+ /**
1143
+ * 单模型「额度耗尽」的推断窗口(分钟)。
1144
+ *
1145
+ * 官方只给**账户级**魔粒余额,没有 per-model 额度端点,所以「哪个模型刚才在
1146
+ * 限频」只能从本插件的 429 事件流推断(规则是纯函数 `exhaustedModelIds`,
1147
+ * usage-store.ts):某模型最近在这个窗口内出现过 `kind:"quota"` 事件
1148
+ * (重分类后的 code,见 llm-error-fix),picker 就不再提供它、面板 roster
1149
+ * 灰显;事件移出窗口即恢复提供。
1150
+ *
1151
+ * 取 60 分钟:覆盖一次「耗尽 → 用户看到 → 换模型」的合理观察期,又不至于让
1152
+ * 一次深夜的耗尽事件把模型关一整天。**恢复不可观测是这条规则已知的代价**:
1153
+ * 成功的流不发事件,所以额度若在窗口内恢复(按天重置恰好在窗口内发生),
1154
+ * 模型最多灰显满一个窗口——方向是保守的(宁可少提供一会,不提供注定 429
1155
+ * 的模型),且面板灰显上写着原因,不是静默消失。
1156
+ */
1157
+ exhaustionWindowMinutes: 60,
1083
1158
  /** provider 注册的 patch 默认值(面板保存值优先于它)。 */
1084
1159
  registerProvider: false,
1085
1160
  /**
1161
+ * 逐模型的单次输出上限覆盖(模型 id → tokens),空对象 = 目录说了算。
1162
+ *
1163
+ * **为什么需要它**:本插件对输出上限的原始策略是「目录声明了才声明」
1164
+ * (llm-models.ts 模块头注第 2 条),而魔搭 `/v1/models` **从不返回**这个字段
1165
+ * (docs/REFERENCES.md 实测:每条只有 `id`/`object`/`owned_by`/`created`)。
1166
+ * 两条合起来的结果是**每一个**模型的 descriptor 都不带 `maxTokens`,harness 于是
1167
+ * 走它的 `defaultMaxTokens ?? 32768` 兜底——32768 不是魔搭声明的数,也不是任何
1168
+ * 实测过的数,它只是 harness 的默认值。
1169
+ *
1170
+ * 目录给不出真值时,操作者是唯一的信息源:他知道某个模型实测能吐 64k。没有这个
1171
+ * 字段,这份知识**无处可写**——写进目录缓存会被下一轮轮询覆盖。于是他只能看着
1172
+ * 输出在 32768 处被截断,而手上明明有答案。
1173
+ *
1174
+ * **语义与优先级**:`覆盖 > 目录声明 > 不声明`。目录声明了时覆盖仍然赢(这是
1175
+ * 「覆盖」二字的全部含义);目录没声明时覆盖就是唯一的真值来源。空对象(默认)
1176
+ * = 完全维持旧行为,不改变任何已有部署的输出。
1177
+ *
1178
+ * **键是精确 id,不做子串匹配**——与 sensenova 那种「按名字包含」的倍率旋钮
1179
+ * 刻意不同:上限是**请求参数**,一个宽松的键会顺手改掉一批没被点名的模型
1180
+ * (键写 `flash` 会命中七八条)。只认整串相等。
1181
+ *
1182
+ * **值必须是正整数**:0 有保留含义(`maxOutputLengthOf` 里 0 = 未声明),所以
1183
+ * 非法值一律**丢弃**(非整数 / NaN / Infinity / 字符串数字 / 0 / 负数),不是夹取
1184
+ * ——夹取等于替用户声明一个他没说过的上限。
1185
+ *
1186
+ * 读取点只有一处:`llm-models.ts` 的 `maxTokensFor`。descriptor 构建与面板 roster
1187
+ * **都**调它,所以面板显示的数字与线上真正发的 `max_tokens` 不可能不一致。
1188
+ */
1189
+ maxTokensOverrides: {},
1190
+ /**
1086
1191
  * 出图工具的 patch 默认值(面板保存值优先于它)。
1087
1192
  *
1088
1193
  * **与 `registerProvider` 是两个独立开关**:前者是「把魔搭当 chat provider」,
@@ -1165,6 +1270,32 @@ function assertHttpUrl(value, field) {
1165
1270
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${field} must be an http(s) URL, got ${parsed.protocol}`);
1166
1271
  }
1167
1272
  /**
1273
+ * 归一「逐模型输出上限覆盖」。
1274
+ *
1275
+ * 三个刻意的选择,都与姊妹插件的同类函数不同:
1276
+ *
1277
+ * 1. **键精确相等**,不做子串匹配。上限是请求参数,宽松匹配会改掉一批没被点名的
1278
+ * 模型;倍率那种「按名字包含」的旋钮可以宽松,因为它只影响画图。
1279
+ * 2. **值必须是正整数**,非法值**丢弃**而不是夹取。0 在 `maxOutputLengthOf` 里已经
1280
+ * 表示「未声明」,把 `-5` 夹成 1 等于替用户声明了一个他没说过的上限;丢弃则
1281
+ * 退回目录/兜底,方向是保守的。
1282
+ * 3. **非对象一律回落到默认空对象**,不抛错——坏配置不能让 apply 抛出去(本文件的
1283
+ * 既有纪律,见 resolveSettings 的 catch 分支)。
1284
+ * @param {unknown} raw - patch 行里的原始值(加载器送来的未校验输入)。
1285
+ * @returns {Record<string, number>} id → 上限,只含合法项。
1286
+ */
1287
+ function resolveMaxTokensOverrides(raw) {
1288
+ if (raw === void 0 || raw === null) return CONFIG_DEFAULTS.maxTokensOverrides;
1289
+ if (typeof raw !== "object" || Array.isArray(raw)) return { ...CONFIG_DEFAULTS.maxTokensOverrides };
1290
+ const out = {};
1291
+ for (const [key, value] of Object.entries(raw)) {
1292
+ if (key === "") continue;
1293
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) continue;
1294
+ out[key] = value;
1295
+ }
1296
+ return out;
1297
+ }
1298
+ /**
1168
1299
  * 把 patch 行的原始配置解析成有效设置。
1169
1300
  *
1170
1301
  * 坏行不许从这里抛出去——apply 在挂载期跑,抛错等于整机插件消失。问题以
@@ -1189,7 +1320,9 @@ function resolveSettings(config) {
1189
1320
  pollSeconds: clampInt(source.pollSeconds, CONFIG_DEFAULTS.pollSeconds, 5, 3600),
1190
1321
  inferenceTimeoutMs: clampInt(source.inferenceTimeoutMs, CONFIG_DEFAULTS.inferenceTimeoutMs, 1e3, 6e5),
1191
1322
  maxEvents: clampInt(source.maxEvents, CONFIG_DEFAULTS.maxEvents, 1, 1e3),
1323
+ exhaustionWindowMinutes: clampInt(source.exhaustionWindowMinutes, CONFIG_DEFAULTS.exhaustionWindowMinutes, 5, 1440),
1192
1324
  registerProvider: source.registerProvider === true,
1325
+ maxTokensOverrides: resolveMaxTokensOverrides(source.maxTokensOverrides),
1193
1326
  drawEnabled: source.drawEnabled === true,
1194
1327
  drawModelId: str(source.drawModelId, CONFIG_DEFAULTS.drawModelId),
1195
1328
  drawTimeoutMs,
@@ -1209,7 +1342,9 @@ function resolveSettings(config) {
1209
1342
  pollSeconds: CONFIG_DEFAULTS.pollSeconds,
1210
1343
  inferenceTimeoutMs: CONFIG_DEFAULTS.inferenceTimeoutMs,
1211
1344
  maxEvents: CONFIG_DEFAULTS.maxEvents,
1345
+ exhaustionWindowMinutes: CONFIG_DEFAULTS.exhaustionWindowMinutes,
1212
1346
  registerProvider: false,
1347
+ maxTokensOverrides: CONFIG_DEFAULTS.maxTokensOverrides,
1213
1348
  drawEnabled: false,
1214
1349
  drawModelId: CONFIG_DEFAULTS.drawModelId,
1215
1350
  drawTimeoutMs: CONFIG_DEFAULTS.drawTimeoutMs,