dsh-connect-modelscope-token-plan 0.1.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.
@@ -0,0 +1,557 @@
1
+ import { C as redactSecrets, _ as reclassifyStream, c as LLM_PROVIDER_ID, i as name, l as buildDescriptors, m as CODE, o as LLM_API_KEY_NAME, s as LLM_DISPLAY_NAME } from "./host-config-DBCiZOBW.js";
2
+ import { createProvider } from "@earendil-works/pi-ai";
3
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
4
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
5
+ import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
+
7
+ //#region src/host/llm-retry.ts
8
+ /**
9
+ * The directly-registered ModelScope provider's 429 retry policy — the peer-FREE
10
+ * half of the 429 self-healing work.
11
+ *
12
+ * The *decision* (which failure classes this shared-pool provider retries, and
13
+ * how gently) is pure, so it lives here — importable on a clean checkout where
14
+ * the `@deepseek-ai/dsh-llm` peer is not resolvable — and is handed to the peer's
15
+ * `resolveRetryPolicy` from `llm-adapter.ts`. `test/retry.test.mjs` pins its
16
+ * shape without importing that peer.
17
+ *
18
+ * The peer classifies a ModelScope 429 into two codes (`isQuotaExceededError` →
19
+ * `rate.?limit` inside `classifyPiAiError`, pinned against the real source by
20
+ * `test/peer-contract.test.mjs`):
21
+ *
22
+ * - `QUOTA` / `ACCOUNT_QUOTA` — the Token Plan pool is depleted. Retrying
23
+ * cannot refill it, and the pool is SHARED across every model on this key,
24
+ * so hammering it only extends the cool-down (the same lesson `st-rotator`
25
+ * bakes into its AIMD limiter). Deliberately NOT retried: fast-fail and let
26
+ * the panel say why.
27
+ * - `RATE_LIMIT` — a transient throttle that self-clears. Retried, with a
28
+ * backoff biased longer than the peer default so one shared pool is not
29
+ * re-hit immediately: ModelScope's daytime rpm/tpm ceiling is aggressive
30
+ * (`llm-error-fix.ts`: its `quota_exceeded_error` code 8 is really a
31
+ * per-minute rate cap), so we ride it out with more attempts and a gentler
32
+ * first step.
33
+ *
34
+ * @module dsh-connect-modelscope-token-plan/llm-retry
35
+ */
36
+ /**
37
+ * The failure-class codes this provider reasons about, in peer-canonical
38
+ * spelling.
39
+ *
40
+ * The strings mirror the `@deepseek-ai/dsh-llm` peer's error-code constants
41
+ * (`QUOTA_EXCEEDED_CODE = "QUOTA"`, `ACCOUNT_QUOTA_EXCEEDED_CODE =
42
+ * "ACCOUNT_QUOTA"`, `EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE"`). They are stable
43
+ * protocol codes, not implementation details, so pinning them here is what the
44
+ * qoder route does too; `llm-adapter.ts` still imports the live constants from
45
+ * the peer and passes them through `resolveRetryPolicy`, so a peer rename would
46
+ * surface at the adapter, not silently drift here.
47
+ */
48
+ const QUOTA_CODES = Object.freeze({
49
+ /** Depleted Token Plan pool (per-pool quota). Not retried. */
50
+ quota: "QUOTA",
51
+ /** Depleted account-level quota. Not retried. */
52
+ accountQuota: "ACCOUNT_QUOTA",
53
+ /** Empty/truncated response. Retried. */
54
+ emptyResponse: "EMPTY_RESPONSE",
55
+ /** Transient throttle (429 rate). Retried with backoff. */
56
+ rateLimit: "RATE_LIMIT",
57
+ /** Upstream 5xx. Retried. */
58
+ server: "SERVER",
59
+ /** Request deadline exceeded. Retried. */
60
+ timeout: "TIMEOUT",
61
+ /** Connection-level failure. Retried. */
62
+ transport: "TRANSPORT"
63
+ });
64
+ /**
65
+ * The failure classes this provider retries, in peer-canonical order.
66
+ *
67
+ * Excludes both quota codes on purpose: a depleted pool cannot be retried into
68
+ * health, and retrying it against a shared credit pool only prolongs the
69
+ * cool-down. `RATE_LIMIT` stays — transient throttles self-clear.
70
+ * @returns {string[]} the retryable code list (no duplicates, non-empty).
71
+ */
72
+ function retryableCodes() {
73
+ return [
74
+ QUOTA_CODES.emptyResponse,
75
+ QUOTA_CODES.rateLimit,
76
+ QUOTA_CODES.server,
77
+ QUOTA_CODES.timeout,
78
+ QUOTA_CODES.transport
79
+ ];
80
+ }
81
+ /**
82
+ * Build the provider's retry-policy config.
83
+ *
84
+ * The shape is exactly what `@deepseek-ai/dsh-llm`'s `resolveRetryPolicy`
85
+ * accepts (`mode: "normal"` → `{ mode, maxRetries, retryableCodes, backoff }`).
86
+ * We pin it explicitly rather than passing `undefined` so a future change to
87
+ * the peer's default policy cannot silently alter this provider's behaviour.
88
+ * This module's shape is pinned by `test/retry.test.mjs`; the live peer's
89
+ * `resolveRetryPolicy` contract is covered by `test/peer-contract.test.mjs`,
90
+ * which needs the runtime peer (not vendored here) and is outside the offline
91
+ * `npm test` gate.
92
+ *
93
+ * Tuned for ModelScope's daytime rate ceiling (rpm/tpm), which the peer mislabels
94
+ * as `QUOTA` — `llm-error-fix.ts` pulls those back to `RATE_LIMIT` so they
95
+ * reach this policy. The numbers: more attempts (8) and a gentler, longer
96
+ * backoff than the peer default (initial 1.5s → cap 20s, jitter 0.25) so a
97
+ * single shared credit pool is not stampeded while the rate window refills.
98
+ * Still bounded: a genuine outage fails after ~90s of backed-off retries rather
99
+ * than spinning forever. QUOTA stays excluded (a depleted pool cannot be retried
100
+ * into health; retrying it only prolongs the cool-down — ROADMAP §1).
101
+ * @returns {{mode: "normal", maxRetries: number, retryableCodes: string[], backoff: {initialDelayMs: number, maxDelayMs: number, jitterRatio: number}}}
102
+ */
103
+ function buildRetryPolicyConfig() {
104
+ return {
105
+ mode: "normal",
106
+ maxRetries: 8,
107
+ retryableCodes: retryableCodes(),
108
+ backoff: {
109
+ initialDelayMs: 1500,
110
+ maxDelayMs: 2e4,
111
+ jitterRatio: .25
112
+ }
113
+ };
114
+ }
115
+
116
+ //#endregion
117
+ //#region src/host/llm-adapter-core.ts
118
+ /**
119
+ * `PiAiAdapter` 装配的共享半边——魔搭 provider 的 inert pi-ai auth 平面、
120
+ * profile 行与流重分类 Proxy 都从这里来。
121
+ *
122
+ * 装配这一层(inert auth 平面、profile 行、`PiAiAdapter` 装配、流重分类 Proxy)
123
+ * 最初在姊妹插件里是抄写的多份:那是**对运行时 peer 的装配**,不是领域逻辑——
124
+ * 副本之间的差异买不到任何东西,代价却是要修多次(429 误分类层是修正,每一处
125
+ * 修正都必须到达每一条路由,否则其中一条会静默停止退避)。魔搭复用同一份,
126
+ * 理由完全一样:装配与上游是谁无关。
127
+ *
128
+ * 留在各适配器里的:descriptor 构建(每个上游映自己的 roster/catalog)与各路由
129
+ * 读取的凭据解析器——那是真正的领域差异。
130
+ *
131
+ * peer-dependent:import `pi-ai` / `dsh-llm` / `dsh-llm-pi-ai`,它们随 Host
132
+ * 发行。与适配器本身一样,不能由离线单测套件导入,由 wiring/e2e 检查来测。
133
+ *
134
+ * @module dsh-connect-modelscope-token-plan/llm-adapter-core
135
+ */
136
+ /** 一次流读取在途时的空闲上限(dsh-llm-pi-ai 默认)。 */
137
+ const STREAM_IDLE_TIMEOUT_MS = 3e5;
138
+ /** `dsh-llm-pi-ai` 默认的像素预算(魔搭 provider 直接沿用)。 */
139
+ const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
140
+ /**
141
+ * `dsh-llm-pi-ai` 默认的图片预算,像素预算可覆盖。
142
+ * @param {number} [requestImagePixelBudget] - 像素预算覆盖值。
143
+ * @returns {{maxRequestImageBytes: number, requestImagePixelBudget: number,
144
+ * requestImageMaxBytes: number}} profile 的图片预算行。
145
+ */
146
+ function imageBudgets(requestImagePixelBudget = DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET) {
147
+ return {
148
+ maxRequestImageBytes: 20971520,
149
+ requestImagePixelBudget,
150
+ requestImageMaxBytes: 1048576
151
+ };
152
+ }
153
+ /**
154
+ * inert pi-ai auth 平面。
155
+ *
156
+ * 认证走各路由提供的凭据解析器(存着的 `MODELSCOPE_API_KEY` 引用),每次请求现
157
+ * 读。pi-ai 自己的凭据生命周期绝不能为这条路制造凭据,所以所有环境性问题都答
158
+ * 「没存、没设」。
159
+ */
160
+ const INERT_AUTH = {
161
+ credentials: {
162
+ async read() {},
163
+ async list() {
164
+ return [];
165
+ },
166
+ async modify() {},
167
+ async delete() {}
168
+ },
169
+ authContext: {
170
+ async env() {},
171
+ async fileExists() {
172
+ return false;
173
+ }
174
+ }
175
+ };
176
+ /**
177
+ * 给适配器包一层,让两个流出口都把被误判的 429 重分类。
178
+ *
179
+ * peer 的 `classifyPiAiError` 会把「budget/credits」措辞的 429 读成 QUOTA(不重
180
+ * 试);这一层在流离开前把这样的 body 改回 RATE_LIMIT,`llm-retry.ts` 的退避才
181
+ * 真的会触发。只拦流出口,不碰 peer 内部,也不改普通数据块。见 `llm-error-fix.ts`。
182
+ * @param {object} inner - 要包的 `PiAiAdapter`。
183
+ * @returns {object} 包好的适配器。
184
+ */
185
+ function withReclassifiedStream(inner) {
186
+ return new Proxy(inner, { get(target, prop, receiver) {
187
+ const value = Reflect.get(target, prop, receiver);
188
+ if (prop === "stream") {
189
+ const stream = target.stream;
190
+ return (options) => reclassifyStream(stream(options));
191
+ }
192
+ if (typeof value === "function" && prop === "prepareCall") {
193
+ const prepare = target.prepareCall;
194
+ return (...args) => {
195
+ const prepared = prepare.apply(target, args);
196
+ const handle = prepared;
197
+ if (handle !== null && typeof handle.then === "function") return handle.then((p) => {
198
+ const inner = p;
199
+ return inner !== null && typeof inner.stream === "function" ? {
200
+ ...inner,
201
+ stream: (o) => reclassifyStream(inner.stream(o))
202
+ } : p;
203
+ });
204
+ return handle !== null && typeof handle.stream === "function" ? {
205
+ ...handle,
206
+ stream: (o) => reclassifyStream(handle.stream(o))
207
+ } : prepared;
208
+ };
209
+ }
210
+ return value;
211
+ } });
212
+ }
213
+ /**
214
+ * 装配一个 provider 的适配器:pi-ai provider、它的单条 profile 行、承载它们的
215
+ * `PiAiAdapter`。
216
+ *
217
+ * 每次重建都换新实例是刻意的:`PiAiAdapter` 内部 memoize profiles 快照,所以目录
218
+ * 或凭据变化时调用方**替换**注册的适配器并 emit `llm/adapters-updated`。
219
+ * @param {PiAiAdapterParts} options - 每条上游的事实。
220
+ * @returns {{adapter: object, providerIds: string[]}} 适配器与它拥有的 ids。
221
+ */
222
+ function assemblePiAiAdapter({ providerId, displayName, apiKeyName, models, resolveCredential, get, reasoning, requestImagePixelBudget }) {
223
+ const provider = {
224
+ ...createProvider({
225
+ id: providerId,
226
+ name: displayName,
227
+ auth: { apiKey: {
228
+ name: apiKeyName,
229
+ /**
230
+ * pi-ai 会把它解析到的凭据递过来;这些路由自己不存,所以参数只为了给
231
+ * 「从它上面读什么」一个名字。
232
+ * @param {{credential?: {key?: string}}} [options]
233
+ */
234
+ async resolve({ credential } = {}) {
235
+ const key = credential?.key;
236
+ return key === void 0 || key.length === 0 ? void 0 : {
237
+ auth: { apiKey: key },
238
+ source: displayName
239
+ };
240
+ }
241
+ } },
242
+ models,
243
+ api: openAICompletionsApi()
244
+ }),
245
+ getModels: () => models
246
+ };
247
+ const profiles = /* @__PURE__ */ new Map([[providerId, {
248
+ provider: providerId,
249
+ displayName,
250
+ streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
251
+ retryPolicy: resolveRetryPolicy(buildRetryPolicyConfig(), `${name}.${providerId}.retryPolicy`),
252
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
253
+ modelErrors: /* @__PURE__ */ new Map(),
254
+ ...reasoning !== void 0 ? { reasoning } : {},
255
+ ...imageBudgets(requestImagePixelBudget),
256
+ piProvider: provider
257
+ }]]);
258
+ return {
259
+ adapter: withReclassifiedStream(new PiAiAdapter({
260
+ profiles: () => profiles,
261
+ auth: INERT_AUTH,
262
+ resolveApiKey: async () => resolveCredential(),
263
+ resolveAttachments: () => get?.("attachments"),
264
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => (get?.("fs"))?.processPathFromHostPath?.(hostPath), ref)
265
+ })),
266
+ providerIds: [providerId]
267
+ };
268
+ }
269
+
270
+ //#endregion
271
+ //#region src/host/usage-observer.ts
272
+ /**
273
+ * 本地计数的**写入侧**——把 DSH 真实对话(经本插件注册的 provider)折算成
274
+ * usage-store 的天桶/单模型计数与 429 事件流。
275
+ *
276
+ * 为什么不是「在 adapter 里随便挂个钩子」,而是独立一层:M4 之前
277
+ * `usageStore.recordCall` 的唯一调用者是 `routes/probe.ts`,而面板的 usage
278
+ * 试调按钮已随目录表删除、validity 探针按设计不记调用——于是本地计数层在
279
+ * 接入 provider 之后**没有任何生产者**,「今日次数 / 单模型分布 / 趋势 /
280
+ * 429 事件流」四块面板恒为 0 与空。本模块补上这条缺失的生产者。
281
+ *
282
+ * ## 与 harness 自己的账本的分工(别再造第二本账)
283
+ *
284
+ * DSH 有 `@linxin666/dsh-usage`,它按 `days → provider → model` 折 token
285
+ * (`~/.dsh/dsh-usage/usage-ledger.json`),数据源是 session 的
286
+ * `assistant/message` 事件。因此:
287
+ *
288
+ * - **成功调用的 token 已有主**。本模块**不重复记 token 总量**去和它抢一个
289
+ * 真源;本插件面板的「今日次数 / 分布 / 趋势」是**本插件口径**的独立观测
290
+ * (回答「经本插件的调用分布」,与全局账本的回答不同,两者可以并存)。
291
+ * - **失败调用没有主**。`assistant/message` 只在成功时发出,429/错误一次都
292
+ * 不进那本账——而「哪个模型刚才在限频/额度耗尽」恰恰是魔搭面板唯一能给出
293
+ * 独家答案的问题(官方只给总额度余额)。所以**事件流是本模块存在的核心理由**。
294
+ *
295
+ * 换句话说:能用官方魔粒余额回答的,本插件不重复回答;只有本地计数能回答的
296
+ * (分布 / 趋势 / 失败事件),才由这里生产。
297
+ *
298
+ * ## 观察点选在「流出口」,不是 HTTP 层
299
+ *
300
+ * harness 的流协议(`@deepseek-ai/dsh-llm` 的 `StreamChunk`)自带
301
+ * `{type:'usage', usage: TokenUsage}` chunk 与 `finish` chunk:
302
+ *
303
+ * - 成功:末尾先 `usage`(累计值)再 `finish`;
304
+ * - 失败:`finish` 且 `reason.kind === 'error'`(或 `aborted`),带
305
+ * `reason.failure.{code,message}`——429 的 code/message 就在这里。
306
+ *
307
+ * 所以一次遍历就能同时拿到 token 与失败分类,不必碰 provider 的私有 API,
308
+ * 也不必改 vendor peer。`llm-error-fix.ts` 的重分类层已在同一位置改写 code,
309
+ * 事件分诊**读改写后的 code**,于是「被误判成 QUOTA 的 rpm 限频」在本插件的
310
+ * 事件流里也记成 `rate_limit` 而不是 `quota`——面板与重试策略说同一件事。
311
+ *
312
+ * ## 记账口径
313
+ *
314
+ * 每次流**结束就记一次 call**(含失败、含用户中断):魔搭按**次数**计费,
315
+ * 一次被打上限频的请求同样是一次上游调用。token 数在上游给出时带上,给不出
316
+ * 时传 `null`(wire 语义基线:null 是「上游没给」,绝不写成 0)。
317
+ *
318
+ * 已知偏差(方向是少记,不是多记):peer 的重试在同一条流**内部**重发 HTTP,
319
+ * 观察器只看到一条流,于是重试掉的那些次不单独计。宁可少记也不虚增。
320
+ *
321
+ * peer-free:只 import 本仓库的 peer-free 模块(`llm-error-fix` / `util`)与
322
+ * `shared/wire` 的类型,离线可测——与 `llm-error-fix` 同一纪律。
323
+ *
324
+ * @module dsh-connect-modelscope-token-plan/usage-observer
325
+ */
326
+ /**
327
+ * 读一个流 chunk 的 token 总量,取不到返回 `null`。
328
+ *
329
+ * 优先 `totalTokens`(peer 保留的上游精确总量);它缺失时退回
330
+ * `inputTokens + outputTokens`——**不含** cache 字段,因为本插件的账是
331
+ * 「次数 + 大致 token」口径,与全局账本的可加总口径刻意不同。
332
+ * @param {unknown} chunk - 一个流 chunk。
333
+ * @returns {number|null} 正整数 token 数,或 null。
334
+ */
335
+ function tokensOfChunk(chunk) {
336
+ if (chunk === null || typeof chunk !== "object") return null;
337
+ const usage = chunk.usage;
338
+ if (usage === null || typeof usage !== "object") return null;
339
+ const total = usage.totalTokens;
340
+ if (typeof total === "number" && Number.isFinite(total) && total > 0) return total;
341
+ const input = usage.inputTokens;
342
+ const output = usage.outputTokens;
343
+ const sum = (typeof input === "number" && Number.isFinite(input) && input > 0 ? input : 0) + (typeof output === "number" && Number.isFinite(output) && output > 0 ? output : 0);
344
+ return sum > 0 ? sum : null;
345
+ }
346
+ /**
347
+ * 读一个流 chunk 的失败事件,取不到返回 `null`(成功 chunk、空 chunk、非
348
+ * error 终止都返回 null)。
349
+ *
350
+ * 分诊读的是**重分类之后**的 `failure.code`(`llm-error-fix` 与本层跑在同一个
351
+ * 包装链上,改写先于观察发生):
352
+ *
353
+ * - `QUOTA` / `ACCOUNT_QUOTA` → `quota`(真耗尽,快失败,不重试);
354
+ * - `RATE_LIMIT` → `rate_limit`(瞬时限频,会退避重试);
355
+ * - 其它 code(含空 code)→ `error`。
356
+ *
357
+ * message 过 `redactSecrets` 后再返回:上游错误体是外来文本,事件流会进面板
358
+ * 与 state 文件,凭据形状必须先脱敏。
359
+ * @param {unknown} chunk - 一个流 chunk。
360
+ * @returns {{kind: QuotaEvent["kind"], message: string}|null} 事件,或 null。
361
+ */
362
+ function eventOfChunk(chunk) {
363
+ if (chunk === null || typeof chunk !== "object") return null;
364
+ const reason = chunk.reason;
365
+ if (reason === null || typeof reason !== "object") return null;
366
+ if (reason.kind !== "error") return null;
367
+ const failure = reason.failure;
368
+ if (failure === null || typeof failure !== "object") return null;
369
+ const code = typeof failure.code === "string" ? failure.code : "";
370
+ const kind = code === CODE.QUOTA || code === CODE.ACCOUNT_QUOTA ? "quota" : code === CODE.RATE_LIMIT ? "rate_limit" : "error";
371
+ const raw = typeof failure.message === "string" ? failure.message : "";
372
+ return {
373
+ kind,
374
+ message: redactSecrets(raw === "" ? "stream failed (" + code + ")" : raw)
375
+ };
376
+ }
377
+ /**
378
+ * 包一条流:透传所有 chunk,同时在流结束时把这一次调用记进 sinks。
379
+ *
380
+ * 三个刻意的行为:
381
+ *
382
+ * 1. **结束即记账,且只记一次**。正常结束、消费者提前 `break`、内层抛错都走
383
+ * 同一个 `finally`(async generator 的 `finally` 至多执行一次,所以不需要
384
+ * 额外的幂等闸);魔搭按次数计费,这三种都是一次真实的上游调用。多个
385
+ * `usage` chunk 取最后一个(peer 的 usage 是累计值,不是增量)。
386
+ * 2. **失败也记账,但 token 为 null**。429/错误没有 usage chunk,计数照记、
387
+ * 事件另记;「没有 token 数」与「token 是 0」在 wire 里是两件事。
388
+ * 3. **写 sinks 永不抛**。`safe()` 把同步抛与异步拒一并吞掉:观测层不能成为
389
+ * 对话失败的原因。
390
+ *
391
+ * @param {AsyncIterableIterator<unknown>} source - 内层流。
392
+ * @param {string} modelId - 本次调用的模型 id(由出口处的 options 捕获)。
393
+ * @param {UsageSinks} sinks - 写入口。
394
+ * @returns {AsyncGenerator<unknown>} 透传后的流。
395
+ */
396
+ async function* observeStream(source, modelId, sinks) {
397
+ let tokens = null;
398
+ try {
399
+ for await (const chunk of source) {
400
+ const seen = tokensOfChunk(chunk);
401
+ if (seen !== null) tokens = seen;
402
+ const event = eventOfChunk(chunk);
403
+ if (event !== null) safe(() => sinks.recordEvent({
404
+ modelId,
405
+ kind: event.kind,
406
+ message: event.message
407
+ }));
408
+ yield chunk;
409
+ }
410
+ } finally {
411
+ safe(() => sinks.recordCall({
412
+ modelId,
413
+ tokens
414
+ }));
415
+ }
416
+ }
417
+ /** 调一个 sink 并吞掉它的任何失败(同步抛与异步拒都吞)。 */
418
+ function safe(run) {
419
+ try {
420
+ const result = run();
421
+ if (result !== null && typeof result === "object" && typeof result.then === "function") result.then(void 0, () => {});
422
+ } catch {}
423
+ }
424
+ /**
425
+ * 读一条调用参数里的模型 id(`GenerateOptions.model`)。
426
+ *
427
+ * 缺失/空串一律读成 `""`,由调用方决定是否跳过记账——**不猜**:把 `unknown`
428
+ * 填成某个模型 id 会让分布图出现凭空的一行。
429
+ * @param {unknown} options - 流/调用的参数对象。
430
+ * @returns {string} 模型 id,或空串。
431
+ */
432
+ function modelIdOfOptions(options) {
433
+ if (options === null || typeof options !== "object") return "";
434
+ const model = options.model;
435
+ return typeof model === "string" ? model : "";
436
+ }
437
+ /**
438
+ * 给一个 adapter 套上观察层:包住两个流出口(`stream` 与
439
+ * `prepareCall(...).stream`),与 `llm-adapter-core` 的重分类包装同构。
440
+ *
441
+ * 刻意**不**把这段塞进 `llm-adapter-core.ts`:那个文件是三族插件的受控复制
442
+ * 共享层(sensenova/agnes 各有一份),往里加一个只有魔搭需要的观测钩子,等于
443
+ * 给三份副本之间再造一个漂移面(`patchPayload`、签名比对、三态口径都栽在
444
+ * 这类地方)。这里独立成文件、由魔搭自己的 `llm-adapter.ts` 组合。
445
+ *
446
+ * 无 sinks 时返回原对象(不套 Proxy)——没有观测者的路径零开销。
447
+ *
448
+ * @param {object} inner - 适配器实例。
449
+ * @param {UsageSinks} sinks - 写入口。
450
+ * @returns {object} 套好观察层的适配器。
451
+ */
452
+ function withUsageObserver(inner, sinks) {
453
+ const wrapStream = (stream, options) => {
454
+ const modelId = modelIdOfOptions(options);
455
+ const source = stream(options);
456
+ if (modelId === "") return source;
457
+ return observeStream(source, modelId, sinks);
458
+ };
459
+ return new Proxy(inner, { get(target, prop, receiver) {
460
+ const value = Reflect.get(target, prop, receiver);
461
+ if (prop === "stream") {
462
+ const stream = target.stream;
463
+ return typeof stream === "function" ? (options) => wrapStream(stream, options) : value;
464
+ }
465
+ if (typeof value === "function" && prop === "prepareCall") {
466
+ const prepare = target.prepareCall;
467
+ return (...args) => {
468
+ const modelId = modelIdOfOptions({ model: args[1] });
469
+ const prepared = prepare.apply(target, args);
470
+ const wrap = (handle) => {
471
+ if (handle === null || typeof handle !== "object" || modelId === "") return handle;
472
+ const inner = handle;
473
+ if (typeof inner.stream !== "function") return handle;
474
+ return {
475
+ ...inner,
476
+ stream: (o) => observeStream(inner.stream(o), modelId, sinks)
477
+ };
478
+ };
479
+ const promise = prepared;
480
+ if (promise !== null && typeof promise.then === "function") return promise.then((p) => wrap(p));
481
+ return wrap(prepared);
482
+ };
483
+ }
484
+ return value;
485
+ } });
486
+ }
487
+
488
+ //#endregion
489
+ //#region src/host/llm-adapter.ts
490
+ /**
491
+ * 魔搭 provider 注册的 peer-dependent 半边。
492
+ *
493
+ * 这里的一切都跑在 Host 发行的 peer 上(`pi-ai`、`dsh-llm`、`dsh-llm-pi-ai`)——这
494
+ * 正是 descriptor 映射要住在 peer-free 的 `llm-models.ts` 的原因:本模块不能被离线
495
+ * 单测套件导入,所以它只装对运行时的装配,由 wiring/e2e 检查来测。
496
+ *
497
+ * 形状对齐已知可用的 qoder 适配器:
498
+ *
499
+ * - 一个 `PiAiAdapter` 承载一条 profile(本 provider 就一个源站——`apiBase`);
500
+ * - inert pi-ai auth 平面——令牌每次请求现从插件自己的 store 解析,pi-ai 绝不能
501
+ * 自己造凭据;
502
+ * - 两个 IMAGE hook 都接上,否则吃图模型在消息带图的那一刻就会答
503
+ * `UNSUPPORTED_CONTENT`;
504
+ * - profile 里不烤 API key:picker 在没钥匙的情况下照常展示模型,请求在解析时才
505
+ * 失败,而那里正是面板状态可见的地方。
506
+ *
507
+ * 装配与姊妹插件共用一份(`llm-adapter-core.ts`);本 provider 贡献的是它的
508
+ * descriptor 构建与凭据解析器。**不传 `reasoning`**(§4:魔搭是多模型代理,档位表
509
+ * 无法离线得知,宁可「不选」也不「错发」——profile 不钉 effort)。
510
+ *
511
+ * @module dsh-connect-modelscope-token-plan/llm-adapter
512
+ */
513
+ /**
514
+ * 为一份目录快照装配适配器实例。
515
+ *
516
+ * 每次重建换新实例是刻意的:`PiAiAdapter` 内部 memoize profiles 快照
517
+ * (`if (this.snapshot?.profiles === profiles)`),所以调用方在目录或钥匙变化时
518
+ * **替换**注册的适配器并 emit `llm/adapters-updated`,与 qoder 路由刷自己注册的
519
+ * 方式一致。
520
+ * @param {object} options - 装配参数。
521
+ * @param {object[]} options.entries - 归一目录条目。
522
+ * @param {string[]} [options.enabledIds] - 用户精选的允许清单;空 = 全部模型。
523
+ * @param {string} options.baseUrl - OpenAI 兼容源站。
524
+ * @param {() => Promise<string>} options.resolveApiKey - 每次请求解析活的 `ms-` 令牌。
525
+ * @param {(service: string) => unknown} [options.get] - 图片 hook(`attachments`、
526
+ * `fs`)的服务解析器。
527
+ * @param {string[]} [options.unavailableModelIds] - 额度耗尽的模型 id,从 offer 里
528
+ * 排除,避免发出注定 429 的请求。
529
+ * @param {UsageSinks} [options.usage] - 本地计数的写入口(usage-store 的两条
530
+ * 方法)。**这是面板「今日次数 / 模型分布 / 趋势 / 429 事件流」唯一的生产
531
+ * 者**——probe 那条写入路径已随目录表删除,缺了它四块面板恒为 0 与空。
532
+ * 缺省不套观察层(零开销,见 `usage-observer.ts`)。
533
+ * @returns {{adapter: object, providerIds: string[]}} 适配器与它拥有的 ids。
534
+ */
535
+ function createModelScopeAdapter({ entries, enabledIds = [], baseUrl, resolveApiKey, get, unavailableModelIds = [], usage }) {
536
+ const models = buildDescriptors(entries, {
537
+ providerId: LLM_PROVIDER_ID,
538
+ baseUrl,
539
+ enabledIds,
540
+ unavailableModelIds
541
+ });
542
+ const built = assemblePiAiAdapter({
543
+ providerId: LLM_PROVIDER_ID,
544
+ displayName: LLM_DISPLAY_NAME,
545
+ apiKeyName: LLM_API_KEY_NAME,
546
+ models,
547
+ resolveCredential: resolveApiKey,
548
+ ...get !== void 0 ? { get } : {}
549
+ });
550
+ return {
551
+ adapter: usage === void 0 ? built.adapter : withUsageObserver(built.adapter, usage),
552
+ providerIds: built.providerIds
553
+ };
554
+ }
555
+
556
+ //#endregion
557
+ export { createModelScopeAdapter };
package/locale/en.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "meta": {
3
+ "title": "ModelScope Quota Connect"
4
+ }
5
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "魔搭接入 · 额度面板",
4
+ "description": "魔搭(ModelScope)API-Inference 免费额度面板:官方魔粒余额、本地调用计数与趋势、限流事件流、免认证模型目录与零额度令牌校验,可选把魔搭注册成 DSH 的 OpenAI 兼容 provider(路线图)。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "dsh-connect-modelscope-token-plan",
3
+ "displayName": "DSH Connect ModelScope Token Plan",
4
+ "icon": "./icon.svg",
5
+ "version": "0.1.0",
6
+ "homepage": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan",
7
+ "bugs": {
8
+ "url": "https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan/issues"
9
+ },
10
+ "author": "eghrhegpe",
11
+ "description": "ModelScope (modelscope.cn) connect bundle for the Harness Web UI: a local-usage quota panel for the free API-Inference tier (daily call budget, per-model caps, 429 events, local trend) plus optional provider registration that brings ModelScope models into DSH conversation.",
12
+ "keywords": [
13
+ "dsh-plugin",
14
+ "deepseek-harness",
15
+ "modelscope",
16
+ "token-plan",
17
+ "quota",
18
+ "api-inference"
19
+ ],
20
+ "type": "module",
21
+ "main": "./lib/index.js",
22
+ "exports": {
23
+ ".": "./lib/index.js",
24
+ "./client": "./client.js",
25
+ "./locale/*.json": "./locale/*.json",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "client.js",
31
+ "icon.svg",
32
+ "locale",
33
+ "assets",
34
+ "cordis.patch.yml",
35
+ "README.md",
36
+ "CHANGELOG.md",
37
+ "LICENSE",
38
+ "screenshots.json"
39
+ ],
40
+ "scripts": {
41
+ "typecheck": "tsc -p tsconfig.json",
42
+ "build:client": "tsdown -c tsdown.config.mjs",
43
+ "duplicate-check": "jscpd src --min-tokens 70 --reporters console-full",
44
+ "test": "node test/wire.test.mjs && node test/config.test.mjs && node test/state-store.test.mjs && node test/usage-store.test.mjs && node test/routes.test.mjs && node test/provider.test.mjs && node test/inference-vision.test.mjs && node test/provider-routes.test.mjs && node test/provider-publish.test.mjs && node test/panel.test.mjs && node test/doctor.test.mjs && node test/catalog.test.mjs && node test/switch-precedence.test.mjs && node test/credentials.test.mjs && node test/codes.test.mjs && node test/retry.test.mjs && node test/error-fix.test.mjs && node test/usage-observer.test.mjs && node test/release.test.mjs && node test/build-gate.mjs",
45
+ "doctor": "node tools/doctor.mjs",
46
+ "doctor:json": "node tools/doctor.mjs --json",
47
+ "build": "tsdown -c tsdown.config.mjs",
48
+ "prepack": "npm run build"
49
+ },
50
+ "dsh": {
51
+ "bundle": {
52
+ "patch": "./cordis.patch.yml"
53
+ },
54
+ "client": {
55
+ "platform": "web",
56
+ "immediately": true,
57
+ "inject": [
58
+ "@deepseek-ai/dsh-client-locale",
59
+ "@deepseek-ai/dsh-client-ui-renderer"
60
+ ]
61
+ }
62
+ },
63
+ "peerDependencies": {
64
+ "@deepseek-ai/cordis": ">=4.0.2 <5.0.0",
65
+ "@deepseek-ai/dsh-client-locale": ">=0.1.5 <0.3",
66
+ "@deepseek-ai/dsh-client-ui-layout": ">=0.1.5 <0.3",
67
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.1.5 <0.3",
68
+ "@deepseek-ai/dsh-credentials": ">=0.1.5 <0.3",
69
+ "@deepseek-ai/dsh-home-paths": ">=0.1.5 <0.3",
70
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.5 <0.3",
71
+ "@deepseek-ai/dsh-llm": ">=0.1.5 <0.3",
72
+ "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.5 <0.3",
73
+ "@deepseek-ai/dsh-settings": ">=0.1.5 <0.3",
74
+ "@deepseek-ai/dsh-tools": ">=0.1.5 <0.3",
75
+ "@deepseek-ai/schemastery": "^3.18.2",
76
+ "@earendil-works/pi-ai": "^0.85.1"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "@deepseek-ai/cordis": { "optional": true },
80
+ "@deepseek-ai/dsh-client-locale": { "optional": true },
81
+ "@deepseek-ai/dsh-client-ui-layout": { "optional": true },
82
+ "@deepseek-ai/dsh-client-ui-renderer": { "optional": true },
83
+ "@deepseek-ai/dsh-credentials": { "optional": true },
84
+ "@deepseek-ai/dsh-home-paths": { "optional": true },
85
+ "@deepseek-ai/dsh-host-webserver": { "optional": true },
86
+ "@deepseek-ai/dsh-llm": { "optional": true },
87
+ "@deepseek-ai/dsh-llm-pi-ai": { "optional": true },
88
+ "@deepseek-ai/dsh-settings": { "optional": true },
89
+ "@deepseek-ai/dsh-tools": { "optional": true },
90
+ "@deepseek-ai/schemastery": { "optional": true },
91
+ "@earendil-works/pi-ai": { "optional": true }
92
+ },
93
+ "engines": {
94
+ "node": "^22.19.0 || >=24.0.0"
95
+ },
96
+ "repository": {
97
+ "type": "git",
98
+ "url": "git+https://github.com/eghrhegpe/dsh-connect-modelscope-token-plan.git"
99
+ },
100
+ "license": "MIT",
101
+ "devDependencies": {
102
+ "@types/node": "^26.6.3",
103
+ "jscpd": "^5.4.0",
104
+ "tsdown": "^0.23.0",
105
+ "typescript": "5.9.3"
106
+ }
107
+ }
@@ -0,0 +1,4 @@
1
+ [
2
+ "assets/tab-account-and-usage.png",
3
+ "assets/tab-model-list.png"
4
+ ]