@dmh2002/dsh-connect-qoder 0.0.0-stage → 0.4.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/lib/index.js ADDED
@@ -0,0 +1,3093 @@
1
+ import { _ as piAiRuntimeInfo, a as writeHostHeartbeat, b as QoderUpstreamError, c as writePoolProbes, h as patFromEnv, l as QODER_CONNECT_VERSION, m as maskPat, p as isQoderAccountId, s as readPoolProbes, t as clearHostHeartbeat, u as QoderAccountStore, x as isQoderUpstreamError, y as QoderUpstreamClient } from "./host-heartbeat-BNuj6Lt_.js";
2
+ import { _ as regionOfStatusUrl, a as QODER_MODELS_REFRESH_PATH, c as QODER_REGIONS, d as QODER_SETTINGS_NS, f as QODER_TRANSFER_PATH, g as poolActionOf, h as poolAccountIdOf, i as QODER_GITHUB_URL, m as accountActionOf, n as QODER_ACCOUNTS_REFRESH_PATH, o as QODER_POOL_PATH, p as QODER_USAGE_PATH, r as QODER_ACCOUNT_CREDITS_PATH, s as QODER_PROBE_PATH, t as QODER_ACCOUNTS_PATH, u as QODER_SAVE_PATH, v as transferActionOf, y as unwrapVolatileDeep } from "./status-paths-w2WDUPVS.js";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { createServer } from "node:http";
6
+ import { createProvider } from "@earendil-works/pi-ai";
7
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
+ //#region src/catalog.ts
11
+ const QODER_FALLBACK_MODELS = [
12
+ {
13
+ key: "qmodel_latest",
14
+ displayName: "Qwen3.7-Max",
15
+ isVl: true,
16
+ isReasoning: true,
17
+ maxInputTokens: 2e5
18
+ },
19
+ {
20
+ key: "qmodel_preview",
21
+ displayName: "Qwen3.8-Max-Preview",
22
+ isVl: true,
23
+ isReasoning: true,
24
+ maxInputTokens: 2e5
25
+ },
26
+ {
27
+ key: "qmodel",
28
+ displayName: "Qwen3.7-Plus",
29
+ isVl: true,
30
+ isReasoning: true,
31
+ maxInputTokens: 2e5
32
+ },
33
+ {
34
+ key: "q36fmodel",
35
+ displayName: "Qwen3.6-Flash",
36
+ isVl: true,
37
+ isReasoning: true,
38
+ maxInputTokens: 2e5
39
+ },
40
+ {
41
+ key: "dmodel",
42
+ displayName: "DeepSeek-V4-Pro",
43
+ isVl: true,
44
+ isReasoning: true,
45
+ maxInputTokens: 2e5
46
+ },
47
+ {
48
+ key: "dfmodel",
49
+ displayName: "DeepSeek-V4-Flash",
50
+ isVl: true,
51
+ isReasoning: true,
52
+ maxInputTokens: 2e5
53
+ },
54
+ {
55
+ key: "gm51model",
56
+ displayName: "GLM-5.2",
57
+ isVl: true,
58
+ isReasoning: true,
59
+ maxInputTokens: 2e5
60
+ },
61
+ {
62
+ key: "kmodel",
63
+ displayName: "Kimi-K2.7-Code",
64
+ isVl: true,
65
+ isReasoning: true,
66
+ maxInputTokens: 256e3
67
+ },
68
+ {
69
+ key: "mmodel",
70
+ displayName: "MiniMax-M2.7",
71
+ isVl: false,
72
+ isReasoning: true,
73
+ maxInputTokens: 2e5
74
+ }
75
+ ];
76
+ /** 从 context_config 档位表提取升序 token 档位(无档位返回 undefined)。 */
77
+ function contextTiersOf(raw) {
78
+ const cc = raw.context_config;
79
+ if (!cc) return void 0;
80
+ const counts = Object.values(cc).map((v) => v.token_count).filter(Number.isFinite);
81
+ if (counts.length === 0) return void 0;
82
+ return [...new Set(counts)].sort((a, b) => a - b);
83
+ }
84
+ /** 强度语义排序用的序表(`max` 与 `xhigh` 同属"最强"档,都排最后)。 */
85
+ const EFFORT_ORDER = [
86
+ "minimal",
87
+ "low",
88
+ "medium",
89
+ "high",
90
+ "xhigh",
91
+ "max"
92
+ ];
93
+ /**
94
+ * 从 `thinking_config` 提取可选档位。
95
+ *
96
+ * 实测形状(qmodel_38max):
97
+ * `{ disabled: {}, enabled: { is_default: true,
98
+ * efforts: { xhigh: {}, low: {}, medium: { is_default: true } } } }`
99
+ *
100
+ * 两个要点:
101
+ * - **键序不可信**:上游给的顺序是 `xhigh, low, medium`,直接显示会让用户
102
+ * 以为 xhigh 是最弱档。按语义序表排。
103
+ * - **不是每个模型都有档位**:`auto`、`qmodel_latest` 等 `efforts` 缺失,
104
+ * 此时返回空对象(字段缺席),卡片据此不渲染这一项——而不是渲染一个空标签。
105
+ */
106
+ function thinkingOf(config) {
107
+ const enabled = config?.["enabled"];
108
+ if (enabled === null || enabled === void 0 || typeof enabled !== "object") return {};
109
+ const efforts = enabled["efforts"];
110
+ if (efforts === null || typeof efforts !== "object" || Array.isArray(efforts)) return {};
111
+ const names = Object.keys(efforts);
112
+ if (names.length === 0) return {};
113
+ const sorted = [...names].sort((a, b) => {
114
+ const ia = EFFORT_ORDER.indexOf(a);
115
+ const ib = EFFORT_ORDER.indexOf(b);
116
+ if (ia === -1 && ib === -1) return a.localeCompare(b);
117
+ if (ia === -1) return 1;
118
+ if (ib === -1) return -1;
119
+ return ia - ib;
120
+ });
121
+ const declared = sorted.find((name) => {
122
+ const entry = efforts[name];
123
+ return entry !== null && typeof entry === "object" && entry["is_default"] === true;
124
+ });
125
+ return {
126
+ thinkingEfforts: sorted,
127
+ ...declared === void 0 ? {} : { defaultThinkingEffort: declared }
128
+ };
129
+ }
130
+ /** 把上游 snake_case 条目解析成 DSH 侧模型信息。 */
131
+ function parseUpstreamModel(raw) {
132
+ const contextWindow = pickDefaultContextWindow(raw) ?? raw.max_input_tokens ?? 2e5;
133
+ const canDisable = raw.thinking_config?.disabled !== void 0;
134
+ const reasoning = thinkingOf(raw.thinking_config);
135
+ return {
136
+ key: raw.key,
137
+ displayName: raw.display_name,
138
+ priceFactor: raw.price_factor ?? 1,
139
+ contextWindow,
140
+ maxOutputTokens: raw.max_output_tokens ?? 32768,
141
+ supportsImages: raw.is_vl,
142
+ supportsThinking: raw.is_reasoning,
143
+ canDisableThinking: canDisable || raw.is_reasoning === false,
144
+ ...reasoning,
145
+ isDefault: raw.is_default,
146
+ isFree: raw.is_free === true,
147
+ isNew: false,
148
+ ...tiersField(contextTiersOf(raw))
149
+ };
150
+ }
151
+ /** 从 context_config 档位表选默认档(is_default=true,否则取 token_count 最小者)。 */
152
+ function pickDefaultContextWindow(raw) {
153
+ const cc = raw.context_config;
154
+ if (!cc) return void 0;
155
+ const entries = Object.entries(cc);
156
+ if (entries.length === 0) return void 0;
157
+ const def = entries.find(([, v]) => v.is_default === true);
158
+ if (def) return def[1].token_count;
159
+ return Math.min(...entries.map(([, v]) => v.token_count));
160
+ }
161
+ /** `contextTiers` 的可选字段投影(undefined 时略去字段,避免写入 undefined)。 */
162
+ function tiersField(tiers) {
163
+ return tiers === void 0 || tiers.length <= 1 ? {} : { contextTiers: tiers };
164
+ }
165
+ /** 把 fallback 条目解析成模型信息。 */
166
+ function fallbackModelInfo(m) {
167
+ return {
168
+ key: m.key,
169
+ displayName: m.displayName,
170
+ priceFactor: m.priceFactor ?? 1,
171
+ contextWindow: m.maxInputTokens ?? 2e5,
172
+ maxOutputTokens: m.maxOutputTokens ?? 32768,
173
+ supportsImages: m.isVl,
174
+ supportsThinking: m.isReasoning,
175
+ canDisableThinking: true,
176
+ isDefault: false,
177
+ isFree: false,
178
+ isNew: false
179
+ };
180
+ }
181
+ /**
182
+ * 应用用户保存的每模型上下文档位。
183
+ *
184
+ * Qoder 的窗口是**档位选择**而非任意上限:一模型说得出几个 `context_config`
185
+ * 档,用户保存的预算就只能在档位里挑(选了不在档位里的值没有效果,保持原生)。
186
+ * 这样卡片渲染的 `<select>` 选项与运行期生效的窗口永远一致 —— 不会出现存档里
187
+ * 存了 200K 而该模型根本没这个档的时候,运行期悄悄用别的东西。
188
+ *
189
+ * `contextWindow` 不降反升的情形只可能发生在档位表的**原生档**高于默认窗口时
190
+ * (例如默认档 96K、另有一档 400K),此时按档位选择是诚实的用户决定。
191
+ */
192
+ function applyContextBudgets(catalog, budgets = {}) {
193
+ return catalog.map((model) => {
194
+ const chosen = budgets[model.key];
195
+ if (model.contextTiers === void 0 || chosen === void 0) return model;
196
+ return model.contextTiers.includes(chosen) ? {
197
+ ...model,
198
+ contextWindow: chosen
199
+ } : model;
200
+ });
201
+ }
202
+ /**
203
+ * 模型显示名:内嵌积分倍率(与 Qoder 自身菜单格式一致——倍率为 1 也照写,
204
+ * `Qwen3.7-Max · x1.00`)。workbuddy 的选择器同名规则同样不省略 1。
205
+ */
206
+ function modelDisplayName(info) {
207
+ return `${info.displayName} · x${info.priceFactor.toFixed(2)}`;
208
+ }
209
+ /**
210
+ * 目录派生:目录 ∩ 勾选(workbuddy 的 `deriveCatalog` 同规)。
211
+ *
212
+ * **注册给 DSH 的模型 = 用户勾选的那些**。此前实现是「一律全部保留」,于是
213
+ * 无论用户在卡片里勾了几个,DSH 的模型选择器里都躺着整份上游目录(用户实拍:
214
+ * 「保存的模型,还是那么多」)——勾选形同虚设。
215
+ *
216
+ * `enabled` 为空是特例:**全不勾不等于全不选**,而是「还没选过」——此时把整份
217
+ * 目录交出去,否则新用户的选择器会是空的。这与 workbuddy 的
218
+ * `enabled.size === 0 ? catalog : catalog.filter(...)` 逐字同义。
219
+ *
220
+ * 卡片自己的模型列表走的是另一条路(`catalog.current()`,始终是完整目录),
221
+ * 所以「未勾选的也要看得见、才勾得上」不受影响。
222
+ */
223
+ function deriveCatalog(models, enabledKeys) {
224
+ return enabledKeys.size === 0 ? models : models.filter((model) => enabledKeys.has(model.key));
225
+ }
226
+ /** 初始勾选集合:目录中的默认模型 + 免费模型;其余不勾。 */
227
+ function initialEnabledKeys(models) {
228
+ return new Set(models.filter((m) => m.isDefault || m.isFree).map((m) => m.key));
229
+ }
230
+ /**
231
+ * 一区域的当前目录。启动时以静态 fallback 填充(离线也能立刻有 provider),
232
+ * 上游目录拉取成功后整表替换;空结果不覆盖已有表。
233
+ */
234
+ var QoderCatalog = class {
235
+ models;
236
+ constructor(initial) {
237
+ this.models = initial;
238
+ }
239
+ current() {
240
+ return this.models;
241
+ }
242
+ set(models) {
243
+ if (models.length > 0) this.models = models;
244
+ }
245
+ /** 按 key 或显示名查找模型。 */
246
+ find(selector) {
247
+ return this.models.find((m) => m.key === selector || m.displayName === selector);
248
+ }
249
+ /** key 解析:优先内部 key,其次显示名;未知返回 undefined。 */
250
+ keyOf(selector) {
251
+ return this.find(selector)?.key;
252
+ }
253
+ supportsImagesOf(selector) {
254
+ return this.find(selector)?.supportsImages ?? false;
255
+ }
256
+ isReasoningOf(selector) {
257
+ return this.find(selector)?.supportsThinking ?? false;
258
+ }
259
+ };
260
+ //#endregion
261
+ //#region src/off-thinking.ts
262
+ /**
263
+ * 已观察到**拒绝** `reasoning_effort: "off"`(HTTP 400)的模型 id,
264
+ * 即使它们声明了「可关闭思考」。
265
+ *
266
+ * 按精确 model id 键控,且刻意**不**按区域分:拒绝与否跟模型家族走,不跟网关走。
267
+ * 代价是不对称的——提供一个必然失败的档位,代价是用户该模型的**每一次**请求;
268
+ * 而藏起一个本可用的档位,代价只是少一个选项——所以本表宁可多藏。
269
+ * 知道得更清楚的用户可以显式覆盖(见 {@link effectiveOff})。
270
+ *
271
+ * 现状为空:本项目尚未在 Qoder 网关上实测到拒绝样本。留空即「只信上游声明」,
272
+ * 这与本插件 0.1.0 的行为一致,属于**不倒退**的默认;一旦有实测样本,按上面的
273
+ * 键控规则补条目即可。
274
+ */
275
+ const REFUSES_OFF_MODEL_IDS = /* @__PURE__ */ new Set([]);
276
+ /**
277
+ * 在用户没有表态时,是否应当为某模型提供 `off` 档:上游声明减去已知拒绝的 id。
278
+ *
279
+ * 只有上游**显式**声明可关闭(`canDisableThinking === true`)才开启该档;
280
+ * 缺失视为「未知」,保持不提供——与本插件对其它未声明能力的一贯处理一致。
281
+ */
282
+ function offDefaultFor(info) {
283
+ return info.canDisableThinking === true && !REFUSES_OFF_MODEL_IDS.has(info.key);
284
+ }
285
+ /**
286
+ * 默认应当提供 `off` 档的模型 id 集合。
287
+ *
288
+ * 用户的勾选框是一个普通集合:勾上=提供,取消=撤回。这里是**种子**——集合
289
+ * 第一次被读取时填入的内容——让从未保存过的 profile 立刻显示正确默认值,
290
+ * 而不必先保存一次。
291
+ */
292
+ function defaultOffModelIds(models) {
293
+ return models.filter(offDefaultFor).map((model) => model.key);
294
+ }
295
+ /**
296
+ * 把用户保存的 `off` 选择**盖印**到模型列表上,供**运行时**目录使用。
297
+ *
298
+ * 运行时描述符由适配器的 `qoderThinkingLevelMap` 构建,它只读模型自报的声明;
299
+ * 因此校正后的答案必须在模型抵达适配器之前写到模型上。放在这里可以让适配器
300
+ * 保持「声明进、档位表出」的纯函数,也让这张表紧挨着它所服务的规则。
301
+ *
302
+ * 这里**覆盖**声明字段而不是新增兄弟字段:该声明只有这一个消费者(同一张档位
303
+ * 表),且从不被持久化到卡片能看到的地方(`toWebModel` 会丢掉
304
+ * `canDisableThinking`,卡片的 `lastCatalog` 来自上游原始发现,而非这份盖印
305
+ * 结果)。未声明推理的模型原样返回——没有档位需要校正。
306
+ */
307
+ function withEffectiveOff(models, selection) {
308
+ return models.map((model) => {
309
+ if (model.supportsThinking !== true) return model;
310
+ return {
311
+ ...model,
312
+ canDisableThinking: selection.has(model.key)
313
+ };
314
+ });
315
+ }
316
+ /** 便捷投影:把目录条目转成本模块需要的两个字段。 */
317
+ function offCapabilityOf(info) {
318
+ return {
319
+ key: info.key,
320
+ supportsThinking: info.supportsThinking,
321
+ canDisableThinking: info.canDisableThinking
322
+ };
323
+ }
324
+ //#endregion
325
+ //#region src/native-modality.ts
326
+ /**
327
+ * 已核对的厂商分类,按**精确 model id**(绝不用前缀匹配)键控。
328
+ * 不在本表中的 id 一律 `unknown` —— 见 {@link nativeModalityOf}。
329
+ *
330
+ * 现状:仅登记本插件内置目录里那些「模型名即厂商模型名」且模态可从名字
331
+ * 判定的条目;Qoder 内部的 `q*model` / `d*model` 短 id 无公开文档可依,
332
+ * 一律保持 unknown,宁可不勾选也不猜。
333
+ */
334
+ const NATIVE_MODALITY_BY_MODEL_ID = {
335
+ "kimi-k2.7-code": "text",
336
+ "qwen3.7-max": "multimodal",
337
+ "qwen3.8-max-preview": "multimodal",
338
+ "qwen3.7-plus": "multimodal",
339
+ "qwen3.6-flash": "multimodal"
340
+ };
341
+ /**
342
+ * 一次模型刷新是否应当**预勾选**该模型的图片开关。
343
+ *
344
+ * 只有文档确认的 `multimodal` 会被预勾选。`text` 已写明不能读图,而
345
+ * `unknown` 是刻意留空而非猜测——这张表存在的全部理由就是平台标记猜错过。
346
+ * 平台的 `supportsImages: false` 仍然拥有一票否决权,所以一个文档上
347
+ * multimodal 的 id 也不会在平台明确说「不支持」时被预勾选。
348
+ *
349
+ * 用户始终可以手动勾选;这里只决定**默认值**,运行时生效的仍是用户保存的
350
+ * `imageModelKeys` 之类的持久选择。
351
+ */
352
+ function imageDefaultFor(info) {
353
+ if (info.supportsImages === false) return false;
354
+ return NATIVE_MODALITY_BY_MODEL_ID[normalizeModelId(info.id)] === "multimodal";
355
+ }
356
+ /**
357
+ * 把 Qoder 的内部短 id(`qmodel_latest` 等)或显示名归一到本表键。
358
+ *
359
+ * 内置目录里 `key` 是短 id、`displayName` 是厂商模型名,两处都可能被当成
360
+ * 「模型 id」传进来;先按原样查表,查不到再用显示名的小写连字符形式兜底。
361
+ * 仍然查不到就是 unknown——兜底只是多给一次**精确**匹配机会,绝不做模糊匹配。
362
+ */
363
+ function normalizeModelId(modelId) {
364
+ return modelId.trim().toLowerCase();
365
+ }
366
+ //#endregion
367
+ //#region src/shim.ts
368
+ /**
369
+ * Qoder 回环 shim:进程内 HTTP 服务(127.0.0.1 随机端口)。
370
+ *
371
+ * 安全模型与 dsh-connect-workbuddy 一致(参考其 `src/shim.ts`):
372
+ * - 随机端口 + 进程内 32 字节随机 secret(pi-ai 只拿到 secret 当 apiKey,
373
+ * 真实的 Qoder 凭据绝不交给 pi-ai);
374
+ * - Host / Origin / Content-Type 三重入站校验(必须在路由判定之前),
375
+ * 请求体有 64MB 上限;
376
+ * - 常量时间比对 Authorization。
377
+ *
378
+ * 职责:把 pi-ai 的 OpenAI 聊天请求翻译成 Qoder 网关请求,并把 Qoder 的
379
+ * OpenAI 形状 SSE 原样转发为 OpenAI chunks;错误分类映射为 DSH 可判定的
380
+ * HTTP 状态 + error.code。
381
+ *
382
+ * 首字节之前的失败一律回 **非 200 的 JSON 错误**(不是 SSE 错误帧):pi-ai
383
+ * 的 openai-completions 客户端只认非 2xx 响应体的 `error.message/code`,
384
+ * 对 200 流里的 `error` 字段完全不读(chunk 循环只取 `choices[0]`,
385
+ * `finish_reason:"error"` 会被塌缩成一句无信息的 "Provider finish_reason:
386
+ * error")。只有 JSON 错误能把真实的 kind/code 送进宿主的分类器。
387
+ *
388
+ * 账号池故障转移(参考 dsh-connect-workbuddy 的 `src/shim.ts` 相同模式):
389
+ * - 每次请求先 `resolveServing()` 解析**本次该买单的账号**(账号池排序结果或
390
+ * 用户显式选择;缺省落到 `credential()` 的默认解析);
391
+ * - 首块字节写出前若上游硬拒(auth / 限流 / 额度 / 瞬时),记录该账号的失败
392
+ * (`onServingFailure` → live-request 写回账号池),间隔
393
+ * `failoverGapMs`(默认 2s,可中断)后再 `failoverAccount()` 取下一个候选
394
+ * 重试 —— **只在未写任何 SSE 前重试**,一旦首个 delta 已转发,之后的错误
395
+ * 原样进流(不回放、不换号);策略层若重复返回已试过的账号,shim 侧也刹车;
396
+ * - 客户端中途断开(响应流 close)会中止上游请求,且**不**为该次失败记测量
397
+ * (那是断开,不是账号的错);
398
+ * - 被账号池接管时,首个内容帧以引用块前缀说明「本次由账号池账号 X 服务」
399
+ * (必须走 `choices[0].delta.content`——空 choices 的自定义帧会被 pi-ai
400
+ * 直接跳过,用户将完全看不到换号)。
401
+ *
402
+ * @module dsh-connect-qoder/shim
403
+ */
404
+ const CONTENT_TYPE_JSON = "application/json";
405
+ /** 请求体上限(workbuddy 同值):长对话体可以很大,但必须有界。 */
406
+ const REQUEST_BODY_LIMIT = 67108864;
407
+ /**
408
+ * 换号重试前的间隔(workbuddy 同值)。
409
+ *
410
+ * 上游限流按请求体积触发:零间隔连环重试会让每个候选账号都撞上同一堵墙
411
+ * (4 个账号一秒内全部失败)。批量测试已在账号间等 400ms
412
+ * (`POOL_BATCH_GAP_MS`);故障转移重试是用户正在等的真实请求,给上游窗口
413
+ * 更多喘息空间。等待是可中断的:客户端断开就立刻停,而不是睡满全程。
414
+ */
415
+ const FAILOVER_ACCOUNT_GAP_MS = 2e3;
416
+ /**
417
+ * 浏览器 Origin(存在时)必须指向回环。非浏览器客户端(插件自己的 fetch、
418
+ * pi-ai)根本不发 Origin,直接放行;DNS-rebinding 页面的 Host 可以伪造,
419
+ * 但 Origin 会暴露它的真实域名。
420
+ */
421
+ function originIsLoopback(origin) {
422
+ if (origin === void 0 || origin.trim() === "") return true;
423
+ try {
424
+ const { hostname } = new URL(origin);
425
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
426
+ } catch {
427
+ return false;
428
+ }
429
+ }
430
+ /**
431
+ * 写给宿主的 `code` 翻译表。
432
+ *
433
+ * 宿主(`@deepseek-ai/dsh-llm` + llm-pi-ai)从失败文案里读英文短语来决定
434
+ * 「原地重试(默认 5 次指数退避)」还是「跨供应商故障转移」:`RATE_LIMIT` /
435
+ * `SERVER` / `TIMEOUT` / `TRANSPORT` / `EMPTY_RESPONSE` 在原地重试集里。
436
+ *
437
+ * `rate-limited` 是那个被字面翻译害惨的 kind:上游 429 的冷却常常以小时计
438
+ * (探针从响应体里解析恢复时间),若按 `rate_limit_exceeded` 送达,宿主会把
439
+ * 整个重试预算烧在一个不可能在退避窗口内恢复的端点上。翻成 `quota_exceeded`
440
+ * 是宿主认得的词,也符合上游「此刻不可用」的实际语义;HTTP 状态保持 429
441
+ * 不动(那是上游真实返回的),上游自己的消息仍原文透传给人读。
442
+ */
443
+ const KIND_HOST_CODE = { "rate-limited": "quota_exceeded" };
444
+ /** 首字节前失败写给宿主的 code(宿主按它分类);流内错误帧仍用原 code。 */
445
+ function hostErrorCode(kind) {
446
+ return KIND_HOST_CODE[kind] ?? ERROR_MAP[kind].code;
447
+ }
448
+ /** HTTP 状态 + error.code 双映射表(DSH 依据 code 判定原地重试 vs failover)。 */
449
+ const ERROR_MAP = {
450
+ network: {
451
+ status: 502,
452
+ code: "connection_error"
453
+ },
454
+ auth: {
455
+ status: 401,
456
+ code: "invalid_api_key"
457
+ },
458
+ "rate-limited": {
459
+ status: 429,
460
+ code: "rate_limit_exceeded"
461
+ },
462
+ "invalid-request": {
463
+ status: 400,
464
+ code: "invalid_request_error"
465
+ },
466
+ "content-policy": {
467
+ status: 400,
468
+ code: "content_policy_rejected"
469
+ },
470
+ "quota-exhausted": {
471
+ status: 429,
472
+ code: "quota_exceeded"
473
+ },
474
+ transient: {
475
+ status: 502,
476
+ code: "upstream_error"
477
+ },
478
+ unknown: {
479
+ status: 500,
480
+ code: "upstream_error"
481
+ }
482
+ };
483
+ /**
484
+ * Round about which upstream failures are worth spending a failover attempt
485
+ * on. A request never starts, wait / fix input / the model is gone, a
486
+ * content-policy refusal is about THIS message (the next account would be
487
+ * refused the same way), and a mid-stream error has already committed bytes.
488
+ * Only a failure where the serving account was the problem and nothing has
489
+ * been written yet deserves to move to the next account.
490
+ */
491
+ function isFailoverEligible(kind) {
492
+ return kind === "auth" || kind === "rate-limited" || kind === "quota-exhausted" || kind === "network" || kind === "transient";
493
+ }
494
+ /** 可中断的等待:signal 中止时立即结束,而不是睡满全程。 */
495
+ function sleepAbortable(ms, signal) {
496
+ return new Promise((resolve) => {
497
+ if (ms <= 0 || signal.aborted) return resolve();
498
+ const timer = setTimeout(resolve, ms);
499
+ signal.addEventListener("abort", () => {
500
+ clearTimeout(timer);
501
+ resolve();
502
+ }, { once: true });
503
+ });
504
+ }
505
+ function createQoderShim(options) {
506
+ const { upstream } = options;
507
+ const logger = options.logger ?? (() => {});
508
+ const secret = randomBytes(32).toString("base64url");
509
+ const secretBuf = Buffer.from(secret);
510
+ let baseUrlValue = "";
511
+ let portValue = 0;
512
+ let closeFn = async () => {};
513
+ const ready = new Promise((resolveReady, rejectReady) => {
514
+ const server = createServer((req, res) => {
515
+ handleRequest(req, res).catch((error) => {
516
+ const status = isQoderUpstreamError(error) ? ERROR_MAP[error.kind].status : 500;
517
+ const code = isQoderUpstreamError(error) ? hostErrorCode(error.kind) : "internal_error";
518
+ writeJson(res, status, { error: {
519
+ message: String(error.message ?? "internal error"),
520
+ code,
521
+ type: "qoder_error"
522
+ } });
523
+ });
524
+ });
525
+ server.on("error", (error) => {
526
+ rejectReady(error);
527
+ });
528
+ server.listen(0, "127.0.0.1", () => {
529
+ const addr = server.address();
530
+ baseUrlValue = `http://127.0.0.1:${addr.port}`;
531
+ portValue = addr.port;
532
+ resolveReady();
533
+ });
534
+ closeFn = () => new Promise((resolve, reject) => {
535
+ server.close((error) => error ? reject(error) : resolve());
536
+ });
537
+ });
538
+ async function handleRequest(req, res) {
539
+ if (!checkHost(req)) {
540
+ writeJson(res, 403, { error: {
541
+ message: "bad host",
542
+ code: "host_not_allowed"
543
+ } });
544
+ return;
545
+ }
546
+ if (!originIsLoopback(req.headers.origin)) {
547
+ writeJson(res, 403, { error: {
548
+ message: "Origin must be a loopback origin",
549
+ code: "origin_not_allowed"
550
+ } });
551
+ return;
552
+ }
553
+ if (!checkAuthorization(req, secretBuf)) {
554
+ writeJson(res, 401, { error: {
555
+ message: "invalid api key",
556
+ code: "invalid_api_key"
557
+ } });
558
+ return;
559
+ }
560
+ if (!checkContentType(req)) {
561
+ writeJson(res, 415, { error: {
562
+ message: "unsupported media type",
563
+ code: "invalid_request_error"
564
+ } });
565
+ return;
566
+ }
567
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
568
+ if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/healthz/")) {
569
+ writeJson(res, 200, { ok: true });
570
+ return;
571
+ }
572
+ if (req.method === "GET" && url.pathname === "/v1/models") {
573
+ await handleModels(res);
574
+ return;
575
+ }
576
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
577
+ await handleChat(req, res);
578
+ return;
579
+ }
580
+ writeJson(res, 404, { error: {
581
+ message: "not found",
582
+ code: "invalid_request_error"
583
+ } });
584
+ }
585
+ function checkHost(req) {
586
+ const host = req.headers.host ?? "";
587
+ return /^127\.0\.0\.1:\d+$/.test(host);
588
+ }
589
+ function checkAuthorization(req, expected) {
590
+ const header = req.headers.authorization;
591
+ if (typeof header !== "string") return false;
592
+ const match = /^Bearer (.+)$/.exec(header);
593
+ if (!match) return false;
594
+ const actual = Buffer.from(match[1] ?? "");
595
+ if (actual.length !== expected.length) return false;
596
+ return timingSafeEqual(actual, expected);
597
+ }
598
+ function checkContentType(req) {
599
+ if (req.method === "GET") return true;
600
+ return (req.headers["content-type"] ?? "").startsWith(CONTENT_TYPE_JSON);
601
+ }
602
+ function resolveFallbackCredential() {
603
+ const pat = options.credential();
604
+ if (!pat) throw new QoderUpstreamError("auth", "未配置 Qoder PAT:请在插件设置里填写,或设置环境变量", 401);
605
+ return pat;
606
+ }
607
+ async function handleModels(res) {
608
+ writeJson(res, 200, {
609
+ object: "list",
610
+ data: listModelIds().map((id) => ({
611
+ id,
612
+ object: "model",
613
+ owned_by: "qoder"
614
+ }))
615
+ });
616
+ }
617
+ function listModelIds() {
618
+ return [];
619
+ }
620
+ async function handleChat(req, res) {
621
+ let raw;
622
+ try {
623
+ raw = await readBody(req, options.requestBodyLimit ?? REQUEST_BODY_LIMIT);
624
+ } catch (error) {
625
+ if (error.message === "request body too large") {
626
+ writeJson(res, 413, { error: {
627
+ message: "request body too large",
628
+ code: "invalid_request_error"
629
+ } });
630
+ return;
631
+ }
632
+ throw error;
633
+ }
634
+ let body;
635
+ try {
636
+ body = JSON.parse(raw);
637
+ } catch {
638
+ writeJson(res, 400, { error: {
639
+ message: "invalid JSON body",
640
+ code: "invalid_request_error"
641
+ } });
642
+ return;
643
+ }
644
+ const model = typeof body.model === "string" ? body.model : "";
645
+ const messages = Array.isArray(body.messages) ? body.messages : [];
646
+ const stream = body.stream !== false;
647
+ if (messages.length === 0) {
648
+ writeJson(res, 400, { error: {
649
+ message: "messages is required",
650
+ code: "invalid_request_error"
651
+ } });
652
+ return;
653
+ }
654
+ const key = options.modelKeyOf ? options.modelKeyOf(model) ?? model : model;
655
+ const supportsImages = options.supportsImagesOf ? options.supportsImagesOf(model) : false;
656
+ const savedEffort = options.reasoningEffortOf ? options.reasoningEffortOf(model) : void 0;
657
+ if (messages.some((m) => Array.isArray(m.content) && m.content.some((p) => p?.type === "image_url")) && !supportsImages) {
658
+ writeJson(res, 400, { error: {
659
+ message: `模型 ${model} 不支持图像输入`,
660
+ code: "invalid_request_error"
661
+ } });
662
+ return;
663
+ }
664
+ if (!stream) {
665
+ writeJson(res, 400, { error: {
666
+ message: "only streaming is supported",
667
+ code: "invalid_request_error"
668
+ } });
669
+ return;
670
+ }
671
+ const tools = body.tool_choice === "none" || !Array.isArray(body.tools) ? void 0 : body.tools;
672
+ const requestedOff = body.reasoning_effort === "off";
673
+ const isReasoning = savedEffort !== void 0 && !requestedOff ? true : isReasoningRequested(body);
674
+ const reasoningEffort = requestedOff ? void 0 : savedEffort;
675
+ const controller = new AbortController();
676
+ res.on("close", () => controller.abort());
677
+ if (res.destroyed) controller.abort();
678
+ let serving;
679
+ try {
680
+ serving = await (options.resolveServing ? options.resolveServing() : Promise.resolve(void 0));
681
+ if (serving === void 0) serving = {
682
+ accountId: "default",
683
+ accountName: "",
684
+ pat: resolveFallbackCredential()
685
+ };
686
+ } catch (error) {
687
+ const mapped = isQoderUpstreamError(error) ? ERROR_MAP[error.kind] : {
688
+ status: 500,
689
+ code: "upstream_error"
690
+ };
691
+ writeJson(res, mapped.status, { error: {
692
+ message: error.message,
693
+ code: mapped.code,
694
+ type: "qoder_error"
695
+ } });
696
+ return;
697
+ }
698
+ const requestId = `chatcmpl-${randomBytes(12).toString("hex")}`;
699
+ const created = Math.floor(Date.now() / 1e3);
700
+ const tried = [];
701
+ const gapMs = options.failoverGapMs ?? FAILOVER_ACCOUNT_GAP_MS;
702
+ for (;;) {
703
+ let wroteAny = false;
704
+ let hasToolCalls = false;
705
+ let inputTokens = 0;
706
+ let outputTokens = 0;
707
+ let notice = options.poolServingNotice ? options.poolServingNotice(serving.accountId) : void 0;
708
+ try {
709
+ await upstream.chatStream(serving.pat, key, messages, {
710
+ ...tools === void 0 ? {} : { tools },
711
+ isReasoning,
712
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort },
713
+ isVl: supportsImages,
714
+ signal: controller.signal,
715
+ onDelta: (delta) => {
716
+ if (!wroteAny) {
717
+ res.writeHead(200, {
718
+ "Content-Type": "text/event-stream",
719
+ "Cache-Control": "no-cache",
720
+ "Connection": "keep-alive",
721
+ "X-Accel-Buffering": "no"
722
+ });
723
+ if (notice !== void 0) {
724
+ writeNoticeChunk(res, requestId, created, model, notice);
725
+ notice = void 0;
726
+ }
727
+ wroteAny = true;
728
+ }
729
+ if (delta.inputTokens > 0) inputTokens = delta.inputTokens;
730
+ if (delta.outputTokens > 0) outputTokens = delta.outputTokens;
731
+ if (delta.toolCalls !== void 0) hasToolCalls = true;
732
+ writeDeltaChunk(res, requestId, created, model, delta);
733
+ }
734
+ });
735
+ if (res.destroyed) return;
736
+ const done = makeChunk(requestId, created, model);
737
+ done.choices[0].finish_reason = hasToolCalls ? "tool_calls" : "stop";
738
+ if (inputTokens > 0 || outputTokens > 0) done.usage = {
739
+ prompt_tokens: inputTokens,
740
+ completion_tokens: outputTokens,
741
+ total_tokens: inputTokens + outputTokens
742
+ };
743
+ res.write(`data: ${JSON.stringify(done)}\n\n`);
744
+ res.write("data: [DONE]\n\n");
745
+ res.end();
746
+ return;
747
+ } catch (error) {
748
+ if (wroteAny) {
749
+ writeErrorChunk(res, requestId, created, model, error);
750
+ return;
751
+ }
752
+ if (isQoderUpstreamError(error) && isFailoverEligible(error.kind) && !controller.signal.aborted) {
753
+ options.onServingFailure?.(serving.accountId, error.kind, error.message);
754
+ tried.push(serving.accountId);
755
+ let next;
756
+ try {
757
+ next = options.failoverAccount ? await options.failoverAccount(tried) : void 0;
758
+ } catch {
759
+ next = void 0;
760
+ }
761
+ if (next !== void 0 && !tried.includes(next.accountId)) {
762
+ logger(`shim failover: ${serving.accountId} → ${next.accountId} (${error.kind})`);
763
+ serving = next;
764
+ await sleepAbortable(gapMs, controller.signal);
765
+ if (controller.signal.aborted) return;
766
+ continue;
767
+ }
768
+ }
769
+ if (isQoderUpstreamError(error)) {
770
+ const note = tried.length > 1 ? ` (after trying ${tried.length} accounts)` : "";
771
+ const mapped = ERROR_MAP[error.kind];
772
+ writeJson(res, mapped.status, { error: {
773
+ message: `qoder upstream ${error.kind} (http ${error.status})${note}: ${error.message.slice(0, 400)}`,
774
+ code: hostErrorCode(error.kind),
775
+ type: "qoder_error"
776
+ } });
777
+ return;
778
+ }
779
+ throw error;
780
+ }
781
+ }
782
+ }
783
+ /** 从请求体决定本次的 `is_reasoning`(见在线路上的注释)。 */
784
+ function isReasoningRequested(body) {
785
+ const effort = typeof body.reasoning_effort === "string" ? body.reasoning_effort : void 0;
786
+ if (effort === "off") return false;
787
+ if (effort !== void 0 && effort !== "") return true;
788
+ return body.stream_options?.include_reasoning === true;
789
+ }
790
+ /** 池接管的通知帧:一段以引用块前缀开头的**内容**帧(pi-ai 可见)。 */
791
+ function writeNoticeChunk(res, id, created, model, notice) {
792
+ const chunk = makeChunk(id, created, model);
793
+ chunk.choices[0].delta.content = `\n\n> [dsh-connect-qoder] ${notice}\n\n`;
794
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
795
+ }
796
+ function writeDeltaChunk(res, id, created, model, delta) {
797
+ const chunk = makeChunk(id, created, model);
798
+ const choice = chunk.choices[0];
799
+ if (delta.role !== "") choice.delta.role = delta.role;
800
+ if (delta.content !== "") choice.delta.content = delta.content;
801
+ if (delta.reasoningContent !== "") choice.delta.reasoning_content = delta.reasoningContent;
802
+ if (delta.toolCalls !== void 0) choice.delta.tool_calls = delta.toolCalls;
803
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
804
+ }
805
+ function writeErrorChunk(res, id, created, model, error) {
806
+ if (res.destroyed) return;
807
+ const errChunk = makeChunk(id, created, model);
808
+ errChunk.choices[0].finish_reason = "error";
809
+ const code = isQoderUpstreamError(error) ? ERROR_MAP[error.kind].code : "upstream_error";
810
+ errChunk.error = {
811
+ message: String(error.message ?? "internal error"),
812
+ code,
813
+ type: "qoder_error"
814
+ };
815
+ res.write(`data: ${JSON.stringify(errChunk)}\n\n`);
816
+ res.write("data: [DONE]\n\n");
817
+ res.end();
818
+ }
819
+ function makeChunk(id, created, model) {
820
+ return {
821
+ id,
822
+ object: "chat.completion.chunk",
823
+ created,
824
+ model,
825
+ choices: [{
826
+ index: 0,
827
+ delta: {},
828
+ finish_reason: null
829
+ }]
830
+ };
831
+ }
832
+ function writeJson(res, status, body) {
833
+ if (res.writableEnded || res.destroyed) return;
834
+ const text = JSON.stringify(body);
835
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
836
+ res.end(text);
837
+ }
838
+ function readBody(req, limit) {
839
+ return new Promise((resolve, reject) => {
840
+ const chunks = [];
841
+ let size = 0;
842
+ let over = false;
843
+ req.on("data", (chunk) => {
844
+ if (over) return;
845
+ size += chunk.length;
846
+ if (size > limit) {
847
+ over = true;
848
+ reject(/* @__PURE__ */ new Error("request body too large"));
849
+ req.resume();
850
+ return;
851
+ }
852
+ chunks.push(chunk);
853
+ });
854
+ req.on("end", () => {
855
+ if (!over) resolve(Buffer.concat(chunks).toString("utf8"));
856
+ });
857
+ req.on("error", reject);
858
+ });
859
+ }
860
+ return {
861
+ ready,
862
+ get baseUrl() {
863
+ return baseUrlValue;
864
+ },
865
+ get port() {
866
+ return portValue;
867
+ },
868
+ secret,
869
+ close: async () => {
870
+ await closeFn();
871
+ }
872
+ };
873
+ }
874
+ /** The provider id each region registers as. */
875
+ const QODER_PROVIDERS = {
876
+ cn: "qoder",
877
+ global: "qoder-global"
878
+ };
879
+ /** Region a provider route id belongs to. */
880
+ function regionOfProvider(provider) {
881
+ for (const [region, id] of Object.entries(QODER_PROVIDERS)) if (id === provider) return region;
882
+ }
883
+ /** Human-readable provider name, shown in the DSH model picker. */
884
+ const QODER_PROVIDER_DISPLAY_NAMES = {
885
+ cn: "Qoder",
886
+ global: "Qoder Global"
887
+ };
888
+ /** Provider idle ceiling while one stream read is outstanding. */
889
+ const QODER_STREAM_IDLE_TIMEOUT_MS = 3e5;
890
+ const NO_COST = {
891
+ input: 0,
892
+ output: 0,
893
+ cacheRead: 0,
894
+ cacheWrite: 0
895
+ };
896
+ /** Image-request budgets at the dsh-llm-pi-ai defaults. */
897
+ const REQUEST_IMAGE_BUDGETS = {
898
+ maxRequestImageBytes: 20971520,
899
+ requestImagePixelBudget: 4194304,
900
+ requestImageMaxBytes: 1048576
901
+ };
902
+ /** Inert pi-ai auth plane: the route authenticates only through the shim secret. */
903
+ const INERT_AUTH = {
904
+ credentials: {
905
+ async read() {},
906
+ async list() {
907
+ return [];
908
+ },
909
+ async modify() {}
910
+ },
911
+ authContext: {}
912
+ };
913
+ const THINKING_LEVELS = [
914
+ "minimal",
915
+ "low",
916
+ "medium",
917
+ "high",
918
+ "xhigh",
919
+ "max"
920
+ ];
921
+ /** pi-ai input modalities: images only when Qoder advertises them. */
922
+ function qoderModelInput(info) {
923
+ return info.supportsImages === true ? ["text", "image"] : ["text"];
924
+ }
925
+ /**
926
+ * The DSH-facing thinking level map for one Qoder model.
927
+ *
928
+ * **只映射上游真正声明的档位**(workbuddy 的 `workBuddyThinkingLevelMap` 同规)。
929
+ * Qoder 的 `thinking_config.enabled.efforts` 会逐模型声明档位,且**不统一**:
930
+ * Qwen 系是 `xhigh / low / medium`,DeepSeek/GLM 系是 `high / low / max`。
931
+ * 把六个 DSH 档位全部置 null 会让 DSH 的模型设置里出现一排「可选但无效」的
932
+ * 档位 —— 用户选了也不起作用。正确做法是:声明了的档位映射到**同名**的真实档位,
933
+ * 没声明的映射为 `null`(DSH 据此不提供该档)。
934
+ *
935
+ * 上游声明可以关闭思考时额外提供 `off`;不能关闭则 `off` 映射为 null(DSH
936
+ * 不提供「关闭」)。非推理模型没有 map。
937
+ *
938
+ * 注意:这与「请求线怎么带档位」是两件事 —— shim 侧按
939
+ * `reasoningEffort` + `parameters.reasoning_effort` 发送(见 shim.ts)。
940
+ */
941
+ function qoderThinkingLevelMap(info) {
942
+ if (!info.supportsThinking) return void 0;
943
+ const declared = info.thinkingEfforts ?? [];
944
+ const supported = THINKING_LEVELS.filter((level) => declared.includes(level));
945
+ if (supported.length === 0) return void 0;
946
+ const map = {};
947
+ for (const level of THINKING_LEVELS) map[level] = supported.includes(level) ? level : null;
948
+ if (info.canDisableThinking === true) map.off = "off";
949
+ return map;
950
+ }
951
+ /** Build one pi-ai model descriptor pointing at the loopback shim. */
952
+ function toPiModel(info, baseUrl, providerId) {
953
+ const thinkingLevelMap = qoderThinkingLevelMap(info);
954
+ return {
955
+ id: info.key,
956
+ name: modelDisplayName(info),
957
+ api: "openai-completions",
958
+ provider: providerId,
959
+ baseUrl,
960
+ input: qoderModelInput(info),
961
+ cost: NO_COST,
962
+ contextWindow: info.contextWindow,
963
+ maxTokens: info.maxOutputTokens,
964
+ reasoning: thinkingLevelMap !== void 0,
965
+ ...thinkingLevelMap === void 0 ? {} : { thinkingLevelMap },
966
+ compat: {
967
+ supportsReasoningEffort: thinkingLevelMap !== void 0,
968
+ supportsDeveloperRole: false
969
+ }
970
+ };
971
+ }
972
+ /**
973
+ * Rewrite a pi-ai 0.87-shaped transcript into the 0.85 shape this plugin's
974
+ * pi-ai understands — prompt AND tools.
975
+ *
976
+ * WHY THIS EXISTS: pi-ai 0.87's `normalizeContext` moves the system prompt
977
+ * INTO `messages` as `{ role: 'system', content }`, and 0.85's own `Message`
978
+ * union has no `system` variant — its token estimator then throws inside the
979
+ * library. This plugin cannot patch the library, so it must not hand a 0.87
980
+ * transcript to a 0.85 API object.
981
+ *
982
+ * The 0.87 transcript also carries the tool declarations on that leading
983
+ * system message (`toolsAdded`/`toolsRemoved`) — the only place they live —
984
+ * so the fold promotes them too, exactly the merge pi-ai 0.87's own
985
+ * `getCurrentTools` performs, and leaves an existing top-level `tools` array
986
+ * untouched.
987
+ *
988
+ * Unlike the sibling plugin, Qoder needs NO fallback system prompt: the Qoder
989
+ * gateway accepts user-first conversations (there is no "first message must be
990
+ * the system prompt" rule), so a transcript with no system message is passed
991
+ * through as-is. All the 0.87-vs-0.85 shape work above is identical.
992
+ *
993
+ * Deliberately SHAPE-based within the legacy branch, not version-based, and
994
+ * `withLegacyContext` generation-gates which API object consumes the result.
995
+ */
996
+ function adaptLegacyPiAiContext(context) {
997
+ if (context === null || typeof context !== "object") return context;
998
+ if (context.systemPrompt) return context;
999
+ const messages = Array.isArray(context.messages) ? context.messages : void 0;
1000
+ if (messages === void 0) return context;
1001
+ const systemTexts = [];
1002
+ const rest = [];
1003
+ const toolState = /* @__PURE__ */ new Map();
1004
+ let sawSystemMessage = false;
1005
+ for (const message of messages) {
1006
+ if (message?.role !== "system") {
1007
+ rest.push(message);
1008
+ continue;
1009
+ }
1010
+ sawSystemMessage = true;
1011
+ const text = systemTextOf(message);
1012
+ if (text !== "") systemTexts.push(text);
1013
+ replayToolState(message, toolState);
1014
+ }
1015
+ if (!sawSystemMessage) return context;
1016
+ const systemPrompt = systemTexts.length > 0 ? systemTexts.join("\n\n") : void 0;
1017
+ const tools = Array.isArray(context.tools) ? void 0 : [...toolState.values()];
1018
+ return {
1019
+ ...context,
1020
+ ...systemPrompt === void 0 ? {} : { systemPrompt },
1021
+ ...tools !== void 0 && tools.length > 0 ? { tools } : {},
1022
+ messages: rest
1023
+ };
1024
+ }
1025
+ /** Replay one 0.87 system message's tool deltas into `state`. */
1026
+ function replayToolState(message, state) {
1027
+ const removed = message.toolsRemoved;
1028
+ if (Array.isArray(removed)) for (const tool of removed) {
1029
+ const name = tool?.name;
1030
+ if (typeof name === "string") state.delete(name);
1031
+ }
1032
+ const added = message.toolsAdded;
1033
+ if (Array.isArray(added)) for (const tool of added) {
1034
+ const name = tool?.name;
1035
+ if (typeof name === "string") state.set(name, tool);
1036
+ }
1037
+ }
1038
+ /** The text of one system message, whether it is a string or text blocks. */
1039
+ function systemTextOf(message) {
1040
+ const content = message.content;
1041
+ if (typeof content === "string") return content;
1042
+ if (!Array.isArray(content)) return "";
1043
+ const texts = [];
1044
+ for (const block of content) {
1045
+ if (typeof block !== "object" || block === null) continue;
1046
+ const candidate = block;
1047
+ if (candidate.type === "text" && typeof candidate.text === "string") texts.push(candidate.text);
1048
+ }
1049
+ return texts.join("\n\n");
1050
+ }
1051
+ /**
1052
+ * Wrap one pi-ai API module so both stream entry points receive a context this
1053
+ * build can consume — but only when the module RESOLVED for this plugin is the
1054
+ * legacy generation.
1055
+ *
1056
+ * The gate is the plugin's own module, resolved via {@link piAiRuntimeInfo}: a
1057
+ * modern (0.87+) api consumes the normalized transcript natively, so folding
1058
+ * for it would delete its only carrier of prompt and tools. The unwrapped
1059
+ * modern api is passed through as-is.
1060
+ */
1061
+ function withLegacyContext(api, generation) {
1062
+ if (generation === "modern") return api;
1063
+ return {
1064
+ ...api,
1065
+ stream: (model, context, options) => api.stream(model, adaptLegacyPiAiContext(context), options),
1066
+ streamSimple: (model, context, options) => api.streamSimple(model, adaptLegacyPiAiContext(context), options)
1067
+ };
1068
+ }
1069
+ /**
1070
+ * Assemble the adapter. The provider's `getModels` reads the live catalog,
1071
+ * and every model's `baseUrl` is re-resolved per read so the shim's
1072
+ * ephemeral port applies from the first snapshot after startup.
1073
+ */
1074
+ function createQoderAdapter(options) {
1075
+ const { shim, catalog } = options;
1076
+ const providerId = options.provider ?? "qoder";
1077
+ const providerName = options.displayName ?? QODER_PROVIDER_DISPLAY_NAMES.cn;
1078
+ const generation = options.piAiGeneration ?? piAiRuntimeInfo()?.generation ?? "modern";
1079
+ const api = withLegacyContext(openAICompletionsApi(), generation);
1080
+ const buildModels = () => {
1081
+ const baseUrl = `${shim.baseUrl}/v1`;
1082
+ return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
1083
+ };
1084
+ const provider = {
1085
+ ...createProvider({
1086
+ id: providerId,
1087
+ name: providerName,
1088
+ auth: { apiKey: {
1089
+ name: "Qoder shim token",
1090
+ async resolve({ credential }) {
1091
+ const apiKey = credential?.key;
1092
+ return {
1093
+ auth: apiKey === void 0 || apiKey.length === 0 ? {} : { apiKey },
1094
+ source: "Qoder shim token"
1095
+ };
1096
+ }
1097
+ } },
1098
+ models: buildModels(),
1099
+ api
1100
+ }),
1101
+ getModels: () => buildModels()
1102
+ };
1103
+ const profile = {
1104
+ provider: providerId,
1105
+ displayName: providerName,
1106
+ streamIdleTimeoutMs: QODER_STREAM_IDLE_TIMEOUT_MS,
1107
+ retryPolicy: resolveRetryPolicy(void 0, "dsh-connect-qoder retryPolicy"),
1108
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
1109
+ modelErrors: /* @__PURE__ */ new Map(),
1110
+ ...REQUEST_IMAGE_BUDGETS,
1111
+ piProvider: provider
1112
+ };
1113
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1114
+ return {
1115
+ adapter: new PiAiAdapter({
1116
+ profiles: () => profiles,
1117
+ auth: INERT_AUTH,
1118
+ resolveApiKey: async () => shim.secret,
1119
+ ...options.resolveAttachments === void 0 ? {} : { resolveAttachments: options.resolveAttachments }
1120
+ }),
1121
+ invalidate: () => {
1122
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1123
+ },
1124
+ piAiGeneration: generation
1125
+ };
1126
+ }
1127
+ //#endregion
1128
+ //#region src/probe.ts
1129
+ /** The family's default probe input, in tokens (a realistic conversation size). */
1130
+ const DEFAULT_PROBE_INPUT_TOKENS = 25e3;
1131
+ /**
1132
+ * The probe sizes the card offers, in tokens — carried from the sibling plugin
1133
+ * so the two cards read one family set.
1134
+ */
1135
+ const PROBE_INPUT_TOKEN_CHOICES = [
1136
+ 1e4,
1137
+ 2e4,
1138
+ 3e4,
1139
+ 5e4,
1140
+ 1e5
1141
+ ];
1142
+ /**
1143
+ * A requested probe size reduced to a size this build will send.
1144
+ */
1145
+ function resolveProbeInputTokens(requested) {
1146
+ return requested !== void 0 && PROBE_INPUT_TOKEN_CHOICES.includes(requested) ? requested : DEFAULT_PROBE_INPUT_TOKENS;
1147
+ }
1148
+ /** Characters per token for the filler text (family estimate, 4.5 c/tok). */
1149
+ const CHARS_PER_TOKEN = 4.5;
1150
+ /** Neutral filler; carries no user content, only volume. */
1151
+ const FILLER_LINE = "The quick brown fox jumps over the lazy dog. ";
1152
+ /**
1153
+ * Build the user message a probe carries: a padded filler (for volume) plus
1154
+ * the actual prompt. Qoder's gateway takes user-first messages (no system
1155
+ * requirement), so no PROBE_SYSTEM_PROMPT is needed.
1156
+ */
1157
+ function probeUserMessage(inputTokens = DEFAULT_PROBE_INPUT_TOKENS) {
1158
+ const targetChars = Math.ceil(resolveProbeInputTokens(inputTokens) * CHARS_PER_TOKEN);
1159
+ return `${FILLER_LINE.repeat(Math.ceil(targetChars / 45))}Reply with a single word.`;
1160
+ }
1161
+ /**
1162
+ * Parse a `Retry-After` header value into an absolute time.
1163
+ *
1164
+ * Both RFC 9110 forms are accepted: delay-seconds (`120`) and an HTTP-date.
1165
+ * Returns undefined for anything else, including negative/zero delays.
1166
+ */
1167
+ function parseRetryAfter(value, nowMs) {
1168
+ if (value === null) return void 0;
1169
+ const trimmed = value.trim();
1170
+ if (trimmed === "") return void 0;
1171
+ if (/^\d+$/u.test(trimmed)) {
1172
+ const seconds = Number(trimmed);
1173
+ if (!Number.isFinite(seconds) || seconds <= 0) return void 0;
1174
+ return nowMs + seconds * 1e3;
1175
+ }
1176
+ const parsed = Date.parse(trimmed);
1177
+ if (Number.isNaN(parsed)) return void 0;
1178
+ if (parsed <= nowMs) return void 0;
1179
+ return parsed;
1180
+ }
1181
+ /**
1182
+ * The upstream's reset sentence, as Qoder's gateways write it in Chinese —
1183
+ * e.g. `您的使用量已超出频率限制,将在 2026-09-30 02:30:30 UTC+8 重置,…`.
1184
+ * Loose around the wording, strict about the parts that must not be guessed:
1185
+ * the keyword 重置, the timestamp shape, and an explicit UTC offset.
1186
+ */
1187
+ const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?[\s\S]*?重置/u;
1188
+ /**
1189
+ * Parse the reset time out of an upstream failure message.
1190
+ *
1191
+ * The offset is honoured, not assumed: `Date.UTC` with the stated offset
1192
+ * subtracted, returned as epoch ms. A whole-second resolution is what the
1193
+ * upstream prints.
1194
+ */
1195
+ function parseUpstreamResetAt(message) {
1196
+ const match = UPSTREAM_RESET_PATTERN.exec(message);
1197
+ if (match === null) return void 0;
1198
+ const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
1199
+ const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
1200
+ const utcMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
1201
+ const parsed = new Date(utcMs);
1202
+ if (parsed.getUTCFullYear() !== Number(year) || parsed.getUTCMonth() !== Number(month) - 1 || parsed.getUTCDate() !== Number(day) || parsed.getUTCHours() !== Number(hour) || parsed.getUTCMinutes() !== Number(minute) || parsed.getUTCSeconds() !== Number(second)) return;
1203
+ if (Math.abs(offsetTotalMinutes) > 840) return void 0;
1204
+ return utcMs - offsetTotalMinutes * 6e4;
1205
+ }
1206
+ /**
1207
+ * The cooldown the upstream stated, if it stated one.
1208
+ *
1209
+ * Three sources, in order of authority: `Retry-After` on the failure response;
1210
+ * the reset time written into the failure body (the case that actually fires —
1211
+ * a 429 carries `将在 … 重置`, no header); and the region's quota refresh point
1212
+ * ONLY for an out-of-credit outcome. None of these → `{}`, which the card
1213
+ * renders as "the upstream did not say when".
1214
+ */
1215
+ function cooldownOf(input) {
1216
+ const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
1217
+ if (fromHeader !== void 0) return {
1218
+ retryAtMs: fromHeader,
1219
+ retrySource: "retry-after"
1220
+ };
1221
+ const fromBody = input.body === void 0 ? void 0 : parseUpstreamResetAt(input.body);
1222
+ if (fromBody !== void 0) return {
1223
+ retryAtMs: fromBody,
1224
+ retrySource: "upstream-message"
1225
+ };
1226
+ if (input.outcome === "out-of-credit" && input.quotaRefreshAtMs !== void 0) return {
1227
+ retryAtMs: input.quotaRefreshAtMs,
1228
+ retrySource: "quota-refresh"
1229
+ };
1230
+ return {};
1231
+ }
1232
+ /**
1233
+ * Classify one failed probe into the card's outcome vocabulary, from the
1234
+ * upstream error kind rather than raw status codes, so a probe and a real chat
1235
+ * request never disagree about the same answer.
1236
+ */
1237
+ function outcomeOfFailure(error) {
1238
+ switch (error.kind) {
1239
+ case "auth": return { outcome: "credential-rejected" };
1240
+ case "rate-limited": return { outcome: "rate-limited" };
1241
+ case "quota-exhausted": return { outcome: "out-of-credit" };
1242
+ case "content-policy": return { outcome: "policy-rejected" };
1243
+ case "invalid-request": return error.status === 400 && /model/i.test(error.message) ? { outcome: "not-found" } : { outcome: "failed" };
1244
+ case "not-found-model": return { outcome: "not-found" };
1245
+ case "network":
1246
+ case "transient": return { outcome: "unavailable" };
1247
+ case "unknown": return error.status === 0 || error.status >= 500 ? { outcome: "unavailable" } : { outcome: "failed" };
1248
+ }
1249
+ }
1250
+ /** Max characters of upstream text kept for display, after redaction. */
1251
+ const PROBE_MESSAGE_LIMIT = 300;
1252
+ /**
1253
+ * Redact token-shaped content out of upstream text before it is stored or sent.
1254
+ *
1255
+ * Lives here because this module is the one that reads raw failure bodies, so
1256
+ * the redaction belongs at the point of capture. Also used by the pool's
1257
+ * `recordAccountFailure` path so the two writers cannot drift apart.
1258
+ */
1259
+ function redactUpstreamText(text) {
1260
+ return text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^\s&,]+/giu, "$1[redacted]").replace(/\bpt-[A-Za-z0-9_-]{6,}\b/giu, "[redacted credential]").slice(0, PROBE_MESSAGE_LIMIT);
1261
+ }
1262
+ /**
1263
+ * Run one probe for one model and reduce it to a result row.
1264
+ *
1265
+ * Never throws: a probe is a diagnostic the user asked for, so every failure is
1266
+ * a RESULT — one dead model must not abort a batch and hide the other rows.
1267
+ */
1268
+ async function probeModel(input) {
1269
+ const startedAt = Date.now();
1270
+ let answer;
1271
+ try {
1272
+ answer = await input.client.probeChat(input.credential, input.modelKey, {
1273
+ ...input.inputTokens === void 0 ? {} : { inputTokens: input.inputTokens },
1274
+ ...input.signal === void 0 ? {} : { signal: input.signal }
1275
+ });
1276
+ } catch (error) {
1277
+ return {
1278
+ modelId: input.modelKey,
1279
+ outcome: "unavailable",
1280
+ message: redactUpstreamText(error instanceof Error ? error.message : String(error))
1281
+ };
1282
+ }
1283
+ const elapsedMs = Date.now() - startedAt;
1284
+ if (answer.ok) return {
1285
+ modelId: input.modelKey,
1286
+ outcome: "ok",
1287
+ elapsedMs,
1288
+ status: answer.status,
1289
+ ...cooldownOf({
1290
+ outcome: "ok",
1291
+ retryAfter: answer.retryAfter,
1292
+ nowMs: input.nowMs,
1293
+ ...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
1294
+ })
1295
+ };
1296
+ const error = answer.error;
1297
+ if (error === void 0) return {
1298
+ modelId: input.modelKey,
1299
+ outcome: "failed",
1300
+ elapsedMs,
1301
+ status: answer.status
1302
+ };
1303
+ const { outcome } = outcomeOfFailure(error);
1304
+ const body = answer.body ?? "";
1305
+ return {
1306
+ modelId: input.modelKey,
1307
+ outcome,
1308
+ elapsedMs,
1309
+ status: answer.status,
1310
+ ...body === "" ? {} : { message: redactUpstreamText(body) },
1311
+ ...cooldownOf({
1312
+ outcome,
1313
+ retryAfter: answer.retryAfter,
1314
+ nowMs: input.nowMs,
1315
+ body,
1316
+ ...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
1317
+ })
1318
+ };
1319
+ }
1320
+ /**
1321
+ * Whether one account can be billed at `nowMs`.
1322
+ *
1323
+ * Returns the exclusion reason, or undefined when the account is a candidate.
1324
+ * The order matters and is NOT arbitrary:
1325
+ *
1326
+ * - A `credential-rejected` / `not-found` account is excluded regardless of any
1327
+ * cooldown: the token is refused / the model is gone, so waiting cannot help
1328
+ * and the user must act.
1329
+ * - A limited, unreachable or failed outcome is checked against its stated (or
1330
+ * fallback) cooldown, so an account whose limit has already reset — or whose
1331
+ * gateway blipped once — is back in the pool without another test first.
1332
+ * - An `ok` measurement, and no measurement at all, are both candidates.
1333
+ *
1334
+ * WHY TRANSIENT OUTCOMES GET A COOLDOWN: a transport failure (DNS blip, a 5xx,
1335
+ * a cancelled request) says nothing about the ACCOUNT, so excluding it
1336
+ * permanently would idle a good account and drain the pool of candidates. The
1337
+ * cooldown is what makes the pool RECOVER instead of staying broken.
1338
+ */
1339
+ function exclusionOf(probe, nowMs) {
1340
+ if (probe === void 0) return void 0;
1341
+ switch (probe.outcome) {
1342
+ case "ok": return;
1343
+ case "credential-rejected": return "credential-rejected";
1344
+ case "not-found": return "not-found";
1345
+ case "unavailable": return retryDue(probe, nowMs) ? void 0 : "unavailable";
1346
+ case "failed": return retryDue(probe, nowMs) ? void 0 : "failed";
1347
+ case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
1348
+ case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
1349
+ case "policy-rejected": return;
1350
+ default: return "unusable";
1351
+ }
1352
+ }
1353
+ /** Whether an account's cooldown has elapsed (or was never stated). */
1354
+ function retryDue(probe, nowMs) {
1355
+ return nowMs >= (probe.retryAtMs ?? probe.atMs + 18e5);
1356
+ }
1357
+ /**
1358
+ * Rank a pool's members best-first.
1359
+ *
1360
+ * The rule, in priority order — this is the whole feature:
1361
+ *
1362
+ * 1. **Usability.** Only accounts that can be billed right now are candidates.
1363
+ * 2. **Credits, highest first.** Spend the account that has the most.
1364
+ * 3. **Credits expiring soonest first.** Points about to expire are worth zero
1365
+ * after they do.
1366
+ * 4. **Account id.** Final tie-break, making the order TOTAL.
1367
+ *
1368
+ * Excluded accounts are still RETURNED (sorted after the candidates) so the
1369
+ * card can explain why each is out.
1370
+ *
1371
+ * Pure and total: the same input always yields the same order, and it never
1372
+ * throws on missing data (an account with no credits is ranked as 0).
1373
+ */
1374
+ function rankPool(members, nowMs) {
1375
+ const rows = members.map((member) => {
1376
+ const excludedBy = exclusionOf(member.probe, nowMs);
1377
+ return {
1378
+ account: member.account,
1379
+ score: scoreOf(member),
1380
+ ...excludedBy === void 0 ? {} : { excludedBy },
1381
+ _usable: excludedBy === void 0,
1382
+ _expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY
1383
+ };
1384
+ });
1385
+ rows.sort((left, right) => {
1386
+ if (left._usable !== right._usable) return left._usable ? -1 : 1;
1387
+ if (left.score !== right.score) return right.score - left.score;
1388
+ if (left._expiring !== right._expiring) return left._expiring - right._expiring;
1389
+ return left.account.id < right.account.id ? -1 : left.account.id > right.account.id ? 1 : 0;
1390
+ });
1391
+ return rows.map(({ account, score, excludedBy }) => ({
1392
+ account,
1393
+ score,
1394
+ ...excludedBy === void 0 ? {} : { excludedBy }
1395
+ }));
1396
+ }
1397
+ /** The credit figure used for ordering. Absent credits rank as zero. */
1398
+ function scoreOf(member) {
1399
+ return member.credits?.total ?? 0;
1400
+ }
1401
+ /**
1402
+ * Resolve the model the pool should test against.
1403
+ *
1404
+ * A user-specified id always wins (it is a preference, not a computed default),
1405
+ * and is returned even when it is a PAID model: the card says so explicitly.
1406
+ * The choice is VALIDATED against the region's catalog: a saved id no longer
1407
+ * offered is reported as `stale` — never silently replaced by the free model —
1408
+ * and a catalog that has not loaded yet reads as `stale` too (the safe
1409
+ * direction: no credits are spent while the roster is unknown).
1410
+ */
1411
+ function resolveTargetModel(catalog, preferredId) {
1412
+ if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
1413
+ modelId: preferredId,
1414
+ source: "preferred"
1415
+ } : {
1416
+ staleModelId: preferredId,
1417
+ source: "stale"
1418
+ };
1419
+ const free = pickFreeModel(catalog);
1420
+ return free === void 0 ? { source: "none" } : {
1421
+ modelId: free.id,
1422
+ source: "free"
1423
+ };
1424
+ }
1425
+ /**
1426
+ * Pick the free model to test against, from one region's projected catalog.
1427
+ *
1428
+ * "Free" means `creditMultiplier === 0` EXACTLY. An absent multiplier is NOT
1429
+ * free — the Host only folds `is_free` to 0 when it positively knows the model
1430
+ * is free, so an absent multiplier stays a non-free (unknown) model rather than
1431
+ * silently billing real credits on a probe. Among the free candidates the
1432
+ * LARGEST context window wins, because the probe sends a real-sized payload.
1433
+ *
1434
+ * Returns undefined when there is no free model, which the caller must render
1435
+ * as "none available" — never as a fallback to a paid model.
1436
+ */
1437
+ function pickFreeModel(catalog) {
1438
+ let best;
1439
+ for (const model of catalog) {
1440
+ if (model.creditMultiplier !== 0) continue;
1441
+ if (best === void 0 || model.contextWindow > best.contextWindow) best = model;
1442
+ }
1443
+ return best;
1444
+ }
1445
+ //#endregion
1446
+ //#region src/web-status.ts
1447
+ function json(res, status, body) {
1448
+ if (res.writableEnded || res.destroyed) return;
1449
+ const text = JSON.stringify(body);
1450
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
1451
+ res.end(text);
1452
+ }
1453
+ function safeMessage(error) {
1454
+ return error instanceof Error ? error.message : String(error);
1455
+ }
1456
+ function loopbackOrigin(req) {
1457
+ const origin = req.headers.origin;
1458
+ if (origin === void 0) return true;
1459
+ return /^http:\/\/127\.0\.0\.1:\d+$/.test(origin) || /^http:\/\/localhost:\d+$/.test(origin);
1460
+ }
1461
+ async function readBody(req) {
1462
+ return new Promise((resolve, reject) => {
1463
+ let total = 0;
1464
+ const chunks = [];
1465
+ req.on("data", (chunk) => {
1466
+ total += chunk.length;
1467
+ if (total > 1048576) {
1468
+ reject(/* @__PURE__ */ new Error("request body too large"));
1469
+ req.destroy();
1470
+ return;
1471
+ }
1472
+ chunks.push(chunk);
1473
+ });
1474
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1475
+ req.on("error", reject);
1476
+ });
1477
+ }
1478
+ /** Narrow the region on a route URL; 400 when absent/unknown. */
1479
+ function requiredRegion(res, url) {
1480
+ const region = regionOfStatusUrl(url);
1481
+ if (region === void 0) {
1482
+ json(res, 400, { error: "unknown region" });
1483
+ return;
1484
+ }
1485
+ return region;
1486
+ }
1487
+ /** Project one model into the card's web row. */
1488
+ function webModelOf(m) {
1489
+ return {
1490
+ key: m.key,
1491
+ name: m.displayName,
1492
+ contextWindow: m.contextWindow,
1493
+ nativeContextWindow: m.contextWindow,
1494
+ maxTokens: m.maxOutputTokens,
1495
+ creditMultiplier: m.priceFactor,
1496
+ ...m.supportsImages === true ? { supportsImages: true } : {},
1497
+ supportsThinking: m.supportsThinking,
1498
+ ...m.canDisableThinking === true ? { canDisableThinking: true } : {},
1499
+ ...m.thinkingEfforts === void 0 ? {} : { thinkingEfforts: m.thinkingEfforts },
1500
+ ...m.defaultThinkingEffort === void 0 ? {} : { defaultThinkingEffort: m.defaultThinkingEffort },
1501
+ ...m.isDefault === true ? { isDefault: true } : {},
1502
+ ...m.isFree === true ? { isFree: true } : {},
1503
+ ...m.isNew === true ? { isNew: true } : {},
1504
+ ...m.contextTiers === void 0 ? {} : { contextTiers: m.contextTiers }
1505
+ };
1506
+ }
1507
+ /** Redact upstream failure text for safe display/storage. */
1508
+ function redactQoderText(text) {
1509
+ return text.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/\b(pt-|eyJ)[A-Za-z0-9_\-]{6,}\b/giu, "[redacted credential]").slice(0, 300);
1510
+ }
1511
+ /** Project one model into the pool's target picker shape. */
1512
+ function poolModelOf(m) {
1513
+ return {
1514
+ id: m.key,
1515
+ name: m.displayName,
1516
+ creditMultiplier: m.isFree === true ? 0 : m.priceFactor,
1517
+ contextWindow: m.contextWindow
1518
+ };
1519
+ }
1520
+ /**
1521
+ * Mount the web routes for dsh-connect-qoder. Returns the status builder so
1522
+ * the Host can also answer from a cache (the route itself re-derives it).
1523
+ */
1524
+ function registerQoderWebRoutes(ctx, deps) {
1525
+ const disposers = [];
1526
+ const regionOf = requiredRegion;
1527
+ const mount = (path, handler) => {
1528
+ try {
1529
+ const dispose = ctx.webServer.register({
1530
+ kind: "exact",
1531
+ path,
1532
+ handler
1533
+ });
1534
+ if (typeof dispose === "function") disposers.push(dispose);
1535
+ } catch {}
1536
+ };
1537
+ mount(QODER_USAGE_PATH, async (req, res) => {
1538
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
1539
+ const region = regionOfStatusUrl(req.url ?? "");
1540
+ if (region === void 0) return json(res, 400, { error: "unknown region" });
1541
+ return json(res, 200, await buildWebUsage(deps, region));
1542
+ });
1543
+ mount(QODER_ACCOUNT_CREDITS_PATH, async (req, res) => {
1544
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
1545
+ const region = regionOf(res, req.url ?? "");
1546
+ if (region === void 0) return;
1547
+ if (deps.accountCredits === void 0) return json(res, 503, {
1548
+ reason: "credits-unavailable",
1549
+ error: "account credits unavailable"
1550
+ });
1551
+ try {
1552
+ return json(res, 200, await deps.accountCredits(region));
1553
+ } catch (error) {
1554
+ return json(res, 502, { error: safeMessage(error) });
1555
+ }
1556
+ });
1557
+ mount(QODER_MODELS_REFRESH_PATH, async (req, res) => {
1558
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1559
+ const region = regionOf(res, req.url ?? "");
1560
+ if (region === void 0) return;
1561
+ const refresh = deps.refreshModels ?? deps.discoverModels;
1562
+ if (refresh === void 0) return json(res, 503, {
1563
+ reason: "refresh-unavailable",
1564
+ error: "model discovery unavailable"
1565
+ });
1566
+ try {
1567
+ return json(res, 200, { models: (await refresh(region)).map(webModelOf) });
1568
+ } catch (error) {
1569
+ return json(res, 502, { error: safeMessage(error) });
1570
+ }
1571
+ });
1572
+ mount(QODER_ACCOUNTS_REFRESH_PATH, async (req, res) => {
1573
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1574
+ const region = regionOf(res, req.url ?? "");
1575
+ if (region === void 0) return;
1576
+ return json(res, 200, (await deps.store(region).accounts(deps.credentialSources(region))).map(webAccountOf(deps, region)));
1577
+ });
1578
+ mount(QODER_ACCOUNTS_PATH, async (req, res) => {
1579
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "forbidden" });
1580
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1581
+ const region = regionOf(res, req.url ?? "");
1582
+ if (region === void 0) return;
1583
+ const action = accountActionOf(req.url ?? "");
1584
+ if (action === void 0) return json(res, 400, { error: "action is required (add|remove)" });
1585
+ const body = await readBody(req);
1586
+ let parsed;
1587
+ try {
1588
+ parsed = JSON.parse(body || "{}");
1589
+ } catch {
1590
+ return json(res, 400, { error: "invalid JSON body" });
1591
+ }
1592
+ if (action === "add") {
1593
+ const pat = typeof parsed.pat === "string" ? parsed.pat.trim() : "";
1594
+ if (pat === "") return json(res, 400, { error: "pat is required" });
1595
+ const preferred = typeof parsed.name === "string" ? parsed.name.trim() : "";
1596
+ if (deps.accountValidation === void 0) return json(res, 503, {
1597
+ reason: "validation-unavailable",
1598
+ error: "account validation unavailable"
1599
+ });
1600
+ try {
1601
+ const { name } = await deps.accountValidation.validateForRegion(region, pat);
1602
+ const displayName = preferred !== "" ? preferred : name;
1603
+ const stored = await deps.store(region).add(pat, displayName);
1604
+ if (displayName !== stored.accountName) await deps.store(region).rename(stored.id, displayName);
1605
+ return json(res, 200, {
1606
+ accountId: stored.id,
1607
+ accountName: displayName
1608
+ });
1609
+ } catch (error) {
1610
+ return json(res, 502, { error: safeMessage(error) });
1611
+ }
1612
+ }
1613
+ const accountId = typeof parsed.accountId === "string" ? parsed.accountId : "";
1614
+ if (accountId === "") return json(res, 400, { error: "accountId is required" });
1615
+ return json(res, 200, { removed: await deps.store(region).remove(accountId) });
1616
+ });
1617
+ mount(QODER_PROBE_PATH, async (req, res) => {
1618
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
1619
+ const region = regionOf(res, req.url ?? "");
1620
+ if (region === void 0) return;
1621
+ const model = new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("model") ?? "";
1622
+ if (model === "") return json(res, 400, { error: "model is required" });
1623
+ if (deps.probeModel === void 0) return json(res, 503, {
1624
+ reason: "probe-unavailable",
1625
+ error: "probe unavailable"
1626
+ });
1627
+ try {
1628
+ return json(res, 200, await deps.probeModel(region, model));
1629
+ } catch (error) {
1630
+ return json(res, 502, { error: safeMessage(error) });
1631
+ }
1632
+ });
1633
+ mount(QODER_POOL_PATH, async (req, res) => {
1634
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "forbidden" });
1635
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1636
+ const region = regionOf(res, req.url ?? "");
1637
+ if (region === void 0) return;
1638
+ const action = poolActionOf(req.url ?? "");
1639
+ if (action === void 0) return json(res, 400, { error: "action is required (checkin|test|remove)" });
1640
+ if (deps.pool === void 0) return json(res, 503, {
1641
+ reason: "pool-unavailable",
1642
+ error: "pool unavailable"
1643
+ });
1644
+ const pool = deps.pool;
1645
+ if (action === "checkin") {
1646
+ if (pool.checkinSupported(region) !== true) return json(res, 409, {
1647
+ reason: "checkin-unsupported",
1648
+ error: "this region does not offer a daily check-in"
1649
+ });
1650
+ if (pool.checkin === void 0) return json(res, 503, {
1651
+ reason: "pool-unavailable",
1652
+ error: "pool check-in unavailable"
1653
+ });
1654
+ return json(res, 200, {
1655
+ action: "checkin",
1656
+ rows: await pool.checkin(region)
1657
+ });
1658
+ }
1659
+ if (action === "test") {
1660
+ const target = resolveTargetModel(pool.catalog(region).map(poolModelOf), pool.preferences(region).targetModelId);
1661
+ if (target.modelId === void 0) return json(res, 200, {
1662
+ action: "test",
1663
+ rows: [],
1664
+ modelId: void 0
1665
+ });
1666
+ const rows = await pool.test(region, target.modelId);
1667
+ return json(res, 200, {
1668
+ action: "test",
1669
+ modelId: target.modelId,
1670
+ rows
1671
+ });
1672
+ }
1673
+ const accountId = poolAccountIdOf(req.url ?? "");
1674
+ if (accountId === void 0) return json(res, 400, { error: "accountId is required" });
1675
+ if (!isQoderAccountId(accountId)) return json(res, 400, { error: "accountId is not a well-formed account id" });
1676
+ const store = deps.store(region);
1677
+ if ((await store.vaultAccounts()).find((entry) => entry.id === accountId) === void 0) return json(res, 409, {
1678
+ reason: "account-not-deletable",
1679
+ error: "only accounts added by this plugin can be deleted"
1680
+ });
1681
+ const removed = await store.remove(accountId);
1682
+ if (deps.selectedAccountId(region) === accountId) await deps.clearSelection?.(region);
1683
+ const prefs = pool.preferences(region);
1684
+ if (prefs.memberAccountIds.includes(accountId)) pool.updateMembership(region, prefs.memberAccountIds.filter((id) => id !== accountId), prefs.enabled);
1685
+ return json(res, 200, { removed });
1686
+ });
1687
+ mount(QODER_TRANSFER_PATH, async (req, res) => {
1688
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "forbidden" });
1689
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1690
+ const region = regionOf(res, req.url ?? "");
1691
+ if (region === void 0) return;
1692
+ const action = transferActionOf(req.url ?? "");
1693
+ if (action === void 0) return json(res, 400, { error: "action is required (export|preview|import)" });
1694
+ if (deps.transfer === void 0) return json(res, 503, {
1695
+ reason: "transfer-unavailable",
1696
+ error: "transfer unavailable"
1697
+ });
1698
+ const transfer = deps.transfer;
1699
+ const body = await readBody(req);
1700
+ let parsed = {};
1701
+ try {
1702
+ parsed = JSON.parse(body || "{}");
1703
+ } catch {
1704
+ return json(res, 400, { error: "invalid JSON body" });
1705
+ }
1706
+ if (action === "export") return json(res, 200, await transfer.exportAccounts(region));
1707
+ if (action === "preview") {
1708
+ const text = typeof parsed.text === "string" ? parsed.text : "";
1709
+ return json(res, 200, await transfer.previewImport(region, text));
1710
+ }
1711
+ const text = typeof parsed.text === "string" ? parsed.text : "";
1712
+ const indexes = Array.isArray(parsed.indexes) ? parsed.indexes.filter((i) => typeof i === "number" && Number.isFinite(i)) : [];
1713
+ return json(res, 200, await transfer.importAccounts(region, text, indexes));
1714
+ });
1715
+ mount(QODER_SAVE_PATH, async (req, res) => {
1716
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "forbidden" });
1717
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1718
+ const bodyText = await readBody(req);
1719
+ let body;
1720
+ try {
1721
+ body = JSON.parse(bodyText);
1722
+ } catch {
1723
+ return json(res, 400, { error: "invalid JSON body" });
1724
+ }
1725
+ const field = body.field;
1726
+ if (typeof field !== "string" || field === "") return json(res, 400, { error: "field is required" });
1727
+ try {
1728
+ await deps.settings.set(field, mergeSlotWise(field, deps.settings.describe(), body.value));
1729
+ return json(res, 200, { ok: true });
1730
+ } catch (error) {
1731
+ return json(res, 500, { error: safeMessage(error) });
1732
+ }
1733
+ });
1734
+ const mounted = { status: (region) => buildWebUsage(deps, region) };
1735
+ ctx.effect(() => () => {
1736
+ for (const dispose of disposers) try {
1737
+ dispose();
1738
+ } catch {}
1739
+ });
1740
+ return mounted;
1741
+ }
1742
+ /** Project one account entry to its web shape with the selection flag. */
1743
+ function webAccountOf(deps, region) {
1744
+ return (account) => ({
1745
+ id: account.id,
1746
+ accountName: account.accountName,
1747
+ source: account.source,
1748
+ selected: account.id === deps.selectedAccountId(region) || account.source !== "vault" && deps.selectedAccountId(region) === void 0
1749
+ });
1750
+ }
1751
+ /**
1752
+ * 按区域槽位合并一次 settings 写入。
1753
+ *
1754
+ * 客户端只带它要写的那一个区域(`{ [region]: value }`)——它没有另一侧的当前
1755
+ * 状态,无法自己做合并;而宿主有 `describe()`,所以合并必须发生在这里。
1756
+ *
1757
+ * 规则:
1758
+ * - `regions.<region>` 是**槽位级**的:请求里出现的槽位整份替换(槽位内部的
1759
+ * 字段级合并已经在客户端完成——`nextRegionModels` 从现槽位起 merge),未出现
1760
+ * 的槽位**原样保留**;
1761
+ * - 若宿主当前没有这个槽位,则整份采用请求值。
1762
+ *
1763
+ * 这条规则是必须的,不是优化:缺了它,保存任一区域都会静默删除另一区域的全部
1764
+ * 配置(用户实拍「保存模型保存不了」)。
1765
+ */
1766
+ function mergeSlotWise(field, described, incoming) {
1767
+ if (field !== "regions") return incoming;
1768
+ if (!isRecord(incoming)) return incoming;
1769
+ const current = described.find((entry) => entry.value !== void 0)?.value;
1770
+ const currentRegions = isRecord(current) && isRecord(current["regions"]) ? current["regions"] : void 0;
1771
+ if (currentRegions === void 0) return incoming;
1772
+ const merged = { ...currentRegions };
1773
+ for (const [region, value] of Object.entries(incoming)) merged[region] = isRecord(value) && isRecord(currentRegions[region]) ? {
1774
+ ...currentRegions[region],
1775
+ ...value
1776
+ } : value;
1777
+ return {
1778
+ ...isRecord(current) ? current : {},
1779
+ regions: merged
1780
+ };
1781
+ }
1782
+ /** Whether a value is a plain (non-array, non-null) object. */
1783
+ function isRecord(value) {
1784
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1785
+ }
1786
+ /** Build one region's main usage document. */
1787
+ async function buildWebUsage(deps, region) {
1788
+ const sources = deps.credentialSources(region);
1789
+ const store = deps.store(region);
1790
+ const accounts = await store.accounts(sources);
1791
+ const selectedId = deps.selectedAccountId(region);
1792
+ let serving = await store.resolve(sources, selectedId);
1793
+ if (serving === void 0) {
1794
+ const selectionExplicit = selectedId !== void 0 && selectedId !== "";
1795
+ const selectionLost = selectionExplicit && accounts.length > 0;
1796
+ deps.regionUsable?.(region, false);
1797
+ const pool = deps.pool === void 0 ? void 0 : await buildPoolView(deps, region, accounts, deps.catalogs[region].current(), void 0);
1798
+ return {
1799
+ status: "signed-out",
1800
+ accounts: accounts.map(webAccountOf(deps, region)),
1801
+ enabled: deps.regionEnabled(region),
1802
+ showCreditsInMainUi: deps.regionCreditsShown(region),
1803
+ message: selectionLost ? "所选账号已不存在,请重新选择或填写 PAT" : "请填写 Qoder PAT(或设置环境变量)",
1804
+ selectionLost,
1805
+ selectionExplicit,
1806
+ ...pool === void 0 ? {} : { pool }
1807
+ };
1808
+ }
1809
+ deps.regionUsable?.(region, true);
1810
+ const catalog = deps.catalogs[region].current();
1811
+ const enabledKeys = new Set(deps.enabledModelKeys(region));
1812
+ const budgets = deps.contextBudgets(region);
1813
+ const models = catalog.map((model) => {
1814
+ const budget = budgets[model.key];
1815
+ const effectiveWindow = budget !== void 0 && model.contextTiers?.includes(budget) ? {
1816
+ contextWindow: budget,
1817
+ nativeContextWindow: model.contextWindow
1818
+ } : {
1819
+ contextWindow: model.contextWindow,
1820
+ nativeContextWindow: model.contextWindow
1821
+ };
1822
+ return {
1823
+ ...webModelOf(model),
1824
+ ...effectiveWindow
1825
+ };
1826
+ });
1827
+ const usageEntry = deps.usage(region);
1828
+ const offKeysRaw = deps.offModelKeys(region);
1829
+ const offModelKeys = catalog.filter((m) => offKeysRaw.includes(m.key) || !m.supportsThinking).map((m) => m.key);
1830
+ const credentialRejected = usageEntry?.error === "credential-rejected";
1831
+ let recovery;
1832
+ if (credentialRejected) recovery = deps.credentialRecovery === void 0 ? { reloginRequired: accounts.length <= 1 } : await deps.credentialRecovery(region, serving.id);
1833
+ let pool;
1834
+ if (deps.pool !== void 0) pool = await buildPoolView(deps, region, accounts, catalog, serving.id);
1835
+ return {
1836
+ status: "signed-in",
1837
+ accountId: serving.id,
1838
+ accountName: serving.accountName,
1839
+ region,
1840
+ enabled: deps.regionEnabled(region),
1841
+ showCreditsInMainUi: deps.regionCreditsShown(region),
1842
+ source: serving.source,
1843
+ selectionExplicit: selectedId !== void 0 && selectedId !== "",
1844
+ accounts: accounts.map(webAccountOf(deps, region)),
1845
+ models,
1846
+ enabledModelKeys: models.filter((m) => enabledKeys.has(m.key)).map((m) => m.key),
1847
+ offModelKeys,
1848
+ imageModelKeys: deps.imageModelKeys(region).filter((key) => catalog.some((m) => m.key === key)),
1849
+ ...usageEntry?.usage?.credits === void 0 ? {} : { credits: usageEntry.usage.credits },
1850
+ ...usageEntry?.error !== void 0 ? { creditsError: redactQoderText(usageEntry.error) } : {},
1851
+ ...credentialRejected && recovery ? {
1852
+ credentialRejected: true,
1853
+ recovery
1854
+ } : {},
1855
+ contextBudgets: budgets,
1856
+ thinkingEfforts: deps.thinkingEfforts(region),
1857
+ ...pool === void 0 ? {} : { pool }
1858
+ };
1859
+ }
1860
+ /** Build the pool's web view, including per-account rows tied to the probes. */
1861
+ async function buildPoolView(deps, region, accounts, catalog, servingAccountId) {
1862
+ const pool = deps.pool;
1863
+ const prefs = pool.preferences(region);
1864
+ const probes = pool.poolProbes(region);
1865
+ const now = Date.now();
1866
+ const memberSet = new Set(prefs.memberAccountIds);
1867
+ prefs.targetModelId;
1868
+ const targetModels = catalog.map((m) => ({
1869
+ id: m.key,
1870
+ name: m.displayName,
1871
+ creditMultiplier: m.isFree === true ? 0 : m.priceFactor,
1872
+ contextWindow: m.contextWindow
1873
+ }));
1874
+ const target = resolveTargetModel(targetModels, prefs.targetModelId);
1875
+ const checkinSupported = pool.checkinSupported(region);
1876
+ const checkinSnapshot = pool.checkinSnapshot?.(region) ?? {};
1877
+ const checkinLegacy = pool.checkinState?.(region) ?? {};
1878
+ const effectiveIds = [];
1879
+ const rows = [];
1880
+ const memberCredits = await pool.memberCredits?.(region, prefs.memberAccountIds).catch(() => void 0);
1881
+ for (const account of accounts) {
1882
+ const member = memberSet.has(account.id);
1883
+ const probe = probes[account.id];
1884
+ const excludedBy = exclusionOf(probe, now);
1885
+ const isCurrent = servingAccountId !== void 0 && account.id === servingAccountId;
1886
+ if (member) {
1887
+ if (await pool.credentialFor(region, account.id) !== void 0) effectiveIds.push(account.id);
1888
+ }
1889
+ const reading = member === true ? memberCredits?.get(account.id) : void 0;
1890
+ rows.push({
1891
+ accountId: account.id,
1892
+ accountName: account.accountName,
1893
+ source: account.source,
1894
+ ...reading === void 0 ? {} : {
1895
+ credits: reading.total,
1896
+ expiringSoon: reading.expiringSoon,
1897
+ ...reading.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: reading.nearestExpiryMs }
1898
+ },
1899
+ ...probe === void 0 ? {} : { probe: {
1900
+ outcome: probe.outcome,
1901
+ atMs: probe.atMs,
1902
+ ...probe.source === void 0 ? {} : { source: probe.source },
1903
+ ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
1904
+ ...probe.message === void 0 ? {} : { message: probe.message }
1905
+ } },
1906
+ ...excludedBy === void 0 ? {} : { excludedBy },
1907
+ ...checkinSupported && checkinSnapshot[account.id] !== void 0 ? {
1908
+ checkedInToday: checkinSnapshot[account.id].claimed,
1909
+ ...checkinSnapshot[account.id].refreshAtMs === void 0 ? {} : { checkinRefreshAtMs: checkinSnapshot[account.id].refreshAtMs }
1910
+ } : checkinSupported && checkinLegacy[account.id] !== void 0 ? { checkedInToday: checkinLegacy[account.id] === true } : {},
1911
+ current: isCurrent,
1912
+ member
1913
+ });
1914
+ }
1915
+ return {
1916
+ enabled: prefs.enabled,
1917
+ checkinSupported: pool.checkinSupported(region),
1918
+ ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
1919
+ ...target.source === "stale" && target.staleModelId !== void 0 ? { staleTargetModelId: target.staleModelId } : {},
1920
+ targetModelSource: target.source,
1921
+ memberAccountIds: prefs.memberAccountIds,
1922
+ ...prefs.probeInputTokens && prefs.probeInputTokens > 0 ? { probeInputTokens: prefs.probeInputTokens } : {},
1923
+ effectiveMemberAccountIds: effectiveIds,
1924
+ accounts: rows,
1925
+ catalog: targetModels
1926
+ };
1927
+ }
1928
+ /**
1929
+ * Test every account against one model, one at a time.
1930
+ *
1931
+ * The model is resolved by the caller (see `resolveTargetModel`) so this
1932
+ * function never has to decide "free or not". Never throws: a failed probe is
1933
+ * a ROW, not an aborted batch.
1934
+ *
1935
+ * `onRow` fires the moment one account's row exists, so a caller can report
1936
+ * progress instead of waiting for the SLOWEST member.
1937
+ */
1938
+ async function testAllAccounts(targets, modelId, deps, onRow) {
1939
+ const rows = [];
1940
+ for (const [index, target] of targets.entries()) {
1941
+ if (index > 0) await (deps.wait ?? defaultWait)(400);
1942
+ const row = await testOne(target, modelId, deps);
1943
+ rows.push(row);
1944
+ onRow?.(row);
1945
+ }
1946
+ return rows;
1947
+ }
1948
+ /** One account's probe, reduced to its row. */
1949
+ async function testOne(target, modelId, deps) {
1950
+ const base = {
1951
+ accountId: target.accountId,
1952
+ accountName: target.accountName
1953
+ };
1954
+ let credential;
1955
+ try {
1956
+ credential = await deps.credentialFor(target.accountId);
1957
+ } catch (error) {
1958
+ return {
1959
+ ...base,
1960
+ result: {
1961
+ modelId,
1962
+ outcome: "credential-rejected",
1963
+ message: messageOf(error)
1964
+ }
1965
+ };
1966
+ }
1967
+ if (credential === void 0) return {
1968
+ ...base,
1969
+ result: {
1970
+ modelId,
1971
+ outcome: "credential-rejected",
1972
+ message: "no stored credential for this account"
1973
+ }
1974
+ };
1975
+ return {
1976
+ ...base,
1977
+ result: await deps.probe(credential, modelId)
1978
+ };
1979
+ }
1980
+ /** The default inter-account pause. */
1981
+ function defaultWait(ms) {
1982
+ return new Promise((resolve) => setTimeout(resolve, ms));
1983
+ }
1984
+ /** A safe message from an unknown throwable, bounded in length. */
1985
+ function messageOf(error) {
1986
+ return (error instanceof Error ? error.message : String(error)).slice(0, 300);
1987
+ }
1988
+ //#endregion
1989
+ //#region src/index.ts
1990
+ const name = "dsh-connect-qoder";
1991
+ /** The model registry and settings service required before providers register. */
1992
+ const inject = ["llm", "settings"];
1993
+ /** The namespace the HOST actually serves this plugin under (workbuddy probe). */
1994
+ function settingsNamespaceOf(ctx) {
1995
+ const id = ctx?.fiber?.entry?.options?.id;
1996
+ return typeof id === "string" && id !== "" ? id : QODER_SETTINGS_NS;
1997
+ }
1998
+ function asVolatile(schema) {
1999
+ if (typeof schema.volatile === "function") return schema.volatile();
2000
+ return schema;
2001
+ }
2002
+ /** Unwrap a schemastery volatile's reactive `.get()` into a plain snapshot. */
2003
+ function unwrapVolatile(value) {
2004
+ if (value !== null && typeof value === "object" && "get" in value && typeof value.get === "function") return value.get();
2005
+ return value;
2006
+ }
2007
+ const modelConfig = z.object({
2008
+ key: z.string(),
2009
+ displayName: z.string(),
2010
+ priceFactor: z.number(),
2011
+ contextWindow: z.number(),
2012
+ maxOutputTokens: z.number(),
2013
+ supportsImages: z.boolean(),
2014
+ supportsThinking: z.boolean(),
2015
+ canDisableThinking: z.boolean(),
2016
+ isDefault: z.boolean(),
2017
+ isFree: z.boolean(),
2018
+ isNew: z.boolean(),
2019
+ /**
2020
+ * 该模型声明的思考强度档位(`['xhigh','low','medium']` 之类,已按强度升序)。
2021
+ *
2022
+ * 必须在这里声明,否则 zod 解析保存的 `lastCatalog` 时会**静默剥掉**它 ——
2023
+ * 客户端明明写进了 payload,宿主读回来却没有,思考强度选择器于是整个不渲染
2024
+ * (用户实拍「拉取的思考模式又不见了」)。`contextTiers` 同理:它在客户端被
2025
+ * 写入,但同样没被声明,于是上下文预算选择器也会随之失效。
2026
+ */
2027
+ thinkingEfforts: z.array(z.string()).required(false),
2028
+ defaultThinkingEffort: z.string().required(false),
2029
+ contextTiers: z.array(z.number()).required(false)
2030
+ });
2031
+ const poolConfig = z.object({
2032
+ enabled: z.boolean().default(false),
2033
+ targetModelId: z.string().default(""),
2034
+ probeInputTokens: z.number().default(0),
2035
+ memberAccountIds: z.array(z.string()).default([])
2036
+ });
2037
+ const regionConfig = z.object({
2038
+ /** 本区域 PAT。留空读环境变量;env 与 vault 承接连带选择。 */
2039
+ pat: z.string().default("").description("Qoder PAT(留空时读取环境变量)"),
2040
+ /** 本区域 provider 是否启用;显式 false 才停用(opt-out)。 */
2041
+ enabled: z.boolean().default(true),
2042
+ /** 用户显式选择的账号 id('' = 自动:settings → env → vault)。 */
2043
+ accountId: z.string().default(""),
2044
+ /** 是否在主界面展示积分(composer 面板);opt-out。 */
2045
+ showCreditsInMainUi: z.boolean().default(true),
2046
+ /** 用户勾选的模型 key 集合。 */
2047
+ enabledModelKeys: z.array(z.string()).default([]),
2048
+ /**
2049
+ * 用户**显式**开启图片输入的模型 key 集合。
2050
+ *
2051
+ * 图像能力只来自这里,绝不从上游的 `is_vl` 平台标记推断(见
2052
+ * src/native-modality.ts:平台标记几乎对每个模型都是 true,含纯文本模型)。
2053
+ * 与 lastCatalog 分开存储,使勾选状态不随目录重写而耦合丢失。
2054
+ */
2055
+ imageModelKeys: z.array(z.string()).default([]),
2056
+ /** 用户关闭的模型 key 集合(独立于硬性关闭)。 */
2057
+ offModelKeys: z.array(z.string()).default([]),
2058
+ /** 每个模型的显式上下文预算(必须是模型声明的 tier,否则用原生值)。 */
2059
+ contextBudgets: z.dict(z.number()).default({}),
2060
+ /**
2061
+ * 每个模型保存的思考强度(档位名,如 `low` / `medium` / `xhigh`)。
2062
+ *
2063
+ * 「设置里选好、之后每个请求自动带上」——与 Qoder 客户端同形态(它的事件
2064
+ * 名就叫 `reasoning-effort-changed`)。缺省表示用模型自己声明的默认档。
2065
+ * 键是模型 key:不同模型的档位名不统一(Qwen 系 low/medium/xhigh,
2066
+ * DeepSeek/GLM 系 low/high/max),所以必须逐模型存。
2067
+ */
2068
+ thinkingEfforts: z.dict(z.string()).default({}),
2069
+ /** 账号池偏好。 */
2070
+ pool: poolConfig.default({
2071
+ enabled: false,
2072
+ targetModelId: "",
2073
+ probeInputTokens: 0,
2074
+ memberAccountIds: []
2075
+ }),
2076
+ /** 保存的模型目录(卡片“保存”时写入)。 */
2077
+ lastCatalog: z.array(modelConfig).default([])
2078
+ });
2079
+ const Config = z.object({ regions: asVolatile(z.dict(regionConfig).default({}).description("Per-region Qoder settings, keyed cn | global")) });
2080
+ /**
2081
+ * 一份目录是否**声明了**思考档位。
2082
+ *
2083
+ * 「一个模型都没有档位」有两种完全不同的含义,必须分开:
2084
+ * 1. 上游当前没声明(`thinkingEfforts` 字段缺席或为空)—— 那它是「上游没
2085
+ * 档位」的诚实答案;
2086
+ * 2. 这份目录来自一次没拉到档位的时刻 —— 本机实测 14 个 cn 模型的
2087
+ * `thinkingEfforts` 全是 `[]`,是旧 schema 把字段剥掉时的产物。用它当答案,
2088
+ * 卡片就不渲染档位选择器(用户实拍「思考强度有时候有、有时候没有」:点一次
2089
+ * 「从 Qoder 刷新」就有,重启后就没)。
2090
+ *
2091
+ * 两种情况从数据上分不开(空数组与缺席都判「没声明」),要不要重拉由**目录的
2092
+ * 来源**决定:调用方只在「本进程还没成功拉到实时目录」时才据此触发重拉(见
2093
+ * displayModels)—— 实时目录没档位是上游的诚实答案,重拉多少次都一样。
2094
+ *
2095
+ * 判据用 `length > 0` 而不是「字段在不在」:旧 schema 留下的是**空数组**而不是
2096
+ * 缺席,所以「字段在」恰恰不能作为「声明过」的证据。
2097
+ */
2098
+ function catalogSnapshotDeclaresEfforts(models) {
2099
+ return models.some((model) => (model.thinkingEfforts?.length ?? 0) > 0);
2100
+ }
2101
+ /**
2102
+ * 「手上的目录能不能当档位答案」——**判据与动作打包在一起**。
2103
+ *
2104
+ * 拆开成两个函数(判据一个、调用处自己判)会留下一条没被测试覆盖的接线:把调用处
2105
+ * 变异掉,单测仍然全绿(第一版就是这么自我欺骗的)。这里让规则自己执行动作并
2106
+ * 返回结论,于是「动作有没有真的发生」也能被断言。
2107
+ *
2108
+ * 检测到「一个档位都没声明」时**不改动要服务的模型列表**:模型不能从卡片上
2109
+ * 消失,缺的只是档位 —— 后台拉回来之后的下一次读取自然补上。返回 `stale`
2110
+ * 标记这次调用有没有触发过拉取。
2111
+ */
2112
+ function serveSavedCatalog(models, onStale) {
2113
+ if (catalogSnapshotDeclaresEfforts(models)) return {
2114
+ models,
2115
+ stale: false
2116
+ };
2117
+ onStale();
2118
+ return {
2119
+ models,
2120
+ stale: true
2121
+ };
2122
+ }
2123
+ /** 某区域的配置状态(缺省返回空对象)。 */
2124
+ function regionStateOf(config, region) {
2125
+ const stored = unwrapVolatile(config.regions)?.[region];
2126
+ if (stored !== void 0 && stored !== null && typeof stored === "object") return stored;
2127
+ return {};
2128
+ }
2129
+ /** 某区域是否启用(opt-out)。 */
2130
+ function regionEnabled(config, region) {
2131
+ return regionStateOf(config, region).enabled !== false;
2132
+ }
2133
+ /** 某区域是否在主界面显示积分(opt-out)。 */
2134
+ function regionCreditsShown(config, region) {
2135
+ return regionStateOf(config, region).showCreditsInMainUi !== false;
2136
+ }
2137
+ /** 某区域是否配置了凭据(设置 PAT 或环境变量其一)。 */
2138
+ function regionHasCredential(config, region) {
2139
+ const state = regionStateOf(config, region);
2140
+ if ((typeof state.pat === "string" ? state.pat.trim() : "") !== "") return true;
2141
+ return patFromEnv(region) !== void 0;
2142
+ }
2143
+ /** 某区域显式选择的账号 id(undefined = 自动)。 */
2144
+ function selectedAccountId(config, region) {
2145
+ const id = regionStateOf(config, region).accountId;
2146
+ return typeof id === "string" && id !== "" ? id : void 0;
2147
+ }
2148
+ /** 某区域保存的 contextBudgets。 */
2149
+ function contextBudgetsOf(config, region) {
2150
+ const raw = regionStateOf(config, region).contextBudgets;
2151
+ if (raw === null || raw === void 0 || typeof raw !== "object") return {};
2152
+ const out = {};
2153
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
2154
+ return out;
2155
+ }
2156
+ /** 某区域保存的 offModelKeys。 */
2157
+ function offModelKeysOf(config, region) {
2158
+ const state = regionStateOf(config, region);
2159
+ return Array.isArray(state.offModelKeys) ? state.offModelKeys : [];
2160
+ }
2161
+ /** 某区域保存的逐模型思考强度(模型 key → 档位名)。 */
2162
+ function thinkingEffortsOf(config, region) {
2163
+ const raw = regionStateOf(config, region).thinkingEfforts;
2164
+ if (raw === null || raw === void 0 || typeof raw !== "object") return {};
2165
+ const out = {};
2166
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "string" && value !== "") out[key] = value;
2167
+ return out;
2168
+ }
2169
+ /** 某区域保存的 imageModelKeys(用户显式开启图片输入的模型)。 */
2170
+ function imageModelKeysOf(config, region) {
2171
+ const state = regionStateOf(config, region);
2172
+ return Array.isArray(state.imageModelKeys) ? state.imageModelKeys : [];
2173
+ }
2174
+ /**
2175
+ * 把用户的**显式**图片 opt-in 盖印到模型列表上——这是 `supportsImages`
2176
+ * 在运行时的**唯一**来源,上游的 `is_vl` 平台标记一律不采信。
2177
+ *
2178
+ * 与 workbuddy 的 `withImageSelection` 同构:所有运行时目录路径(启动种子、
2179
+ * 刷新、保存)都经过它,保证同一模型在三条路径上得到一致的图像能力答案。
2180
+ * 用户从未保存过(集合为空)时,用 `imageDefaultFor` 的厂商表结论作种子,
2181
+ * 使首次打开就能显示正确默认,而不必先保存一次。
2182
+ */
2183
+ function withImageSelection(models, images, seeded) {
2184
+ return models.map((model) => ({
2185
+ ...model,
2186
+ supportsImages: images.has(model.key) ? true : seeded ? false : imageDefaultFor({
2187
+ id: model.key,
2188
+ ...model.supportsImages === void 0 ? {} : { supportsImages: model.supportsImages }
2189
+ })
2190
+ }));
2191
+ }
2192
+ /**
2193
+ * 把用户保存的「可关闭思考」选择盖印到运行时目录上。
2194
+ *
2195
+ * 上游声明的 `canDisableThinking` 并不可全信(见 src/off-thinking.ts)。运行时
2196
+ * 适配器只读模型自报的该字段,所以必须在此之前把校正后的答案写上去。用户尚未
2197
+ * 保存过(选择为空)时用 `defaultOffModelIds` 作为种子——只对上游显式声明可关闭、
2198
+ * 且不在已知拒绝集里的模型提供 off。
2199
+ */
2200
+ function stampOffSelection(config, region, models) {
2201
+ const saved = new Set(offModelKeysOf(config, region));
2202
+ return withEffectiveOff(models, saved.size > 0 ? saved : new Set(defaultOffModelIds(models.map(offCapabilityOf))));
2203
+ }
2204
+ /**
2205
+ * 把用户的两项显式选择(图片输入、可关闭思考)盖印到运行时目录上。
2206
+ *
2207
+ * 两者机制相同:上游声明不可全信,用户的选择是唯一权威;未保存过时用厂商表/
2208
+ * 内置规则作种子。合在一处便于所有运行时目录路径(启动、刷新、保存)一次盖齐,
2209
+ * 避免漏盖导致「某条路径上模型能力与另一条不一致」。
2210
+ */
2211
+ function stampRuntimeCatalog(config, region, models) {
2212
+ const images = new Set(imageModelKeysOf(config, region));
2213
+ return stampOffSelection(config, region, withImageSelection(models, images, images.size > 0));
2214
+ }
2215
+ /** 某区域保存的 enabledModelKeys。 */
2216
+ function enabledModelKeysOf(config, region) {
2217
+ const state = regionStateOf(config, region);
2218
+ return Array.isArray(state.enabledModelKeys) ? state.enabledModelKeys : [];
2219
+ }
2220
+ /** 某区域保存的 pool 偏好。 */
2221
+ function poolPreferencesOf(config, region) {
2222
+ const raw = regionStateOf(config, region).pool ?? {};
2223
+ return {
2224
+ enabled: raw.enabled === true,
2225
+ targetModelId: typeof raw.targetModelId === "string" ? raw.targetModelId : "",
2226
+ probeInputTokens: typeof raw.probeInputTokens === "number" ? raw.probeInputTokens : 0,
2227
+ memberAccountIds: Array.isArray(raw.memberAccountIds) ? raw.memberAccountIds : []
2228
+ };
2229
+ }
2230
+ /** 把保存的目录条目解析为模型信息(历史兼容)。 */
2231
+ function modelFromStored(raw) {
2232
+ if (typeof raw.key !== "string" || typeof raw.displayName !== "string") return void 0;
2233
+ return {
2234
+ key: raw.key,
2235
+ displayName: raw.displayName,
2236
+ priceFactor: typeof raw.priceFactor === "number" ? raw.priceFactor : 1,
2237
+ contextWindow: typeof raw.contextWindow === "number" ? raw.contextWindow : 2e5,
2238
+ maxOutputTokens: typeof raw.maxOutputTokens === "number" ? raw.maxOutputTokens : 32768,
2239
+ supportsImages: raw.supportsImages === true,
2240
+ supportsThinking: raw.supportsThinking === true,
2241
+ canDisableThinking: raw.canDisableThinking === true,
2242
+ isDefault: raw.isDefault === true,
2243
+ isFree: raw.isFree === true,
2244
+ isNew: raw.isNew === true,
2245
+ ...Array.isArray(raw.contextTiers) ? { contextTiers: raw.contextTiers.filter((v) => typeof v === "number") } : {},
2246
+ ...Array.isArray(raw.thinkingEfforts) && raw.thinkingEfforts.length > 0 ? { thinkingEfforts: raw.thinkingEfforts.filter((v) => typeof v === "string") } : {},
2247
+ ...typeof raw.defaultThinkingEffort === "string" && raw.defaultThinkingEffort !== "" ? { defaultThinkingEffort: raw.defaultThinkingEffort } : {}
2248
+ };
2249
+ }
2250
+ /** 某区域当前应展示的目录(保存 → 运行期 → fallback)。 */
2251
+ function configuredModels(config, region, live) {
2252
+ const saved = regionStateOf(config, region).lastCatalog;
2253
+ if (Array.isArray(saved) && saved.length > 0) {
2254
+ const parsed = saved.map((item) => modelFromStored(item ?? {})).filter((m) => m !== void 0);
2255
+ if (parsed.length > 0) return parsed;
2256
+ }
2257
+ return live.length > 0 ? live : QODER_FALLBACK_MODELS.map(fallbackModelInfo);
2258
+ }
2259
+ /** 把一次用量解析成卡片可渲染的积分包。 */
2260
+ function creditsOf(usage) {
2261
+ const packages = [];
2262
+ let total = 0;
2263
+ let expiringSoon = 0;
2264
+ let nearestExpiryMs;
2265
+ const SOON_MS = 2592e5;
2266
+ const add = (packageName, remaining, size, expiresAtMs) => {
2267
+ if (size <= 0) return;
2268
+ total += remaining;
2269
+ if (expiresAtMs !== void 0 && remaining > 0) {
2270
+ nearestExpiryMs = nearestExpiryMs === void 0 ? expiresAtMs : Math.min(nearestExpiryMs, expiresAtMs);
2271
+ if (expiresAtMs - Date.now() < SOON_MS) expiringSoon += 1;
2272
+ }
2273
+ packages.push({
2274
+ packageName,
2275
+ remain: remaining,
2276
+ size,
2277
+ monthly: false,
2278
+ ...expiresAtMs === void 0 ? {} : { expiresAtMs }
2279
+ });
2280
+ };
2281
+ const userQuota = usage.userQuota;
2282
+ if (userQuota !== void 0 && typeof userQuota.total === "number") add("base", Math.max(userQuota.total - (userQuota.used ?? 0), 0), userQuota.total);
2283
+ const addOnQuota = usage.addOnQuota;
2284
+ if (addOnQuota !== void 0 && typeof addOnQuota.total === "number") add("addon", Math.max(addOnQuota.total - (addOnQuota.used ?? 0), 0), addOnQuota.total, usage.expiresAt);
2285
+ const org = usage.orgResourcePackage;
2286
+ if (org !== void 0 && typeof org.cap === "number") add("org", Math.max(org.cap - (org.used ?? 0), 0), org.cap);
2287
+ return {
2288
+ total,
2289
+ packages,
2290
+ expiringSoon,
2291
+ ...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
2292
+ };
2293
+ }
2294
+ function apply(ctx, config) {
2295
+ const settingsNs = settingsNamespaceOf(ctx);
2296
+ const log = (message) => ctx.logger.warn(`dsh-connect-qoder: ${message}`);
2297
+ let stopped = false;
2298
+ const stacks = {};
2299
+ const usageCache = {};
2300
+ const registration = {
2301
+ cn: {},
2302
+ global: {}
2303
+ };
2304
+ /**
2305
+ * 今日签到状态快照(区域 → 成员 id → 是否已领)。签到批次回写、成员表读取。
2306
+ * 进程内即可:它描述的是「今天」,重启后由下一次签到重新写入。
2307
+ */
2308
+ const checkinState = {
2309
+ cn: {},
2310
+ global: {}
2311
+ };
2312
+ /**
2313
+ * 每区域的签到快照:每个成员「是否已领」+「下一窗何时刷新」(epoch 毫秒,
2314
+ * 来自上游活动行的 `endAt`)。
2315
+ *
2316
+ * 为什么不能只留 `checkinState` 那个布尔表:「已领取」这句话离开「还有多久
2317
+ * 刷新」是不完整的 —— 领取不是「今天到此为止」,只是这一窗从 `CLAIMABLE`
2318
+ * 变成 `CLAIMED`,下一窗什么时候开上游早就写在 `endAt` 里了(实测活动窗是
2319
+ * 当天 10:00 → 次日 09:59 UTC+8)。用户实拍里官网页面上那个 `09:22:23`
2320
+ * 就是这个 `endAt − 当前时间`。
2321
+ *
2322
+ * 同样只在进程内:它描述的是「今天」,重启后由下一次签到重新写入。
2323
+ */
2324
+ const checkinSnapshots = {
2325
+ cn: {},
2326
+ global: {}
2327
+ };
2328
+ /**
2329
+ * 每区域最近一次**实时**拉到的完整目录(含上游声明的思考档位),未过滤。
2330
+ *
2331
+ * 为什么要单独存:`stacks[region].catalog` 装的是**过滤后**(只含勾选模型)
2332
+ * 的运行时目录,卡片不能用它;而保存的 `lastCatalog` 可能是**旧快照**,
2333
+ * 上游新声明的思考档位会缺失(用户实拍「还是没有思考强度」——即使刚刚
2334
+ * 「从 Qoder 刷新」成功过,卡片读的仍是那份没有档位的旧快照)。
2335
+ * 实时目录优先于保存快照,才能反映上游当前声明了什么。
2336
+ */
2337
+ const fullCatalog = {
2338
+ cn: [],
2339
+ global: []
2340
+ };
2341
+ /**
2342
+ * 卡片展示用的模型列表(workbuddy 的 `displayModels` 同规)。
2343
+ *
2344
+ * 与运行时目录(`stacks[region].catalog`)的区别只有一条,但它是关键:
2345
+ * **运行时目录只含用户勾选的模型**(DSH 的选择器读它),而卡片必须看到**完整
2346
+ * 目录**,否则未勾选的模型根本不出现在卡片里,用户再也勾不上它们。
2347
+ *
2348
+ * 实时目录(`fullCatalog`)优先于运行时目录:实时目录反映上游**当前**声明了
2349
+ * 什么(含思考档位),而运行时目录是启动时由保存的 lastCatalog 装配的 ——
2350
+ * 那是上一次保存时的旧快照(issue #32 的教训:保存槽位丢了目录时必须显示
2351
+ * 实时目录,否则卡片会把用户正在用的模型报成「已下架」)。
2352
+ *
2353
+ * 「思考强度有时候有、有时候没有」(用户实拍)的机制也在这条读路径上:
2354
+ * `fullCatalog` 是纯内存的、只在启动时拉一次;启动那次失败的话,卡片每次
2355
+ * 读到的都是启动装配的旧快照 —— 一个档位都没有,直到用户手动点一次
2356
+ * 「从 Qoder 刷新」。所以读到「本进程还没成功拉过实时目录 + 手上的目录一个
2357
+ * 档位都没声明」时,**触发一次后台重拉**(fire-and-forget,见
2358
+ * ensureCatalogRefresh)并且**继续服务手上的目录** —— 模型不能从卡片上消失,
2359
+ * 缺的只是档位,拉回来之后下一次读取自然补上。
2360
+ */
2361
+ function displayModels(region) {
2362
+ const full = fullCatalog[region];
2363
+ const live = full.length > 0 ? full : stacks[region].catalog.current();
2364
+ if (full.length === 0) serveSavedCatalog(live, () => {
2365
+ ensureCatalogRefresh(region);
2366
+ });
2367
+ return live;
2368
+ }
2369
+ /**
2370
+ * 幂等地触发一次该区域的目录拉取(**不等待**)。
2371
+ *
2372
+ * 为什么不等待:`displayModels` 是**读**路径,被 `/usage` 路由同步调用;在里面
2373
+ * await 网络会让「卡片读一次快照」这个纯读动作每次都等一次上游。改为
2374
+ * fire-and-forget + 在飞标记去重,拉取完成后下一次读取自然拿到新目录。
2375
+ *
2376
+ * 为什么需要它:目录此前**只在启动时拉一次**(`refreshModels`),而 `fullCatalog`
2377
+ * 是纯内存的。于是「思考强度」在重启后必然退化成那份空快照 —— 除非用户恰好
2378
+ * 去点一次「从 Qoder 刷新」。有没有点过刷新、以及进程活多久,就是「有时候有、
2379
+ * 有时候没有」的全部来源。
2380
+ */
2381
+ const catalogRefreshInFlight = {};
2382
+ const ensureCatalogRefresh = (region) => {
2383
+ if (catalogRefreshInFlight[region] === true) return;
2384
+ catalogRefreshInFlight[region] = true;
2385
+ refreshModels(region).finally(() => {
2386
+ catalogRefreshInFlight[region] = false;
2387
+ });
2388
+ };
2389
+ let invalidateCatalog = () => {};
2390
+ for (const region of QODER_REGIONS) {
2391
+ const upstream = new QoderUpstreamClient(region, { logger: log });
2392
+ const catalog = new QoderCatalog(QODER_FALLBACK_MODELS.map(fallbackModelInfo));
2393
+ const store = new QoderAccountStore(region);
2394
+ let poolRuntime = {};
2395
+ readPoolProbes(region).then((probes) => {
2396
+ poolRuntime = probes;
2397
+ });
2398
+ stacks[region] = {
2399
+ upstream,
2400
+ catalog,
2401
+ shim: createQoderShim({
2402
+ upstream,
2403
+ credential: () => resolveServingPat(region),
2404
+ modelKeyOf: (selector) => catalog.keyOf(selector),
2405
+ supportsImagesOf: (selector) => catalog.supportsImagesOf(selector),
2406
+ reasoningEffortOf: (selector) => {
2407
+ const key = catalog.keyOf(selector);
2408
+ if (key === void 0) return void 0;
2409
+ return thinkingEffortsOf(config, region)[key];
2410
+ },
2411
+ logger: log,
2412
+ resolveServing: async () => {
2413
+ const serving = await resolveServing(region);
2414
+ return serving === void 0 ? void 0 : {
2415
+ accountId: serving.id,
2416
+ accountName: serving.accountName,
2417
+ pat: serving.pat
2418
+ };
2419
+ },
2420
+ failoverAccount: async (tried) => nextFailoverCredential(region, tried),
2421
+ onServingFailure: (accountId, kind, _message) => {
2422
+ const outcome = FAIL_OVER_OUTCOME[kind];
2423
+ if (outcome !== void 0 && accountId !== "") {
2424
+ poolRuntime[accountId] = {
2425
+ outcome,
2426
+ atMs: Date.now(),
2427
+ source: "live-request"
2428
+ };
2429
+ writePoolProbes(region, { [accountId]: poolRuntime[accountId] });
2430
+ log(`pool ${region}: ${maskPat(accountId)} marked ${outcome} by live request`);
2431
+ }
2432
+ }
2433
+ }),
2434
+ store,
2435
+ probeClient: buildProbeClient(region),
2436
+ poolRuntime
2437
+ };
2438
+ }
2439
+ /** The region's source PATs (settings + env). */
2440
+ function sourcesOf(region) {
2441
+ const state = regionStateOf(config, region);
2442
+ const settingsPat = typeof state.pat === "string" ? state.pat.trim() : "";
2443
+ const envPat = patFromEnv(region);
2444
+ return {
2445
+ ...settingsPat === "" ? {} : { settingsPat },
2446
+ ...envPat === void 0 ? {} : { envPat }
2447
+ };
2448
+ }
2449
+ /** The region's auto-resolved serving PAT (settings → env). Vault is async. */
2450
+ function resolveServingPat(region) {
2451
+ const sources = sourcesOf(region);
2452
+ if (sources.settingsPat) return sources.settingsPat;
2453
+ if (sources.envPat) return sources.envPat;
2454
+ }
2455
+ /** Full serving credential via the store (honors selection + vault). */
2456
+ async function resolveServing(region) {
2457
+ return stacks[region].store.resolve(sourcesOf(region), selectedAccountId(config, region));
2458
+ }
2459
+ /** Next usable PAT for failover: rankPool over members, skip tried. */
2460
+ async function nextFailoverCredential(region, tried) {
2461
+ const store = stacks[region].store;
2462
+ const memberIds = poolPreferencesOf(config, region).memberAccountIds;
2463
+ const probes = stacks[region].poolRuntime;
2464
+ const now = Date.now();
2465
+ const snapshot = now - (poolCreditsSnapshotAt[region] ?? 0) <= POOL_CREDITS_SNAPSHOT_MS ? poolCreditsSnapshot[region] : void 0;
2466
+ const candidates = [];
2467
+ for (const accountId of memberIds) {
2468
+ if (tried.includes(accountId)) continue;
2469
+ const credit = await store.credentialFor(accountId, sourcesOf(region));
2470
+ if (credit === void 0) continue;
2471
+ const probe = probes[accountId];
2472
+ const reading = snapshot?.get(accountId);
2473
+ candidates.push({
2474
+ account: {
2475
+ id: credit.id,
2476
+ accountName: credit.accountName
2477
+ },
2478
+ ...reading === void 0 ? {} : { credits: reading },
2479
+ ...probe === void 0 ? {} : { probe }
2480
+ });
2481
+ }
2482
+ const chosen = rankPool(candidates, now)[0];
2483
+ if (chosen === void 0 || chosen.excludedBy !== void 0) return void 0;
2484
+ const credit = await store.credentialFor(chosen.account.id, sourcesOf(region));
2485
+ return credit === void 0 ? void 0 : {
2486
+ accountId: credit.id,
2487
+ accountName: credit.accountName,
2488
+ pat: credit.pat
2489
+ };
2490
+ }
2491
+ /** 成员积分快照:请求路径不能上网,排名用卡片路由最近一次的读数。 */
2492
+ const poolCreditsSnapshot = {};
2493
+ const POOL_CREDITS_SNAPSHOT_MS = 6e5;
2494
+ const poolCreditsSnapshotAt = {};
2495
+ /**
2496
+ * 池成员的最新积分(每成员一次 usage 读数)。由卡片路由付费——卡片正在看的
2497
+ * 数字与排名快照同一次读取(workbuddy 的 poolMembersOf 同规),单个账号失败
2498
+ * 只让那一行显示「—」,不拖垮整张表。
2499
+ */
2500
+ async function poolMemberCredits(region, accountIds) {
2501
+ const store = stacks[region].store;
2502
+ const SOON_MS = 2592e5;
2503
+ const entries = await Promise.all(accountIds.map(async (accountId) => {
2504
+ const credit = await store.credentialFor(accountId, sourcesOf(region)).catch(() => void 0);
2505
+ if (credit === void 0) return void 0;
2506
+ const usage = await stacks[region].upstream.fetchUsage(credit.pat).catch(() => void 0);
2507
+ if (usage === void 0) return void 0;
2508
+ const view = creditsOf(usage);
2509
+ const expiringSoon = view.packages.reduce((sum, pkg) => pkg.expiresAtMs !== void 0 && pkg.expiresAtMs - Date.now() < SOON_MS ? sum + pkg.remain : sum, 0);
2510
+ return [accountId, {
2511
+ total: view.total,
2512
+ expiringSoon,
2513
+ ...view.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: view.nearestExpiryMs }
2514
+ }];
2515
+ }));
2516
+ const map = new Map(entries.filter((entry) => entry !== void 0));
2517
+ poolCreditsSnapshot[region] = map;
2518
+ poolCreditsSnapshotAt[region] = Date.now();
2519
+ return map;
2520
+ }
2521
+ /** A probe client for one region backed by chatStream. */
2522
+ function buildProbeClient(region) {
2523
+ return { probeChat: async (credential, modelKey, opts) => {
2524
+ let firstBytes = 0;
2525
+ let error;
2526
+ try {
2527
+ await stacks[region].upstream.chatStream(credential.pat, modelKey, [{
2528
+ role: "user",
2529
+ content: probeUserMessage(opts.inputTokens)
2530
+ }], {
2531
+ ...opts.signal === void 0 ? {} : { signal: opts.signal },
2532
+ isReasoning: false,
2533
+ isVl: false,
2534
+ onDelta: (delta) => {
2535
+ if (firstBytes === 0) firstBytes = cheapProbeLength(delta);
2536
+ }
2537
+ });
2538
+ } catch (caught) {
2539
+ if (isQoderUpstreamError(caught)) error = caught;
2540
+ else throw caught;
2541
+ }
2542
+ if (error !== void 0) {
2543
+ const probeError = {
2544
+ kind: error.kind,
2545
+ status: error.status,
2546
+ message: error.message
2547
+ };
2548
+ return {
2549
+ ok: false,
2550
+ status: error.status,
2551
+ retryAfter: null,
2552
+ body: error.message,
2553
+ error: probeError
2554
+ };
2555
+ }
2556
+ return {
2557
+ ok: true,
2558
+ status: 200,
2559
+ retryAfter: null
2560
+ };
2561
+ } };
2562
+ }
2563
+ /** Cheap text length of a delta for probe first-byte accounting. */
2564
+ function cheapProbeLength(delta) {
2565
+ let length = delta.content.length;
2566
+ if (delta.reasoningContent.length > 0) length += delta.reasoningContent.length;
2567
+ if (delta.toolCalls !== void 0) length += delta.toolCalls.length;
2568
+ return length;
2569
+ }
2570
+ /** One real probe of one model through one credential. Never throws. */
2571
+ async function probeThrough(region, credential, modelId, signal) {
2572
+ return probeModel({
2573
+ client: stacks[region].probeClient,
2574
+ credential,
2575
+ modelKey: modelId,
2576
+ nowMs: Date.now(),
2577
+ ...signal === void 0 ? {} : { signal }
2578
+ });
2579
+ }
2580
+ /**
2581
+ * 对一个区域勾选的成员逐个签到(领取当日 Credits)。
2582
+ *
2583
+ * 与 `test` 批次同构:只跑**真的有凭据**的成员(幽灵 id 跳过,见 test 里那段
2584
+ * 注释),逐个之间留间隔避免撞限流。每个成员独立成败——一个账号失败不影响
2585
+ * 其余账号,结果逐行回报(workbuddy 的 checkin 批次同规)。
2586
+ *
2587
+ * 三种状态分得很清:`claimed`(这次领到)/ `already`(今天已领,幂等,
2588
+ * **不是**失败)/ `failed`(真的失败:凭据被拒、上游报错)。
2589
+ */
2590
+ async function runCheckinBatch(region) {
2591
+ const store = stacks[region].store;
2592
+ const prefs = poolPreferencesOf(config, region);
2593
+ const client = stacks[region].upstream;
2594
+ const rows = [];
2595
+ let first = true;
2596
+ for (const accountId of prefs.memberAccountIds) {
2597
+ const credit = await store.credentialFor(accountId, sourcesOf(region));
2598
+ if (credit === void 0) continue;
2599
+ if (!first) await new Promise((resolve) => setTimeout(resolve, 400));
2600
+ first = false;
2601
+ const base = {
2602
+ accountId,
2603
+ accountName: credit.accountName
2604
+ };
2605
+ try {
2606
+ const outcome = await client.claimDailyCredits(credit.pat);
2607
+ const refresh = outcome.status === "unavailable" ? {} : outcome.nextRefreshAtMs === void 0 ? {} : { refreshAtMs: outcome.nextRefreshAtMs };
2608
+ if (outcome.status === "claimed") rows.push({
2609
+ ...base,
2610
+ status: "claimed",
2611
+ ...outcome.amount === void 0 ? {} : { credit: outcome.amount },
2612
+ ...outcome.validityDays === void 0 ? {} : { validityDays: outcome.validityDays },
2613
+ ...refresh
2614
+ });
2615
+ else if (outcome.status === "already-claimed") rows.push({
2616
+ ...base,
2617
+ status: "already",
2618
+ ...refresh
2619
+ });
2620
+ else rows.push({
2621
+ ...base,
2622
+ status: "failed",
2623
+ message: outcome.message
2624
+ });
2625
+ } catch (error) {
2626
+ rows.push({
2627
+ ...base,
2628
+ status: "failed",
2629
+ message: error instanceof Error ? error.message : String(error)
2630
+ });
2631
+ }
2632
+ }
2633
+ const next = { ...checkinState[region] ?? {} };
2634
+ const nextSnapshot = { ...checkinSnapshots[region] ?? {} };
2635
+ for (const row of rows) if (row.status === "claimed" || row.status === "already") {
2636
+ next[row.accountId] = true;
2637
+ nextSnapshot[row.accountId] = {
2638
+ claimed: true,
2639
+ ...row.refreshAtMs === void 0 ? {} : { refreshAtMs: row.refreshAtMs }
2640
+ };
2641
+ } else if (row.status === "failed") {
2642
+ next[row.accountId] = false;
2643
+ nextSnapshot[row.accountId] = { claimed: false };
2644
+ }
2645
+ checkinState[region] = next;
2646
+ checkinSnapshots[region] = nextSnapshot;
2647
+ await refreshUsage(region).catch(() => {});
2648
+ return rows;
2649
+ }
2650
+ /** Cached usage read for one region. */
2651
+ function usageEntryOf(region) {
2652
+ const entry = usageCache[region];
2653
+ if (entry === void 0) return void 0;
2654
+ return {
2655
+ ...entry.usage === void 0 ? {} : { usage: {
2656
+ credits: creditsOf(entry.usage),
2657
+ atMs: entry.atMs
2658
+ } },
2659
+ ...entry.error === void 0 ? {} : { error: entry.error }
2660
+ };
2661
+ }
2662
+ const refreshUsage = async (region) => {
2663
+ const serving = await resolveServing(region);
2664
+ if (!serving) return;
2665
+ try {
2666
+ const usage = await stacks[region].upstream.fetchUsage(serving.pat);
2667
+ usageCache[region] = {
2668
+ usage,
2669
+ atMs: Date.now()
2670
+ };
2671
+ } catch (error) {
2672
+ const kind = isQoderUpstreamError(error) ? error.kind : void 0;
2673
+ usageCache[region] = {
2674
+ usage: void 0,
2675
+ atMs: Date.now(),
2676
+ error: kind === "auth" ? "credential-rejected" : kind ?? "unknown"
2677
+ };
2678
+ }
2679
+ };
2680
+ /** The region's catalog projected into the pool target picker's shape. */
2681
+ function poolModelsOf(region) {
2682
+ return stacks[region].catalog.current().map((m) => ({
2683
+ id: m.key,
2684
+ name: m.displayName,
2685
+ creditMultiplier: m.isFree === true ? 0 : m.priceFactor,
2686
+ contextWindow: m.contextWindow
2687
+ }));
2688
+ }
2689
+ /** On-demand model read for discovery + refresh. */
2690
+ async function discoverModels(region, signal) {
2691
+ const serving = await resolveServing(region);
2692
+ if (!serving) return [];
2693
+ return applyContextBudgets((await stacks[region].upstream.fetchModels(serving.pat, signal)).map(parseUpstreamModel), contextBudgetsOf(config, region));
2694
+ }
2695
+ /**
2696
+ * 拉取并安装一个区域的动态目录。返回安装后的目录;拉取失败抛错——
2697
+ * 路由层据此答 500,启动路径自己吞掉落回兜底目录。
2698
+ */
2699
+ async function refreshRegionCatalog(region, pat) {
2700
+ const stamped = stampRuntimeCatalog(config, region, applyContextBudgets((await stacks[region].upstream.fetchModels(pat)).map(parseUpstreamModel), contextBudgetsOf(config, region)));
2701
+ const saved = new Set(enabledModelKeysOf(config, region));
2702
+ fullCatalog[region] = stamped;
2703
+ stacks[region].catalog.set(deriveCatalog(stamped, saved));
2704
+ invalidateCatalog();
2705
+ return stamped;
2706
+ }
2707
+ /**
2708
+ * 路由层(卡片「从 Qoder 刷新」按钮)用的完整刷新:装目录 + 刷积分。
2709
+ * 此前的路由只调只读的 discoverModels——返回了新模型给卡片,却从不写入
2710
+ * 运行时目录,卡片随后重取 usage 文档看到的仍是旧目录,刷了个寂寞。
2711
+ */
2712
+ async function refreshRegionForRoute(region) {
2713
+ const serving = await resolveServing(region);
2714
+ if (!serving) throw new Error(`no PAT configured for ${region}`);
2715
+ const models = await refreshRegionCatalog(region, serving.pat);
2716
+ refreshUsage(region);
2717
+ return models;
2718
+ }
2719
+ const refreshModels = (region) => {
2720
+ return (async () => {
2721
+ const serving = await resolveServing(region);
2722
+ if (!serving) {
2723
+ log(`models refresh skipped for ${region}: no PAT configured`);
2724
+ return;
2725
+ }
2726
+ try {
2727
+ await refreshRegionCatalog(region, serving.pat);
2728
+ } catch (error) {
2729
+ log(`dynamic ${region} model catalog unavailable; serving the static fallback list: ${String(error?.message ?? error)}`);
2730
+ }
2731
+ refreshUsage(region);
2732
+ })();
2733
+ };
2734
+ const syncRegionRegistration = (value) => {
2735
+ for (const region of QODER_REGIONS) {
2736
+ const enabled = regionEnabled(value, region);
2737
+ const handle = registration[region].adapter;
2738
+ if (handle === void 0) continue;
2739
+ try {
2740
+ handle.replace(enabled ? [QODER_PROVIDERS[region]] : []);
2741
+ } catch (error) {
2742
+ log(`region ${region} adapter sync failed: ${String(error?.message ?? error)}`);
2743
+ }
2744
+ }
2745
+ const dirHandle = registration.cn.directory;
2746
+ if (dirHandle === void 0) return;
2747
+ try {
2748
+ dirHandle.replace(QODER_REGIONS.filter((region) => regionEnabled(value, region)).map((region) => ({
2749
+ provider: QODER_PROVIDERS[region],
2750
+ displayName: QODER_PROVIDER_DISPLAY_NAMES[region],
2751
+ settingsNs,
2752
+ settingsPath: [],
2753
+ declared: false
2754
+ })));
2755
+ } catch (error) {
2756
+ log(`provider directory sync failed: ${String(error?.message ?? error)}`);
2757
+ }
2758
+ };
2759
+ const applySelection = (value) => {
2760
+ for (const region of QODER_REGIONS) {
2761
+ const stamped = stampRuntimeCatalog(value, region, applyContextBudgets(configuredModels(value, region, stacks[region].catalog.current()), contextBudgetsOf(value, region)));
2762
+ const saved = new Set(enabledModelKeysOf(value, region));
2763
+ stacks[region].catalog.set(deriveCatalog(stamped, saved));
2764
+ }
2765
+ invalidateCatalog();
2766
+ syncRegionRegistration(value);
2767
+ for (const region of QODER_REGIONS) refreshUsage(region);
2768
+ };
2769
+ applySelection(config);
2770
+ const accountDeps = (region) => {
2771
+ const store = stacks[region].store;
2772
+ return {
2773
+ accounts: (sources) => store.accounts(sources ?? sourcesOf(region)),
2774
+ credentialFor: (accountId, sources) => store.credentialFor(accountId, sources ?? sourcesOf(region)),
2775
+ resolve: (sources, selectedId) => store.resolve(sources ?? sourcesOf(region), selectedId ?? selectedAccountId(config, region)),
2776
+ add: (pat, name) => store.add(pat, name),
2777
+ vaultAccounts: () => store.vaultAccounts(),
2778
+ remove: (accountId) => store.remove(accountId),
2779
+ rename: (accountId, name) => store.rename(accountId, name)
2780
+ };
2781
+ };
2782
+ const accountValidationDeps = { validateForRegion: async (region, pat) => {
2783
+ return { name: (await stacks[region].upstream.exchangeJobToken(pat)).name || "" };
2784
+ } };
2785
+ /** Region-dispatch table for the single pool deps the route gets. */
2786
+ function poolFor(region) {
2787
+ const store = stacks[region].store;
2788
+ return {
2789
+ preferences: (reg) => poolPreferencesOf(config, reg),
2790
+ catalog: (reg) => stacks[reg].catalog.current(),
2791
+ poolProbes: (reg) => stacks[reg].poolRuntime,
2792
+ credentialFor: (reg, accountId) => store.credentialFor(accountId, sourcesOf(reg)),
2793
+ probeOne: (reg, credit, modelId) => probeThrough(reg, credit, modelId),
2794
+ checkinSupported: () => true,
2795
+ checkin: async (reg) => runCheckinBatch(reg),
2796
+ test: async (reg) => {
2797
+ const prefs = poolPreferencesOf(config, reg);
2798
+ const target = resolveTargetModel(poolModelsOf(reg), prefs.targetModelId);
2799
+ if (target.modelId === void 0) return [];
2800
+ const deps = {
2801
+ credentialFor: (accountId) => store.credentialFor(accountId, sourcesOf(reg)),
2802
+ probe: (credit, modelId) => probeThrough(reg, credit, modelId)
2803
+ };
2804
+ const targets = [];
2805
+ for (const accountId of prefs.memberAccountIds) {
2806
+ const credit = await store.credentialFor(accountId, sourcesOf(reg));
2807
+ if (credit === void 0) continue;
2808
+ targets.push({
2809
+ accountId,
2810
+ accountName: credit.accountName
2811
+ });
2812
+ }
2813
+ const rows = await testAllAccounts(targets, target.modelId, deps);
2814
+ const probes = {};
2815
+ for (const row of rows) {
2816
+ const p = poolProbeFromResult(row.result);
2817
+ if (p) probes[row.accountId] = p;
2818
+ }
2819
+ stacks[reg].poolRuntime = {
2820
+ ...stacks[reg].poolRuntime,
2821
+ ...probes
2822
+ };
2823
+ writePoolProbes(reg, probes);
2824
+ return rows;
2825
+ },
2826
+ applyProbes: async (reg, probes) => {
2827
+ stacks[reg].poolRuntime = {
2828
+ ...stacks[reg].poolRuntime,
2829
+ ...probes
2830
+ };
2831
+ writePoolProbes(reg, probes);
2832
+ },
2833
+ updateMembership: (reg, memberAccountIds, enabled) => {
2834
+ submitRegionSettings(reg, { pool: {
2835
+ ...poolPreferencesOf(config, reg),
2836
+ memberAccountIds,
2837
+ ...enabled === void 0 ? {} : { enabled }
2838
+ } });
2839
+ }
2840
+ };
2841
+ }
2842
+ function buildPoolDeps() {
2843
+ return {
2844
+ preferences: (region) => poolPreferencesOf(config, region),
2845
+ catalog: (region) => stacks[region].catalog.current(),
2846
+ poolProbes: (region) => stacks[region].poolRuntime,
2847
+ memberCredits: (region, accountIds) => poolMemberCredits(region, accountIds),
2848
+ credentialFor: (region, accountId) => stacks[region].store.credentialFor(accountId, sourcesOf(region)),
2849
+ probeOne: (region, credit, modelId) => probeThrough(region, credit, modelId),
2850
+ test: (region, modelId) => poolFor(region).test(region, modelId),
2851
+ checkinSupported: (region) => poolFor(region).checkinSupported(region),
2852
+ checkin: (region) => poolFor(region).checkin(region),
2853
+ checkinState: (region) => checkinState[region],
2854
+ checkinSnapshot: (region) => checkinSnapshots[region],
2855
+ applyProbes: async (region, probes) => poolFor(region).applyProbes(region, probes),
2856
+ updateMembership: (region, memberAccountIds, enabled) => poolFor(region).updateMembership(region, memberAccountIds, enabled)
2857
+ };
2858
+ }
2859
+ /** Build the transfer deps once (region-dispatched). */
2860
+ const transferDeps = {
2861
+ exportAccounts: async (region) => {
2862
+ const { transferRecordsFor } = await import("./transfer-CUNaVvmi.js");
2863
+ return { accounts: transferRecordsFor(region, await stacks[region].store.vaultAccounts()) };
2864
+ },
2865
+ previewImport: async (_region, text) => {
2866
+ const { previewTransferFile } = await import("./transfer-CUNaVvmi.js");
2867
+ return previewTransferFile(text);
2868
+ },
2869
+ importAccounts: async (region, text, indexes) => {
2870
+ const { mergeTransferRecords } = await import("./transfer-CUNaVvmi.js");
2871
+ const { changes, imported, skipped } = mergeTransferRecords(region, text, indexes);
2872
+ for (const entry of changes.values()) await stacks[region].store.add(entry.pat, entry.accountName);
2873
+ return {
2874
+ imported,
2875
+ skipped
2876
+ };
2877
+ }
2878
+ };
2879
+ let settingsService;
2880
+ ctx.inject(["settings"], (sctx) => {
2881
+ settingsService = sctx.settings;
2882
+ });
2883
+ const pluginRow = () => {
2884
+ if (settingsService === void 0) return void 0;
2885
+ return settingsService.describe().find((row) => String(row.ns).includes("dsh-connect-qoder"));
2886
+ };
2887
+ /**
2888
+ * Two-level merge a settings field write, then `mutate` it (workbuddy's
2889
+ * `__save` contract, so a partial payload never deletes sibling regions).
2890
+ */
2891
+ async function mutateField(field, incoming) {
2892
+ const row = pluginRow();
2893
+ if (row === void 0) throw new Error("settings namespace not yet available");
2894
+ const current = unwrapVolatileDeep(row.value?.[field] ?? {});
2895
+ const patch = incoming ?? {};
2896
+ const merged = { ...current };
2897
+ for (const [key, slot] of Object.entries(patch)) {
2898
+ const prior = current[key];
2899
+ const isPlain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2900
+ merged[key] = isPlain(prior) && isPlain(slot) ? {
2901
+ ...prior,
2902
+ ...slot
2903
+ } : slot;
2904
+ }
2905
+ await settingsService.mutate(row.ns, [{
2906
+ op: "set",
2907
+ path: [field],
2908
+ value: merged
2909
+ }], void 0);
2910
+ }
2911
+ const settingsDeps = {
2912
+ describe: () => settingsService === void 0 ? [] : settingsService.describe(),
2913
+ set: async (field, value) => {
2914
+ if (field !== "regions") throw new Error(`unsupported settings field: ${String(field)}`);
2915
+ await mutateField(field, value);
2916
+ }
2917
+ };
2918
+ /** Write one region's settings patch (whole `regions` dict replace). */
2919
+ function submitRegionSettings(region, patch) {
2920
+ const regions = unwrapVolatile(config.regions) ?? {};
2921
+ mutateField("regions", {
2922
+ ...regions,
2923
+ [region]: {
2924
+ ...regions[region] ?? {},
2925
+ ...patch
2926
+ }
2927
+ });
2928
+ }
2929
+ /** Fresh per-account credit balances for the composer panel. */
2930
+ async function accountCreditsOf(region, signal) {
2931
+ const store = stacks[region].store;
2932
+ const accounts = await store.accounts(sourcesOf(region));
2933
+ const out = [];
2934
+ for (const account of accounts) {
2935
+ const pat = (await store.credentialFor(account.id, sourcesOf(region)))?.pat;
2936
+ if (!pat) continue;
2937
+ let usage;
2938
+ try {
2939
+ usage = await stacks[region].upstream.fetchUsage(pat, signal);
2940
+ } catch {
2941
+ usage = void 0;
2942
+ }
2943
+ out.push({
2944
+ id: account.id,
2945
+ accountName: account.accountName || account.source,
2946
+ selected: account.id === selectedAccountId(config, region) || account.source !== "vault" && selectedAccountId(config, region) === void 0,
2947
+ ...usage === void 0 ? {} : { credits: creditsOf(usage).total }
2948
+ });
2949
+ }
2950
+ return out;
2951
+ }
2952
+ /** The serving credential for a probe route (region's selection). */
2953
+ async function probeModelOf(region, modelId, signal) {
2954
+ const serving = await resolveServing(region);
2955
+ if (!serving) return {
2956
+ modelId,
2957
+ outcome: "credential-rejected",
2958
+ message: "未配置凭据"
2959
+ };
2960
+ return probeThrough(region, {
2961
+ pat: serving.pat,
2962
+ accountName: serving.accountName
2963
+ }, modelId, signal);
2964
+ }
2965
+ writeHostHeartbeat();
2966
+ ctx.inject(["webServer"], (ctxWithWeb) => {
2967
+ registerQoderWebRoutes(ctxWithWeb, {
2968
+ regionEnabled: (region) => regionEnabled(config, region),
2969
+ regionCreditsShown: (region) => regionCreditsShown(config, region),
2970
+ regionUsable: (region, usable) => {
2971
+ if (!usable) log(`region ${region} is signed out`);
2972
+ },
2973
+ store: accountDeps,
2974
+ credentialSources: sourcesOf,
2975
+ selectedAccountId: (region) => selectedAccountId(config, region),
2976
+ clearSelection: (region) => {
2977
+ submitRegionSettings(region, { accountId: "" });
2978
+ },
2979
+ catalogs: {
2980
+ cn: { current: () => [...displayModels("cn")] },
2981
+ global: { current: () => [...displayModels("global")] }
2982
+ },
2983
+ contextBudgets: (region) => contextBudgetsOf(config, region),
2984
+ thinkingEfforts: (region) => thinkingEffortsOf(config, region),
2985
+ offModelKeys: (region) => offModelKeysOf(config, region),
2986
+ imageModelKeys: (region) => imageModelKeysOf(config, region),
2987
+ enabledModelKeys: (region) => enabledModelKeysOf(config, region),
2988
+ discoverModels,
2989
+ refreshModels: refreshRegionForRoute,
2990
+ usage: usageEntryOf,
2991
+ probeModel: probeModelOf,
2992
+ accountCredits: accountCreditsOf,
2993
+ pool: buildPoolDeps(),
2994
+ transfer: transferDeps,
2995
+ accountValidation: accountValidationDeps,
2996
+ settings: settingsDeps,
2997
+ logger: log
2998
+ });
2999
+ });
3000
+ ctx.on("loader/volatile-update", () => applySelection(config));
3001
+ let disposed = false;
3002
+ ctx.effect(() => () => {
3003
+ if (disposed) return;
3004
+ disposed = true;
3005
+ stopped = true;
3006
+ for (const region of QODER_REGIONS) stacks[region].shim.close();
3007
+ clearHostHeartbeat();
3008
+ });
3009
+ Promise.all(QODER_REGIONS.map((region) => stacks[region].shim.ready)).then(async () => {
3010
+ if (stopped) return;
3011
+ try {
3012
+ const adapters = {};
3013
+ for (const region of QODER_REGIONS) {
3014
+ adapters[region] = createQoderAdapter({
3015
+ shim: stacks[region].shim,
3016
+ catalog: stacks[region].catalog,
3017
+ provider: QODER_PROVIDERS[region],
3018
+ displayName: QODER_PROVIDER_DISPLAY_NAMES[region],
3019
+ resolveAttachments: () => ctx.get("attachments")
3020
+ });
3021
+ stacks[region].adapter = adapters[region];
3022
+ }
3023
+ invalidateCatalog = () => {
3024
+ for (const region of QODER_REGIONS) adapters[region].invalidate();
3025
+ };
3026
+ const releaseAdapter = {};
3027
+ let releaseDirectory;
3028
+ try {
3029
+ for (const region of QODER_REGIONS) releaseAdapter[region] = registration[region].adapter = ctx.llm.registerAdapter([QODER_PROVIDERS[region]], adapters[region].adapter);
3030
+ releaseDirectory = registration.cn.directory = ctx.llm.registerConfigurableProviders(QODER_REGIONS.map((region) => ({
3031
+ provider: QODER_PROVIDERS[region],
3032
+ displayName: QODER_PROVIDER_DISPLAY_NAMES[region],
3033
+ settingsNs,
3034
+ settingsPath: [],
3035
+ declared: false
3036
+ })));
3037
+ } finally {
3038
+ if (QODER_REGIONS.some((region) => releaseAdapter[region] === void 0) || releaseDirectory === void 0) {
3039
+ for (const region of QODER_REGIONS) releaseAdapter[region]?.();
3040
+ releaseDirectory?.();
3041
+ }
3042
+ }
3043
+ ctx.effect(() => () => {
3044
+ for (const region of QODER_REGIONS) releaseAdapter[region]?.();
3045
+ releaseDirectory?.();
3046
+ });
3047
+ syncRegionRegistration(config);
3048
+ ctx.llm.registerModelDiscovery(settingsNs, async (request, signal) => {
3049
+ const region = regionOfProvider(request.provider ?? "");
3050
+ if (region === void 0) return [];
3051
+ if (!regionEnabled(config, region)) return [];
3052
+ try {
3053
+ const models = await discoverModels(region, signal);
3054
+ const saved = new Set(enabledModelKeysOf(config, region));
3055
+ return deriveCatalog(models, saved.size > 0 ? saved : initialEnabledKeys(models)).map((model) => ({
3056
+ id: model.key,
3057
+ name: modelDisplayName(model),
3058
+ contextWindow: model.contextWindow,
3059
+ maxTokens: model.maxOutputTokens,
3060
+ inputModalities: model.supportsImages ? ["text", "image"] : ["text"]
3061
+ }));
3062
+ } catch {
3063
+ return [];
3064
+ }
3065
+ });
3066
+ } catch (error) {
3067
+ ctx.logger.error("dsh-connect-qoder: provider registration failed", error);
3068
+ return;
3069
+ }
3070
+ for (const region of QODER_REGIONS) refreshModels(region);
3071
+ }).catch((error) => {
3072
+ ctx.logger.error("dsh-connect-qoder: loopback endpoint failed to start; providers not registered", error);
3073
+ });
3074
+ /** One batch result → a persisted pool probe. */
3075
+ function poolProbeFromResult(result) {
3076
+ return {
3077
+ outcome: result.outcome,
3078
+ atMs: Date.now(),
3079
+ source: "test-batch",
3080
+ ...result.retryAtMs === void 0 ? {} : { retryAtMs: result.retryAtMs },
3081
+ ...result.message === void 0 ? {} : { message: result.message }
3082
+ };
3083
+ }
3084
+ }
3085
+ const FAIL_OVER_OUTCOME = {
3086
+ auth: "credential-rejected",
3087
+ "rate-limited": "rate-limited",
3088
+ "quota-exhausted": "out-of-credit",
3089
+ network: "unavailable",
3090
+ transient: "unavailable"
3091
+ };
3092
+ //#endregion
3093
+ export { Config, QODER_CONNECT_VERSION, QODER_GITHUB_URL, apply, asVolatile, catalogSnapshotDeclaresEfforts, inject, name, regionEnabled, regionHasCredential, regionStateOf, serveSavedCatalog, settingsNamespaceOf, unwrapVolatile };