dsh-connect-modelscope-token-plan 0.4.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.
- package/CHANGELOG.md +235 -178
- package/README.md +20 -12
- package/assets/tab-account-and-usage.png +0 -0
- package/assets/tab-model-draw.png +0 -0
- package/assets/tab-model-list.png +0 -0
- package/client.js +770 -165
- package/cordis.patch.yml +45 -9
- package/lib/host-config.js +288 -21
- package/lib/index.js +916 -232
- package/lib/llm-adapter.js +45 -19
- package/package.json +115 -115
- package/screenshots.json +2 -1
- package/assets/icon-preview.png +0 -0
package/cordis.patch.yml
CHANGED
|
@@ -24,10 +24,25 @@
|
|
|
24
24
|
# 删除——留着它们只会让人以为面板能算「还剩几次」,而它算不出。
|
|
25
25
|
# 本地计数的独家价值在分布 / 趋势 / 429 事件流,不在「离上限多远」。
|
|
26
26
|
# ------------------------------------------------------------------
|
|
27
|
-
#
|
|
27
|
+
# 趋势图显示天数(面板标题「近 N 天」与图上柱数)——面板里可改看多长。
|
|
28
28
|
trendDays: 14
|
|
29
|
+
# 本地趋势**保留**天数(插件私有状态文件里的天桶数量,与上游无关)。
|
|
30
|
+
# 独立于 trendDays:显示只决定「画多少」,保留决定「留多少」。留得比画得多,
|
|
31
|
+
# 面板才可能让你把窗口调到更远。显示窗口恒被硬夹在保留之内。
|
|
32
|
+
retentionDays: 90
|
|
29
33
|
# 429/错误事件环形缓冲的上限(面板只展示最近的)。上界 1000,硬夹。
|
|
30
34
|
maxEvents: 50
|
|
35
|
+
# 单模型「额度耗尽」推断窗口(分钟)。官方只有账户级魔粒余额、没有
|
|
36
|
+
# per-model 额度端点,所以「哪个模型刚才在限频」从本插件的 429 事件流
|
|
37
|
+
# 推断(纯函数见 src/host/usage-store.ts 的 exhaustedModelIds):某模型
|
|
38
|
+
# 最近在这个窗口内出现过 kind=quota 的事件(重分类后的口径——被误判成
|
|
39
|
+
# QUOTA 的 rpm 限频已在此之前纠正为 rate_limit,不算证据),选择器就不再
|
|
40
|
+
# 提供它、面板 roster 灰显;事件移出窗口即恢复。**恢复不可观测是已知
|
|
41
|
+
# 代价**:成功的调用不发事件,额度若在窗口内恢复(按天重置恰好如此),
|
|
42
|
+
# 模型最多灰显满一个窗口——方向保守(不提供注定 429 的模型),且灰显
|
|
43
|
+
# 带着原因。下界 5 分钟(再短规则形同虚设),上界 1440(一天,额度按天
|
|
44
|
+
# 重置,更长的窗口没有对应的恢复语义)。硬夹。
|
|
45
|
+
exhaustionWindowMinutes: 60
|
|
31
46
|
# Host 侧对 /v1/models 的缓存秒数;面板脚注直接引用此值。上界 600。
|
|
32
47
|
cacheSeconds: 60
|
|
33
48
|
# 面板轮询间隔,由 Host 下发、面板跟随。上界 3600(1 小时)。
|
|
@@ -43,11 +58,12 @@
|
|
|
43
58
|
# tab 的「接入为 DSH 模型」开关可随时翻转,面板保存值优先于这里的默认。
|
|
44
59
|
# 注册后魔搭模型进入 DSH 模型选择器。
|
|
45
60
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
61
|
+
# 计数范围:注册开关打开后,本客户端(本 profile)的魔搭对话经本插件的
|
|
62
|
+
# 适配器出去,每一次调用与每一条 429/错误事件都由 src/host/usage-observer.ts
|
|
63
|
+
# 在流的出口记进同一个 usage-store。口径是**本客户端**:桌面端与 Web 端是
|
|
64
|
+
# 两个 profile,各记各的账、互不可见(`npm run doctor` 并排列出各 profile),
|
|
65
|
+
# 别把它当跨端总量。直连魔搭的其它客户端同样不计入(本插件看不见它们),
|
|
66
|
+
# 消耗总量以官方魔粒余额为准。
|
|
51
67
|
#
|
|
52
68
|
# 偏差方向是**按流计次**:重试不在流内部发生,而是流被消费完后由
|
|
53
69
|
# agent/request-error 瀑布决定重发,因而每次重试各记一次(按「魔搭按
|
|
@@ -55,6 +71,25 @@
|
|
|
55
71
|
# 与 README「诚实声明」。
|
|
56
72
|
registerProvider: false
|
|
57
73
|
# ------------------------------------------------------------------
|
|
74
|
+
# 逐模型的单次输出上限覆盖(模型 id → tokens)。空对象 = 目录说了算。
|
|
75
|
+
#
|
|
76
|
+
# **为什么必须有它**:本插件对输出上限的策略是「目录声明了才声明」,而魔搭
|
|
77
|
+
# `/v1/models` **从不返回**这个字段(实测每条只有 id / object / owned_by /
|
|
78
|
+
# created,见 docs/REFERENCES.md)。两条合起来 = **每一个**模型的 descriptor
|
|
79
|
+
# 都不带 maxTokens,harness 于是用它的 `defaultMaxTokens ?? 32768` 兜底:
|
|
80
|
+
# 32768 既不是魔搭声明的数,也不是实测过的数,它只是 harness 的缺省值。
|
|
81
|
+
# 目录给不出真值时,只有操作者知道某个模型实测能吐多少——没有这个字段,
|
|
82
|
+
# 那份知识无处可写(写进目录缓存会被下一轮轮询覆盖)。
|
|
83
|
+
#
|
|
84
|
+
# 优先级 `覆盖 > 目录声明 > 不声明`。键是**精确模型 id**,不做子串匹配——
|
|
85
|
+
# 上限是请求参数,宽松的键会顺手改掉一批没被点名的模型(键写 flash 会命中
|
|
86
|
+
# 七八条);值必须是正整数(0 = 未声明;非法值丢弃而不是夹取)。
|
|
87
|
+
# 面板模型清单行会用「最大输出」徽章显示生效值,来源是覆盖还是目录一目了然。
|
|
88
|
+
#
|
|
89
|
+
# 例(把下面这行整行复制出去、去掉 `#` 即可):
|
|
90
|
+
# maxTokensOverrides: { "deepseek-ai/DeepSeek-V4.1-Flash": 65536 }
|
|
91
|
+
maxTokensOverrides: {}
|
|
92
|
+
# ------------------------------------------------------------------
|
|
58
93
|
# 给 agent 注册出图工具(`modelscope_draw_image`)。**与上面的
|
|
59
94
|
# registerProvider 是两条独立开关**:那个管「魔搭作为 chat provider」,
|
|
60
95
|
# 这个管「agent 能不能出图」。默认同样关(opt-in)。
|
|
@@ -77,13 +112,14 @@
|
|
|
77
112
|
# 没有任何 text-to-image。官方示例里的 Qwen/Qwen-Image-2.1-Turbo 与
|
|
78
113
|
# black-forest-labs/FLUX.1-Kontext-dev **都不在目录内**,后者实测 FAILED。
|
|
79
114
|
drawModelId: ""
|
|
80
|
-
# 单次出图总预算(毫秒),含提交与全部轮询。上界
|
|
81
|
-
#
|
|
82
|
-
#
|
|
115
|
+
# 单次出图总预算(毫秒),含提交与全部轮询。上界 1800000(30 分钟),
|
|
116
|
+
# 严格高于这个默认值——它**不是**上限。别调小:实测最慢一次 859s 才成功,
|
|
117
|
+
# 而超时放弃**不等于**任务死亡(上游会继续跑),把预算压到 5 分钟曾实测击穿。
|
|
83
118
|
drawTimeoutMs: 900000
|
|
84
119
|
# 单次出图**工具调用**愿意等的时间(毫秒)。与上面那条是两件事:那是
|
|
85
120
|
# 「这张图值不值得继续等」的总放弃线,这是「一轮对话被占住多久」。
|
|
86
121
|
# 等不到就返回 pending + jobId,agent 带 jobId 再调用即可续查。
|
|
122
|
+
# 实际取值夹在 10s 与上面那条 drawTimeoutMs 之间(等待超过总放弃线没有意义)。
|
|
87
123
|
drawWaitMs: 90000
|
|
88
124
|
# 追加可信 Host 名(默认 localhost / 127.0.0.1 / ::1,只增不替)。
|
|
89
125
|
# allowedHosts: []
|
package/lib/host-config.js
CHANGED
|
@@ -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`
|
|
379
|
-
*
|
|
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 那份是拿真令牌逐模型探测才钉出来的)。
|
|
@@ -482,6 +485,22 @@ const KNOWN_VISION_IDS = Object.freeze(/* @__PURE__ */ new Set([
|
|
|
482
485
|
"stepfun-ai/Step-3.7-Flash"
|
|
483
486
|
]));
|
|
484
487
|
/**
|
|
488
|
+
* 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
|
|
489
|
+
*
|
|
490
|
+
* 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
|
|
491
|
+
* 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
|
|
492
|
+
* `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
|
|
493
|
+
* 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
|
|
494
|
+
* 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
|
|
495
|
+
* 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
|
|
496
|
+
* 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
|
|
497
|
+
* (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
|
|
498
|
+
*
|
|
499
|
+
* 与 `KNOWN_VISION_IDS` 放在一起:所有「哪些模型能做什么」的策展知识集中一处,
|
|
500
|
+
* 不会漂。
|
|
501
|
+
*/
|
|
502
|
+
const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
|
|
503
|
+
/**
|
|
485
504
|
* 把原始 `/v1/models` data 条目变成归一目录条目。
|
|
486
505
|
*
|
|
487
506
|
* 只做投影与回退,不做判断:`id` 必取,`name` 回退 id,`vision`/`contextWindow`/
|
|
@@ -669,9 +688,11 @@ function contextWindowSourceOf(entry) {
|
|
|
669
688
|
/**
|
|
670
689
|
* 读目录声明的单次输出上限,0 = 未声明。
|
|
671
690
|
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
691
|
+
* 这个数**是请求参数**,不是纯展示值:`toPiDescriptor` 在它 >0 时声明为
|
|
692
|
+
* `maxTokens`(见模块头注第 2 条)。**但 `0` 有真实的代价**——魔搭目录从不返回
|
|
693
|
+
* 这个字段,所以它对本插件的每一个模型都是 0,harness 于是用 `defaultMaxTokens ??
|
|
694
|
+
* 32768` 兜底。「目录没说」在这里不等于「平台没限制」,只等于**我们不知道**;把它
|
|
695
|
+
* 当「不设上限」读会错得很远。操作者知道时用 `maxTokensFor` 的覆盖参数补上。
|
|
675
696
|
* @param {object} entry - 一条归一目录条目。
|
|
676
697
|
* @returns {number} 声明的上限,未声明为 0。
|
|
677
698
|
*/
|
|
@@ -687,6 +708,50 @@ function maxOutputLengthOf(entry) {
|
|
|
687
708
|
return 0;
|
|
688
709
|
}
|
|
689
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
|
+
/**
|
|
690
755
|
* 把一条目录条目映射成适配器交给 pi-ai 的 model descriptor。
|
|
691
756
|
*
|
|
692
757
|
* vision 用的是与快照/roster 完全相同的判定(`isVisionModel`),所以模型选择器
|
|
@@ -695,14 +760,17 @@ function maxOutputLengthOf(entry) {
|
|
|
695
760
|
* @param {object} [options] - 装配参数。
|
|
696
761
|
* @param {string} [options.providerId] - 该 descriptor 所属的 provider id。
|
|
697
762
|
* @param {string} [options.baseUrl] - OpenAI 兼容源站。
|
|
763
|
+
* @param {object} [options.maxTokensOverrides] - 逐模型输出上限覆盖(id → tokens),
|
|
764
|
+
* 经 `maxTokensFor` 决定 `maxTokens`。缺省 = 目录说了算(对本插件的目录即「不声
|
|
765
|
+
* 明」)。
|
|
698
766
|
* @returns {object} pi-ai descriptor。
|
|
699
767
|
*/
|
|
700
768
|
function toPiDescriptor(entry, options = {}) {
|
|
701
|
-
const { providerId = LLM_PROVIDER_ID, baseUrl } = options;
|
|
769
|
+
const { providerId = LLM_PROVIDER_ID, baseUrl, maxTokensOverrides } = options;
|
|
702
770
|
const id = str(entry?.id, "");
|
|
703
771
|
if (id === "") throw new Error("toPiDescriptor: catalog entry has no id");
|
|
704
772
|
const vision = isVisionModel(entry);
|
|
705
|
-
const
|
|
773
|
+
const effectiveMaxTokens = maxTokensFor(entry, maxTokensOverrides);
|
|
706
774
|
return {
|
|
707
775
|
id,
|
|
708
776
|
name: str(entry.name, id),
|
|
@@ -713,7 +781,7 @@ function toPiDescriptor(entry, options = {}) {
|
|
|
713
781
|
reasoning: false,
|
|
714
782
|
cost: { ...NO_COST },
|
|
715
783
|
contextWindow: contextWindowOf(entry),
|
|
716
|
-
...
|
|
784
|
+
...effectiveMaxTokens > 0 ? { maxTokens: effectiveMaxTokens } : {},
|
|
717
785
|
compat: {
|
|
718
786
|
maxTokensField: "max_tokens",
|
|
719
787
|
supportsDeveloperRole: false
|
|
@@ -794,11 +862,11 @@ function buildRosterRows(entries, project) {
|
|
|
794
862
|
* `rosterWithAvailability` 仍显示它们,灰显加原因,但 picker 不提供注定 429 的
|
|
795
863
|
* 请求;两处口径不同是故意的,别统一。
|
|
796
864
|
* @param {object[]} entries - 归一目录条目。
|
|
797
|
-
* @param {object} options - `{ providerId, baseUrl, enabledIds, unavailableModelIds }`。
|
|
865
|
+
* @param {object} options - `{ providerId, baseUrl, enabledIds, unavailableModelIds, maxTokensOverrides }`。
|
|
798
866
|
* @returns {object[]} pi-ai descriptor,按首见顺序。
|
|
799
867
|
*/
|
|
800
868
|
function buildDescriptors(entries, options = {}) {
|
|
801
|
-
const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [] } = options;
|
|
869
|
+
const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [], maxTokensOverrides } = options;
|
|
802
870
|
const filtered = filterByEnabled(entries, enabledIds).filter((entry) => isChatModel(entry));
|
|
803
871
|
const blocked = new Set(Array.isArray(unavailableModelIds) ? unavailableModelIds : []);
|
|
804
872
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -818,7 +886,8 @@ function buildDescriptors(entries, options = {}) {
|
|
|
818
886
|
id
|
|
819
887
|
}, {
|
|
820
888
|
providerId,
|
|
821
|
-
...baseUrl !== void 0 ? { baseUrl } : {}
|
|
889
|
+
...baseUrl !== void 0 ? { baseUrl } : {},
|
|
890
|
+
...maxTokensOverrides !== void 0 ? { maxTokensOverrides } : {}
|
|
822
891
|
});
|
|
823
892
|
}
|
|
824
893
|
return out;
|
|
@@ -899,6 +968,85 @@ function textToImageModelIds(entries) {
|
|
|
899
968
|
return idsOf(entries, (entry) => resolveModelCapability(entry) === "text-to-image");
|
|
900
969
|
}
|
|
901
970
|
/**
|
|
971
|
+
* 一份目录里的出图候选,按首见顺序去重,带能力档与纯文生图标记。
|
|
972
|
+
*
|
|
973
|
+
* 与 {@link imageGenModelIds} 同一份判定(只认平台声明的出图能力),只是把
|
|
974
|
+
* `reference` 需要的两个附加位一起投出来——面板的每一行徽章与「当前使用」都从
|
|
975
|
+
* 这里派生,不各自再判一次。
|
|
976
|
+
* @param {unknown} entries - 归一目录条目。
|
|
977
|
+
* @returns {DrawCandidate[]} 出图候选。
|
|
978
|
+
*/
|
|
979
|
+
function drawCandidates(entries) {
|
|
980
|
+
const position = /* @__PURE__ */ new Map();
|
|
981
|
+
const out = [];
|
|
982
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
983
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
984
|
+
const e = entry;
|
|
985
|
+
if (!isImageGenModel(e)) continue;
|
|
986
|
+
const id = str(e.id, "");
|
|
987
|
+
if (id === "") continue;
|
|
988
|
+
const capability = resolveModelCapability(e);
|
|
989
|
+
const row = {
|
|
990
|
+
id,
|
|
991
|
+
capability: capability === "text-to-image" ? "text-to-image" : capability === "image-to-image" ? "image-to-image" : "other",
|
|
992
|
+
textToImage: capability === "text-to-image" || id === "Qwen/Qwen-Image-Edit"
|
|
993
|
+
};
|
|
994
|
+
const seen = position.get(id);
|
|
995
|
+
if (seen === void 0) {
|
|
996
|
+
position.set(id, out.length);
|
|
997
|
+
out.push(row);
|
|
998
|
+
} else out[seen] = row;
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* 从目录里挑一个出图模型,并说明**为什么是它**。
|
|
1004
|
+
*
|
|
1005
|
+
* 优先级(高 → 低),与 `draw-runtime.ts` 的 `pickDrawModel` 同一份:
|
|
1006
|
+
* 1. `requested` —— 工具调用里显式给的 model(这里不投理由,调用方场景);
|
|
1007
|
+
* 2. `preferred` —— 面板保存 / 配置 `drawModelId`;用户点了谁就是谁,不校验在不在目录;
|
|
1008
|
+
* 3. 平台声明为 `text-to-image` 的条目(将来目录补上真文生图模型时自动优先);
|
|
1009
|
+
* 4. 已知能纯文生图的 `KNOWN_T2I_MODEL_ID`(今天目录的坑位);
|
|
1010
|
+
* 5. 目录里第一个出图模型。
|
|
1011
|
+
*
|
|
1012
|
+
* `requested` 会以 `preferred` 的理由返回——它同样是「有人指定了」,面板不需要
|
|
1013
|
+
* 为一次工具调用区分这两者。
|
|
1014
|
+
* @param {unknown} entries - 归一目录条目。
|
|
1015
|
+
* @param {string} [requested] - 工具调用显式指定的 id。
|
|
1016
|
+
* @param {string} [preferred] - 面板/配置的默认 id。
|
|
1017
|
+
* @returns {DrawModelChoice} 选中的 id 与理由。
|
|
1018
|
+
*/
|
|
1019
|
+
function resolveDrawModelChoice(entries, requested, preferred) {
|
|
1020
|
+
const want = str(requested, "");
|
|
1021
|
+
if (want !== "") return {
|
|
1022
|
+
id: want,
|
|
1023
|
+
source: "preferred"
|
|
1024
|
+
};
|
|
1025
|
+
const preferredId = str(preferred, "");
|
|
1026
|
+
if (preferredId !== "") return {
|
|
1027
|
+
id: preferredId,
|
|
1028
|
+
source: "preferred"
|
|
1029
|
+
};
|
|
1030
|
+
const ids = imageGenModelIds(entries);
|
|
1031
|
+
if (ids.length === 0) return {
|
|
1032
|
+
id: null,
|
|
1033
|
+
source: null
|
|
1034
|
+
};
|
|
1035
|
+
const t2i = textToImageModelIds(entries);
|
|
1036
|
+
if (t2i.length > 0) return {
|
|
1037
|
+
id: t2i[0] ?? null,
|
|
1038
|
+
source: "declared-text-to-image"
|
|
1039
|
+
};
|
|
1040
|
+
if (ids.includes("Qwen/Qwen-Image-Edit")) return {
|
|
1041
|
+
id: KNOWN_T2I_MODEL_ID,
|
|
1042
|
+
source: "known-text-to-image"
|
|
1043
|
+
};
|
|
1044
|
+
return {
|
|
1045
|
+
id: ids[0] ?? null,
|
|
1046
|
+
source: "catalog-first"
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
902
1050
|
* 带额度可用性的面板 roster。
|
|
903
1051
|
*
|
|
904
1052
|
* 与 {@link rosterOf} 一样每 chat 模型 id 一行,但每行还带该模型额度池是否耗尽。
|
|
@@ -906,9 +1054,14 @@ function textToImageModelIds(entries) {
|
|
|
906
1054
|
* 面板保留它们在清单里、灰显,让用户看得见「为什么模型从选择器里不见了」。
|
|
907
1055
|
* @param {object[]} entries - 归一目录条目。
|
|
908
1056
|
* @param {string[]} unavailableIds - 额度耗尽的模型 id;空数组 = 全部可用。
|
|
909
|
-
* @
|
|
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"}[]}
|
|
910
1063
|
*/
|
|
911
|
-
function rosterWithAvailability(entries, unavailableIds) {
|
|
1064
|
+
function rosterWithAvailability(entries, unavailableIds, maxTokensOverrides) {
|
|
912
1065
|
const blocked = new Set(Array.isArray(unavailableIds) ? unavailableIds : []);
|
|
913
1066
|
return buildRosterRows(entries, (entry, id) => ({
|
|
914
1067
|
id,
|
|
@@ -919,7 +1072,8 @@ function rosterWithAvailability(entries, unavailableIds) {
|
|
|
919
1072
|
quotaExhausted: blocked.has(id),
|
|
920
1073
|
contextWindow: contextWindowOf(entry),
|
|
921
1074
|
contextSource: contextWindowSourceOf(entry),
|
|
922
|
-
maxOutputLength:
|
|
1075
|
+
maxOutputLength: maxTokensFor(entry, maxTokensOverrides),
|
|
1076
|
+
maxOutputSource: maxTokensSourceOf(entry, maxTokensOverrides)
|
|
923
1077
|
}));
|
|
924
1078
|
}
|
|
925
1079
|
|
|
@@ -949,7 +1103,7 @@ const inject = ["webServer"];
|
|
|
949
1103
|
* 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
|
|
950
1104
|
* package.json 一致。
|
|
951
1105
|
*/
|
|
952
|
-
const PLUGIN_VERSION = "0.
|
|
1106
|
+
const PLUGIN_VERSION = "0.6.1";
|
|
953
1107
|
/**
|
|
954
1108
|
* 默认值。刻意不含额度常数——理由见本文件头。
|
|
955
1109
|
* 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
|
|
@@ -960,8 +1114,23 @@ const CONFIG_DEFAULTS = Object.freeze({
|
|
|
960
1114
|
apiBase: "https://api-inference.modelscope.cn/v1",
|
|
961
1115
|
/** 站点源站,只用于面板外链(访问令牌页 / 文档)。 */
|
|
962
1116
|
siteBase: "https://modelscope.cn",
|
|
963
|
-
/**
|
|
1117
|
+
/**
|
|
1118
|
+
* 趋势图**显示**天数(面板标题「近 N 天」与图上柱数)——默认值,用户可在面板里
|
|
1119
|
+
* 改看多长。
|
|
1120
|
+
*
|
|
1121
|
+
* **与 retentionDays 是两件事**(2026-10-17 拆开):这个数只决定「画多少」,
|
|
1122
|
+
* 不再决定「留多少」。曾经一个 trendDays 同时是保留窗口,于是「想多看几天」等于
|
|
1123
|
+
* 「改保留策略」,改小还会把历史当场剪掉——拆开正是为了消除这层耦合。
|
|
1124
|
+
*/
|
|
964
1125
|
trendDays: 14,
|
|
1126
|
+
/**
|
|
1127
|
+
* 本地趋势**保留**天数(usage-store 天桶的修剪窗口;超出即删)。
|
|
1128
|
+
*
|
|
1129
|
+
* 取 90:够回看一个季度,而每个天桶只是两个整数——留 90 天的磁盘代价可忽略。
|
|
1130
|
+
* 显示窗口恒被夹在它之内(见 resolveSettings),所以「画得比留得多」这个会把
|
|
1131
|
+
* 已剪的日子画成 0(与「那天真的没调用」在面板上无法区分)的组合不会出现。
|
|
1132
|
+
*/
|
|
1133
|
+
retentionDays: 90,
|
|
965
1134
|
/** Host 侧对 /v1/models 的缓存秒数。 */
|
|
966
1135
|
cacheSeconds: 60,
|
|
967
1136
|
/** 面板轮询间隔——由 Host 下发、面板跟随,两端不各自假设。 */
|
|
@@ -970,9 +1139,55 @@ const CONFIG_DEFAULTS = Object.freeze({
|
|
|
970
1139
|
inferenceTimeoutMs: 3e4,
|
|
971
1140
|
/** 事件流保留条数(429/错误事件的环形上限)。 */
|
|
972
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,
|
|
973
1158
|
/** provider 注册的 patch 默认值(面板保存值优先于它)。 */
|
|
974
1159
|
registerProvider: false,
|
|
975
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
|
+
/**
|
|
976
1191
|
* 出图工具的 patch 默认值(面板保存值优先于它)。
|
|
977
1192
|
*
|
|
978
1193
|
* **与 `registerProvider` 是两个独立开关**:前者是「把魔搭当 chat provider」,
|
|
@@ -995,6 +1210,10 @@ const CONFIG_DEFAULTS = Object.freeze({
|
|
|
995
1210
|
* 取 15 分钟:覆盖已观测到的最慢一次(537s)并留一倍余量。**注意超时放弃不等于
|
|
996
1211
|
* 任务死亡**——上游任务会继续跑(且记录最终会被回收,见 docs/SPIKE-image-gen.md
|
|
997
1212
|
* §坑 5)。宁可多等,不要把一张已经在生成的图扔掉。
|
|
1213
|
+
*
|
|
1214
|
+
* 「15 分钟够不够」我们**没有把握**(上面的实测还在恶化),所以夹取的上界
|
|
1215
|
+
* `DRAW_TIMEOUT_MAX_MS` 取 30 分钟,严格高于本默认值——觉得不够就往上调,
|
|
1216
|
+
* 别把它当上限用。
|
|
998
1217
|
*/
|
|
999
1218
|
drawTimeoutMs: 9e5,
|
|
1000
1219
|
/**
|
|
@@ -1023,6 +1242,20 @@ function clampInt(raw, def, min, max = Infinity) {
|
|
|
1023
1242
|
return Math.min(max, Math.max(min, Math.floor(num(raw, def))));
|
|
1024
1243
|
}
|
|
1025
1244
|
/**
|
|
1245
|
+
* `drawTimeoutMs` 的**上界**——必须严格大于它的默认值。
|
|
1246
|
+
*
|
|
1247
|
+
* 这个上界曾经与默认值同为 900_000,于是该字段只能调小、不能调大。而「15 分钟
|
|
1248
|
+
* 够不够」恰恰是本文件自己承认**没有把握**的问题:上面的注释记着同模型连测
|
|
1249
|
+
* 61s→261s→**537s** 的单调恶化、5 分钟预算被实测击穿,docs/SPIKE-image-gen.md
|
|
1250
|
+
* 还记着一次 900s 预算的 e2e 在 905s 放弃。把一个**已知可能不够用**的数值同时
|
|
1251
|
+
* 写成默认值与硬上限,等于在用户最需要「多等一会儿」的时刻把旋钮焊死——而且
|
|
1252
|
+
* 焊死在一个已被实测证伪的数上。
|
|
1253
|
+
*
|
|
1254
|
+
* 取 30 分钟:仍是有界的(不会让一次工具调用无限期挂着),但比默认值高出一倍,
|
|
1255
|
+
* 慢档用户有可调空间。它同时是 `drawWaitMs` 的上界来源(见 resolveSettings)。
|
|
1256
|
+
*/
|
|
1257
|
+
const DRAW_TIMEOUT_MAX_MS = 18e5;
|
|
1258
|
+
/**
|
|
1026
1259
|
* 端点必须是合法的 http(s) 绝对地址;不是就在**挂载时**抛(resolveSettings
|
|
1027
1260
|
* 捕获后转成 configError 经快照呈现),而不是等到第一次轮询才变成莫名其妙的
|
|
1028
1261
|
* 网络失败。
|
|
@@ -1037,6 +1270,32 @@ function assertHttpUrl(value, field) {
|
|
|
1037
1270
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${field} must be an http(s) URL, got ${parsed.protocol}`);
|
|
1038
1271
|
}
|
|
1039
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
|
+
/**
|
|
1040
1299
|
* 把 patch 行的原始配置解析成有效设置。
|
|
1041
1300
|
*
|
|
1042
1301
|
* 坏行不许从这里抛出去——apply 在挂载期跑,抛错等于整机插件消失。问题以
|
|
@@ -1049,20 +1308,25 @@ function resolveSettings(config) {
|
|
|
1049
1308
|
const siteBase = str(source.siteBase, CONFIG_DEFAULTS.siteBase).replace(/\/+$/, "");
|
|
1050
1309
|
assertHttpUrl(apiBase, "apiBase");
|
|
1051
1310
|
assertHttpUrl(siteBase, "siteBase");
|
|
1311
|
+
const drawTimeoutMs = clampInt(source.drawTimeoutMs, CONFIG_DEFAULTS.drawTimeoutMs, 3e4, DRAW_TIMEOUT_MAX_MS);
|
|
1312
|
+
const retentionDays = clampInt(source.retentionDays, CONFIG_DEFAULTS.retentionDays, 1, 365);
|
|
1052
1313
|
return {
|
|
1053
1314
|
settings: {
|
|
1054
1315
|
apiBase,
|
|
1055
1316
|
siteBase,
|
|
1056
|
-
trendDays: clampInt(source.trendDays, CONFIG_DEFAULTS.trendDays, 1,
|
|
1317
|
+
trendDays: clampInt(source.trendDays, CONFIG_DEFAULTS.trendDays, 1, retentionDays),
|
|
1318
|
+
retentionDays,
|
|
1057
1319
|
cacheSeconds: clampInt(source.cacheSeconds, CONFIG_DEFAULTS.cacheSeconds, 5, 600),
|
|
1058
1320
|
pollSeconds: clampInt(source.pollSeconds, CONFIG_DEFAULTS.pollSeconds, 5, 3600),
|
|
1059
1321
|
inferenceTimeoutMs: clampInt(source.inferenceTimeoutMs, CONFIG_DEFAULTS.inferenceTimeoutMs, 1e3, 6e5),
|
|
1060
1322
|
maxEvents: clampInt(source.maxEvents, CONFIG_DEFAULTS.maxEvents, 1, 1e3),
|
|
1323
|
+
exhaustionWindowMinutes: clampInt(source.exhaustionWindowMinutes, CONFIG_DEFAULTS.exhaustionWindowMinutes, 5, 1440),
|
|
1061
1324
|
registerProvider: source.registerProvider === true,
|
|
1325
|
+
maxTokensOverrides: resolveMaxTokensOverrides(source.maxTokensOverrides),
|
|
1062
1326
|
drawEnabled: source.drawEnabled === true,
|
|
1063
1327
|
drawModelId: str(source.drawModelId, CONFIG_DEFAULTS.drawModelId),
|
|
1064
|
-
drawTimeoutMs
|
|
1065
|
-
drawWaitMs: clampInt(source.drawWaitMs, CONFIG_DEFAULTS.drawWaitMs, 1e4,
|
|
1328
|
+
drawTimeoutMs,
|
|
1329
|
+
drawWaitMs: clampInt(source.drawWaitMs, CONFIG_DEFAULTS.drawWaitMs, 1e4, drawTimeoutMs),
|
|
1066
1330
|
allowedHosts: resolveAllowedHosts(source)
|
|
1067
1331
|
},
|
|
1068
1332
|
configError: null
|
|
@@ -1073,11 +1337,14 @@ function resolveSettings(config) {
|
|
|
1073
1337
|
apiBase: CONFIG_DEFAULTS.apiBase,
|
|
1074
1338
|
siteBase: CONFIG_DEFAULTS.siteBase,
|
|
1075
1339
|
trendDays: CONFIG_DEFAULTS.trendDays,
|
|
1340
|
+
retentionDays: CONFIG_DEFAULTS.retentionDays,
|
|
1076
1341
|
cacheSeconds: CONFIG_DEFAULTS.cacheSeconds,
|
|
1077
1342
|
pollSeconds: CONFIG_DEFAULTS.pollSeconds,
|
|
1078
1343
|
inferenceTimeoutMs: CONFIG_DEFAULTS.inferenceTimeoutMs,
|
|
1079
1344
|
maxEvents: CONFIG_DEFAULTS.maxEvents,
|
|
1345
|
+
exhaustionWindowMinutes: CONFIG_DEFAULTS.exhaustionWindowMinutes,
|
|
1080
1346
|
registerProvider: false,
|
|
1347
|
+
maxTokensOverrides: CONFIG_DEFAULTS.maxTokensOverrides,
|
|
1081
1348
|
drawEnabled: false,
|
|
1082
1349
|
drawModelId: CONFIG_DEFAULTS.drawModelId,
|
|
1083
1350
|
drawTimeoutMs: CONFIG_DEFAULTS.drawTimeoutMs,
|
|
@@ -1148,4 +1415,4 @@ function isAdmitted(request, allowedHosts) {
|
|
|
1148
1415
|
}
|
|
1149
1416
|
|
|
1150
1417
|
//#endregion
|
|
1151
|
-
export {
|
|
1418
|
+
export { errMsg as C, redactSecrets as D, pluginError as E, str as O, degrade as S, optional as T, parseAllowedList as _, resolveSettings as a, looksLikeRateLimit as b, LLM_PROVIDER_ID as c, imageGenModelIds as d, isVisionModel as f, enabledCountOf as g, rosterWithAvailability as h, name as i, verbatim as k, buildDescriptors as l, resolveDrawModelChoice as m, inject as n, LLM_API_KEY_NAME as o, normalizeEntry as p, isAdmitted as r, LLM_DISPLAY_NAME as s, PLUGIN_VERSION as t, drawCandidates as u, CODE as v, obj as w, reclassifyStream as x, hasHardQuotaWordingIn as y };
|