dsh-qoder-connect 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -22,6 +22,7 @@ Configure only what you use: a variant with no saved PAT shows no model group an
22
22
  - **Daily auto check-in & logs** — claims the daily 100 Credits automatically, **at a time you can set per variant on the card** (10:00 UTC+8 by default, the upstream's reset moment), with independent toggles and startup catch-up protection. The card features a dedicated *Check-in log* panel with audit trails, *Check in now*, *Refresh*, and *Clear logs* quick actions.
23
23
  - **Sidebar quota card + credit details** — the *“Qoder sidebar display”* card toggles a per-variant sidebar quota widget (off by default; each toggle needs its variant's PAT saved) with one shared refresh interval (default 5 min, minimum 1 min). Clicking the sidebar widget opens the *Qoder quota* panel: one row per credit package — *“Remaining / Total + bar | Expires”* — where a campaign grant (the international edition’s *SOTA Exclusive Credits*, for one) keeps its own upstream-given title and **its own expiry**. When the account reports no overall deadline (the upstream’s year-9999 sentinel), the panel states when the PAT was authorized rather than a fabricated date. Click the same widget again to close; click the other to switch variants.
24
24
  - **Rate display `x<priceFactor>`** — each model name is suffixed with the upstream-reported price multiplier (e.g. `Some Model · x0.79`, free is `x0`), spelled `x<n>` from the catalog's `price_factor`. Display only — it never changes the request; models whose rate the upstream did not report simply show no suffix.
25
+ - **Upstream queue hints are honored** — when Qoder answers a queued or throttled request (401/403/429 body) with `retryAfterSeconds`, the plugin turns it into a local `Retry-After` header and hands it to DSH as `failure.providerRetryAfterMs`; the host retry engine can then wait until the moment the upstream asked for, instead of hammering the same still-closed window with its local backoff.
25
26
 
26
27
  ## Screenshots
27
28
 
@@ -81,7 +82,7 @@ Enable daily automatic check-in in the settings card and set each variant's own
81
82
 
82
83
  Prerequisites:
83
84
 
84
- - DSH core `0.1.7-rc.1` or newer (this plugin peers on the `@deepseek-ai/*` `>=0.1.7-rc.1` line);
85
+ - DSH core `0.2.1-rc.1` or newer (this plugin peers on the `@deepseek-ai/*` `>=0.2.1-rc.1` line);
85
86
  - Node.js `^22.19.0 || >=24.0.0` (per `package.json` engines);
86
87
  - your own Qoder account and at least one Personal Access Token.
87
88
 
@@ -94,6 +95,15 @@ dsh plugin --profile web add dsh-qoder-connect
94
95
 
95
96
  Swap the `--profile` value for the profile you use (`web` / `desktop` / `tui`, e.g. `dsh plugin --profile desktop add dsh-qoder-connect`) — data stays inside that profile, so web / desktop / tui never collide. On a terminal-only profile (TUI) there is no browser card: manage the PAT and check status through the bundled CLI instead; the settings card and sidebar widgets require Web or Desktop.
96
97
 
98
+ > **DSH older than `0.2.1-rc.1`?** Use **[v0.2.5](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.2.5)** instead — the last release on the 0.1.7 line (DSH ≥ `0.1.7-rc.1`):
99
+ >
100
+ > ```sh
101
+ > # From GitHub (recommended, pin the tag)
102
+ > dsh plugin --profile web add github:masknull/dsh-qoder-connect#v0.2.5
103
+ > # Or from npm
104
+ > dsh plugin --profile web add dsh-qoder-connect@0.2.5
105
+ > ```
106
+ >
97
107
  > **DSH older than `0.1.7-rc.1`?** Use **[v0.2.0](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.2.0)** instead — the last release supporting the 0.1.5 line (DSH ≥ `0.1.5-rc.2`):
98
108
  >
99
109
  > ```sh
@@ -103,7 +113,7 @@ Swap the `--profile` value for the profile you use (`web` / `desktop` / `tui`, e
103
113
  > dsh plugin --profile web add dsh-qoder-connect@0.2.0
104
114
  > ```
105
115
  >
106
- > Upgrading from 0.1.5? This release removed the automatic migration of the old host's `settings.yaml`. To carry your old configuration over, migrate through **[v0.1.9](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.1.9)** (the first release with the data migration) first, then upgrade to this release.
116
+ > Upgrading from 0.1.5? Newer releases removed the automatic migration of the old host's `settings.yaml`. To carry your old configuration over, migrate through **[v0.1.9](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.1.9)** (the first release with the data migration) first, then upgrade.
107
117
 
108
118
  ## Configuration
109
119
 
package/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - **每日自动签到与日志明细**:支持每天自动签到领取 100 Credits 算力额度,**签到时刻可在卡片上自定义**(默认 10:00,即上游刷新时间,按 UTC+8),配备双端开关与开机防漏补签机制;卡片常驻「签到日志」面板,提供执行流水记录与「立即签到」、「刷新」、「清空日志」快捷操作。
23
23
  - **侧栏额度展示 + 额度明细**:「Qoder 侧栏展示」卡片可分别为两版开启侧栏底部的额度小卡(默认关,开启前需为该变体保存 PAT),共享一个刷新间隔(默认 5 分钟,最小 1 分钟)。点侧栏小卡在中间面板打开「Qoder 额度」明细:按资源包逐行给出「剩余 / 总量 + 进度条|到期时间」,活动赠送包(如国际版「SOTA 专属积分」)按上游给定的名称与**各自的到期日**单独成行;账号整体无到期(上游回 9999 占位值)时展示 PAT 的授权时间而非假日期;再点同一张卡关闭,点另一张切换到对应版本。
24
24
  - **倍率显示 `x<priceFactor>`**:模型名后直接拼上游报价的价格倍率(如 `某模型 · x0.79`,免费为 `x0`),来自目录接口的 `price_factor` 字段,仅作展示、不影响请求;上游未报倍率的模型不显示后缀,不会凭空补一个数。
25
+ - **尊重上游的排队建议**:Qoder 在排队/限流答复里给出 `retryAfterSeconds` 时(401/403/429 的 body),插件把它转成本机 `Retry-After` 响应头,并作为 `failure.providerRetryAfterMs` 交给 DSH 的重试策略;宿主重试引擎因此会等到上游要求的时刻再发起下一次请求,而不是用本地退避提前撞回同一个未打开的窗口。
25
26
 
26
27
  ## 界面一览
27
28
 
@@ -81,7 +82,7 @@
81
82
 
82
83
  前置条件:
83
84
 
84
- - DSH 核心 `0.1.7-rc.1` 及以上(本插件 peer 依赖 `@deepseek-ai/*` `>=0.1.7-rc.1` 线);
85
+ - DSH 核心 `0.2.1-rc.1` 及以上(本插件 peer 依赖 `@deepseek-ai/*` `>=0.2.1-rc.1` 线);
85
86
  - Node.js `^22.19.0 || >=24.0.0`(`package.json` engines);
86
87
  - 你自己的 Qoder 账号,以及至少一枚个人访问令牌。
87
88
 
@@ -94,6 +95,15 @@ dsh plugin --profile web add dsh-qoder-connect
94
95
 
95
96
  按你使用的 profile 换 `--profile` 的值(`web` / `desktop` / `tui`,如 `dsh plugin --profile desktop add dsh-qoder-connect`)。装到哪个 profile,数据就落在哪个 profile 目录里,web / desktop / tui 各自独立。没有浏览器卡片的终端环境(TUI)下依旧可用 CLI 保存 PAT 并驱动模型;设置卡片与侧栏额度卡需要 Web/Desktop 界面。
96
97
 
98
+ > **DSH 低于 `0.2.1-rc.1`?** 请改用最后一个支持 0.1.7 线的版本 **[v0.2.5](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.2.5)**(要求 DSH ≥ `0.1.7-rc.1`):
99
+ >
100
+ > ```sh
101
+ > # GitHub(推荐,指定 tag)
102
+ > dsh plugin --profile web add github:masknull/dsh-qoder-connect#v0.2.5
103
+ > # 或从 npm 安装
104
+ > dsh plugin --profile web add dsh-qoder-connect@0.2.5
105
+ > ```
106
+ >
97
107
  > **DSH 低于 `0.1.7-rc.1`?** 请改用最后一个支持 0.1.5 线的版本 **[v0.2.0](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.2.0)**(要求 DSH ≥ `0.1.5-rc.2`):
98
108
  >
99
109
  > ```sh
@@ -103,7 +113,7 @@ dsh plugin --profile web add dsh-qoder-connect
103
113
  > dsh plugin --profile web add dsh-qoder-connect@0.2.0
104
114
  > ```
105
115
  >
106
- > 从 0.1.5 升级上来的老用户:本版本已移除旧宿主 `settings.yaml` 的自动迁移;如需保留旧配置,请先经 **[v0.1.9](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.1.9)**(首个带数据迁移的版本)完成迁移后,再升级本版本。
116
+ > 从 0.1.5 升级上来的老用户:新版本已移除旧宿主 `settings.yaml` 的自动迁移;如需保留旧配置,请先经 **[v0.1.9](https://github.com/masknull/dsh-qoder-connect/releases/tag/v0.1.9)**(首个带数据迁移的版本)完成迁移后,再升级。
107
117
 
108
118
  ## 配置与使用
109
119
 
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { K as QoderCredentialStore, M as QoderCatalogStore, N as qoderCatalogPath, P as FALLBACK_QODER_MODELS, T as createQoderTransport, Y as qoderCredentialIdentity, _ as qoderProbePath, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderProbeStore, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, w as validateApiKey, y as QoderUpstreamClient } from "./variants-B_ZzENbC.js";
2
+ import { K as QoderCredentialStore, M as QoderCatalogStore, N as qoderCatalogPath, P as FALLBACK_QODER_MODELS, T as createQoderTransport, Y as qoderCredentialIdentity, _ as qoderProbePath, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderProbeStore, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, w as validateApiKey, y as QoderUpstreamClient } from "./variants-Dl1AbWre.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
package/lib/index.d.ts CHANGED
@@ -954,6 +954,18 @@ type QoderChatResult = {
954
954
  status: number;
955
955
  kind: UpstreamErrorKind;
956
956
  message: string;
957
+ /**
958
+ * The upstream's own "try again in N ms" answer (Qoder's queue body carries
959
+ * `retryAfterSeconds`, and a plain 429 may carry `Retry-After`).
960
+ *
961
+ * It has to survive this layer to be usable: the shim turns it into the
962
+ * `Retry-After` header and the `[retry-after=<n>s]` message tag, and the
963
+ * adapter reads that tag back into `failure.providerRetryAfterMs` — the
964
+ * only field the harness retry policy honors. Dropped here, the host can
965
+ * only fall back to its own backoff (observed: 19134ms against an upstream
966
+ * that asked for 30s, so every retry hit the still-closed window).
967
+ */
968
+ retryAfterMs?: number;
957
969
  };
958
970
  /** Provenance of the last successful catalog fetch. */
959
971
  interface QoderCatalogFetch {
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as configurePluginPaths, A as probeModel, B as QODER_PROBE_PATH, C as normalizeCredits, D as utc8WallClock, E as formatUtc8Date, F as QoderCatalog, G as QODER_PAT_ENV_GLOBAL, H as QODER_STATUS_PATH, I as QODER_AUTH_PATH, J as qoderCredentialId, K as QoderCredentialStore, L as QODER_GLOBAL_AUTH_PATH, M as QoderCatalogStore, N as qoderCatalogPath, O as getMachineId, P as FALLBACK_QODER_MODELS, Q as QODER_DATA_DIR_NAME, R as QODER_GLOBAL_PROBE_PATH, S as modelInfoOf, T as createQoderTransport, U as QODER_AUTH_FILENAME, V as QODER_SETTINGS_FACE_PATH, W as QODER_PAT_ENV_CN, X as qoderOwnAuthPath, Y as qoderCredentialIdentity, Z as QODER_DATA_DIR_ENV, _ as qoderProbePath, a as QODER_HOST_HEARTBEAT_FILENAME, b as classifyUpstreamError, c as processStartTimeMs, d as writeHostHeartbeat, et as qoderMachineIdPath, f as QODER_CONNECT_VERSION, g as newestFirst, h as fingerprintModel, i as variantFor, j as randomSentinel, k as PROBE_EFFORT_CANDIDATES, l as qoderHostHeartbeatPath, m as QoderProbeStore, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as QODER_PROBE_FILENAME, q as patTail, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, tt as qoderPluginDataDir, u as readHostHeartbeat, v as KIND_STATUS, w as validateApiKey, x as kindFromQoderFailure, y as QoderUpstreamClient, z as QODER_GLOBAL_STATUS_PATH } from "./variants-B_ZzENbC.js";
1
+ import { $ as configurePluginPaths, A as probeModel, B as QODER_PROBE_PATH, C as normalizeCredits, D as utc8WallClock, E as formatUtc8Date, F as QoderCatalog, G as QODER_PAT_ENV_GLOBAL, H as QODER_STATUS_PATH, I as QODER_AUTH_PATH, J as qoderCredentialId, K as QoderCredentialStore, L as QODER_GLOBAL_AUTH_PATH, M as QoderCatalogStore, N as qoderCatalogPath, O as getMachineId, P as FALLBACK_QODER_MODELS, Q as QODER_DATA_DIR_NAME, R as QODER_GLOBAL_PROBE_PATH, S as modelInfoOf, T as createQoderTransport, U as QODER_AUTH_FILENAME, V as QODER_SETTINGS_FACE_PATH, W as QODER_PAT_ENV_CN, X as qoderOwnAuthPath, Y as qoderCredentialIdentity, Z as QODER_DATA_DIR_ENV, _ as qoderProbePath, a as QODER_HOST_HEARTBEAT_FILENAME, b as classifyUpstreamError, c as processStartTimeMs, d as writeHostHeartbeat, et as qoderMachineIdPath, f as QODER_CONNECT_VERSION, g as newestFirst, h as fingerprintModel, i as variantFor, j as randomSentinel, k as PROBE_EFFORT_CANDIDATES, l as qoderHostHeartbeatPath, m as QoderProbeStore, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as QODER_PROBE_FILENAME, q as patTail, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, tt as qoderPluginDataDir, u as readHostHeartbeat, v as KIND_STATUS, w as validateApiKey, x as kindFromQoderFailure, y as QoderUpstreamClient, z as QODER_GLOBAL_STATUS_PATH } from "./variants-Dl1AbWre.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { dirname, join } from "node:path";
@@ -221,6 +221,245 @@ function registerQoderAuthRoute(ctx, deps, key) {
221
221
  }, "dsh-qoder-connect: PAT route");
222
222
  }
223
223
  //#endregion
224
+ //#region src/shim.ts
225
+ /**
226
+ * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
227
+ * shim guards the local boundary and hands the raw JSON body to the upstream
228
+ * client, which translates it onto the Qoder transport. It binds 127.0.0.1
229
+ * only and never serves another interface.
230
+ *
231
+ * Inbound hardening: the loopback bind alone is not a trust boundary (any
232
+ * local process or a DNS-rebinding page can reach 127.0.0.1), so every
233
+ * request must carry a loopback Host header, browser-sent Origins must be
234
+ * loopback, chat POSTs must be application/json, and the Authorization
235
+ * header must carry the shim's per-process shared secret. The plugin's
236
+ * own client satisfies all four by construction; local attackers cannot
237
+ * read the secret out of the plugin process's memory.
238
+ *
239
+ * @module dsh-qoder-connect/shim
240
+ */
241
+ const REQUEST_BODY_LIMIT = 67108864;
242
+ /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
243
+ function isJsonContentType(req) {
244
+ const type = req.headers["content-type"];
245
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
246
+ }
247
+ function writeJson(res, status, body, headers = {}) {
248
+ const payload = JSON.stringify(body);
249
+ res.writeHead(status, {
250
+ "Content-Type": "application/json",
251
+ "Content-Length": Buffer.byteLength(payload),
252
+ ...headers
253
+ });
254
+ res.end(payload);
255
+ }
256
+ /**
257
+ * The prefix the shim stamps onto a failure message carrying an upstream retry
258
+ * hint.
259
+ *
260
+ * The hint has to cross two text-only boundaries to reach the harness retry
261
+ * policy: pi-ai flattens an HTTP failure to its message and the adapter may only
262
+ * rewrite that message. So the marker is written here, in front of the 400-byte
263
+ * excerpt (never inside it, where truncation could cut it off), and read back by
264
+ * {@link retryAfterFromMessage}.
265
+ */
266
+ const RETRY_AFTER_TAG = /\[retry-after=(\d+)s\]/u;
267
+ /**
268
+ * The upstream's retry hint, in milliseconds, recovered from a shim failure
269
+ * message.
270
+ *
271
+ * The tagged form is the contract this plugin writes itself. The untagged
272
+ * `retryAfterSeconds` form is the fallback for a message the transport built
273
+ * before the tag existed (an installed older shim, or a caller that composed the
274
+ * message itself), and is the same field {@link qoderQueueSignal} reads.
275
+ */
276
+ function retryAfterFromMessage(message) {
277
+ const tagged = RETRY_AFTER_TAG.exec(message);
278
+ if (tagged !== null) {
279
+ const seconds = Number(tagged[1]);
280
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
281
+ }
282
+ const body = /"retryAfterSeconds"\s*:\s*(\d+)/u.exec(message);
283
+ if (body !== null) {
284
+ const seconds = Number(body[1]);
285
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
286
+ }
287
+ }
288
+ /**
289
+ * Seconds an OpenAI `Retry-After` header carries: the ceiling of the hint, so a
290
+ * client that honors it never returns before the upstream's window closed.
291
+ */
292
+ function retryAfterSeconds(retryAfterMs) {
293
+ return Math.max(1, Math.ceil(retryAfterMs / 1e3));
294
+ }
295
+ function writeOpenAIError(res, status, kind, message, retryAfterMs) {
296
+ const hint = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? Math.floor(retryAfterMs) : void 0;
297
+ if (hint === void 0) {
298
+ writeJson(res, status, { error: {
299
+ message,
300
+ type: kind,
301
+ code: kind
302
+ } });
303
+ return;
304
+ }
305
+ const seconds = retryAfterSeconds(hint);
306
+ writeJson(res, status, { error: {
307
+ message: `[retry-after=${seconds}s] ${message}`,
308
+ type: kind,
309
+ code: kind
310
+ } }, {
311
+ "Retry-After": String(seconds),
312
+ "retry-after-ms": String(hint)
313
+ });
314
+ }
315
+ /** Read a request body with a size cap; over-limit bodies fail the request. */
316
+ function readBody$1(req) {
317
+ return new Promise((resolve, reject) => {
318
+ const chunks = [];
319
+ let size = 0;
320
+ req.on("data", (chunk) => {
321
+ size += chunk.length;
322
+ if (size > REQUEST_BODY_LIMIT) {
323
+ reject(/* @__PURE__ */ new Error("request body too large"));
324
+ req.destroy();
325
+ return;
326
+ }
327
+ chunks.push(chunk);
328
+ });
329
+ req.on("end", () => resolve(Buffer.concat(chunks)));
330
+ req.on("error", reject);
331
+ });
332
+ }
333
+ /**
334
+ * Start the loopback endpoint. Requests carry any bearer; the loopback bind
335
+ * is the boundary, and the upstream credential comes from the store alone.
336
+ */
337
+ function createQoderShim(options) {
338
+ const { store, client, catalog } = options;
339
+ const logger = options.logger;
340
+ const providerId = options.providerId;
341
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
342
+ /** Constant-time bearer check; absent or mismatched bearers are rejected. */
343
+ function bearerOk(req) {
344
+ const header = req.headers.authorization;
345
+ if (typeof header !== "string") return false;
346
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
347
+ if (match === null) return false;
348
+ const presented = match[1];
349
+ const expected = SHARED_SECRET;
350
+ const a = Buffer.from(presented);
351
+ const b = Buffer.from(expected);
352
+ if (a.length !== b.length) return false;
353
+ return timingSafeEqual(a, b);
354
+ }
355
+ const server = createServer((req, res) => {
356
+ handle(req, res).catch((error) => {
357
+ logger?.warn("dsh-qoder-connect: loopback request failed", error);
358
+ try {
359
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", "loopback request failed");
360
+ else res.end();
361
+ } catch {}
362
+ });
363
+ });
364
+ const ready = new Promise((resolve, reject) => {
365
+ server.once("listening", () => resolve());
366
+ server.once("error", reject);
367
+ });
368
+ server.listen(0, "127.0.0.1");
369
+ const baseUrl = () => {
370
+ const address = server.address();
371
+ if (address === null || typeof address === "string") throw new Error(`${providerId} shim has no listening address`);
372
+ return `http://127.0.0.1:${address.port}`;
373
+ };
374
+ async function handle(req, res) {
375
+ try {
376
+ if (!hostIsLoopback(req.headers.host)) {
377
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
378
+ return;
379
+ }
380
+ if (!originIsLoopback(req.headers.origin)) {
381
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
382
+ return;
383
+ }
384
+ if (!bearerOk(req)) {
385
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
386
+ return;
387
+ }
388
+ const url = req.url ?? "/";
389
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
390
+ writeJson(res, 200, { ok: true });
391
+ return;
392
+ }
393
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
394
+ writeJson(res, 200, {
395
+ object: "list",
396
+ data: catalog.current().map((model) => ({
397
+ id: model.id,
398
+ object: "model",
399
+ created: 0,
400
+ owned_by: providerId
401
+ }))
402
+ });
403
+ return;
404
+ }
405
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
406
+ await chatCompletions(req, res);
407
+ return;
408
+ }
409
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
410
+ } catch (error) {
411
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
412
+ else res.end();
413
+ }
414
+ }
415
+ async function chatCompletions(req, res) {
416
+ if (!isJsonContentType(req)) {
417
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
418
+ return;
419
+ }
420
+ try {
421
+ await store.resolve();
422
+ } catch (error) {
423
+ writeOpenAIError(res, KIND_STATUS.missing_credential, "not_signed_in", String(error));
424
+ return;
425
+ }
426
+ const raw = (await readBody$1(req)).toString("utf8");
427
+ const controller = new AbortController();
428
+ req.on("close", () => controller.abort());
429
+ const result = await client.chatStream(raw, controller.signal);
430
+ if (!result.ok) {
431
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`, result.retryAfterMs);
432
+ return;
433
+ }
434
+ res.writeHead(200, {
435
+ "Content-Type": "text/event-stream",
436
+ "Cache-Control": "no-cache",
437
+ "Connection": "keep-alive",
438
+ "X-Accel-Buffering": "no"
439
+ });
440
+ let sawDone = false;
441
+ const body = Readable.fromWeb(result.response.body);
442
+ body.on("data", (chunk) => {
443
+ if (chunk.includes("[DONE]")) sawDone = true;
444
+ });
445
+ body.on("error", (error) => {
446
+ logger?.warn("dsh-qoder-connect: upstream stream failed mid-flight", error);
447
+ if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
448
+ });
449
+ body.pipe(res);
450
+ }
451
+ return {
452
+ ready,
453
+ baseUrl,
454
+ token: () => SHARED_SECRET,
455
+ close: () => new Promise((resolve, reject) => {
456
+ server.close(() => resolve());
457
+ server.closeAllConnections();
458
+ server.once("error", reject);
459
+ })
460
+ };
461
+ }
462
+ //#endregion
224
463
  //#region src/adapter.ts
225
464
  /**
226
465
  * The Qoder pi-ai provider: one loopback-backed adapter per variant route,
@@ -492,6 +731,64 @@ var QoderPiAiAdapter = class QoderPiAiAdapter extends PiAiAdapter {
492
731
  };
493
732
  }
494
733
  /**
734
+ * Carry the upstream's retry hint onto the finish failure.
735
+ *
736
+ * `dsh-llm-pi-ai` maps a failed pi-ai turn onto `{ message, code }` before the
737
+ * chunk reaches the loop: the HTTP status and headers pi-ai's own error object
738
+ * still holds are flattened away, so `failure.providerRetryAfterMs` — the one
739
+ * field the harness retry policy honors — never arrives. The policy then falls
740
+ * back to its local backoff, which is capped (20s in the report that prompted
741
+ * this) below Qoder's 30-second queue answer, so every retry hits the same
742
+ * closed window.
743
+ *
744
+ * The shim stamps `[retry-after=<n>s]` in front of its error message for this
745
+ * layer to recover; see `retryAfterFromMessage`. Rewriting only the field,
746
+ * never the message, keeps the text a human reads unchanged.
747
+ *
748
+ * Both entry points are wrapped because `PiAiAdapter` has two of them and the
749
+ * harness uses the other one: `prepareCall()` returns
750
+ * `stream: (options) => this.streamWithSnapshot(options, snapshot)`, which
751
+ * never calls `this.stream`. Overriding `stream` alone is dead code on every
752
+ * harness request (verified against a live session log: the finish failure
753
+ * arrived as bare `{ message, code }` while a direct `adapter.stream()` call
754
+ * carried the hint).
755
+ */
756
+ async *stream(options) {
757
+ yield* this.withRetryHint(super.stream(options));
758
+ }
759
+ async prepareCall(provider, model, signal) {
760
+ const prepared = await super.prepareCall(provider, model, signal);
761
+ return {
762
+ ...prepared,
763
+ stream: (options) => this.withRetryHint(prepared.stream(options))
764
+ };
765
+ }
766
+ /** Re-stamp `failure.providerRetryAfterMs` on every terminal failure chunk. */
767
+ async *withRetryHint(inner) {
768
+ for await (const chunk of inner) {
769
+ if (chunk.type === "finish") {
770
+ const { reason } = chunk;
771
+ if ((reason.kind === "error" || reason.kind === "aborted") && reason.failure.providerRetryAfterMs === void 0) {
772
+ const retryAfterMs = retryAfterFromMessage(reason.failure.message);
773
+ if (retryAfterMs !== void 0) {
774
+ yield {
775
+ ...chunk,
776
+ reason: {
777
+ ...reason,
778
+ failure: {
779
+ ...reason.failure,
780
+ providerRetryAfterMs: retryAfterMs
781
+ }
782
+ }
783
+ };
784
+ continue;
785
+ }
786
+ }
787
+ }
788
+ yield chunk;
789
+ }
790
+ }
791
+ /**
495
792
  * Resolve the request-image projection target for one route.
496
793
  *
497
794
  * `dsh-llm` 0.2.1 added `LlmAdapter.imageRequestTarget()` and its runtime calls
@@ -517,192 +814,6 @@ var QoderPiAiAdapter = class QoderPiAiAdapter extends PiAiAdapter {
517
814
  }
518
815
  };
519
816
  //#endregion
520
- //#region src/shim.ts
521
- /**
522
- * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
523
- * shim guards the local boundary and hands the raw JSON body to the upstream
524
- * client, which translates it onto the Qoder transport. It binds 127.0.0.1
525
- * only and never serves another interface.
526
- *
527
- * Inbound hardening: the loopback bind alone is not a trust boundary (any
528
- * local process or a DNS-rebinding page can reach 127.0.0.1), so every
529
- * request must carry a loopback Host header, browser-sent Origins must be
530
- * loopback, chat POSTs must be application/json, and the Authorization
531
- * header must carry the shim's per-process shared secret. The plugin's
532
- * own client satisfies all four by construction; local attackers cannot
533
- * read the secret out of the plugin process's memory.
534
- *
535
- * @module dsh-qoder-connect/shim
536
- */
537
- const REQUEST_BODY_LIMIT = 67108864;
538
- /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
539
- function isJsonContentType(req) {
540
- const type = req.headers["content-type"];
541
- return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
542
- }
543
- function writeJson(res, status, body) {
544
- const payload = JSON.stringify(body);
545
- res.writeHead(status, {
546
- "Content-Type": "application/json",
547
- "Content-Length": Buffer.byteLength(payload)
548
- });
549
- res.end(payload);
550
- }
551
- function writeOpenAIError(res, status, kind, message) {
552
- writeJson(res, status, { error: {
553
- message,
554
- type: kind,
555
- code: kind
556
- } });
557
- }
558
- /** Read a request body with a size cap; over-limit bodies fail the request. */
559
- function readBody$1(req) {
560
- return new Promise((resolve, reject) => {
561
- const chunks = [];
562
- let size = 0;
563
- req.on("data", (chunk) => {
564
- size += chunk.length;
565
- if (size > REQUEST_BODY_LIMIT) {
566
- reject(/* @__PURE__ */ new Error("request body too large"));
567
- req.destroy();
568
- return;
569
- }
570
- chunks.push(chunk);
571
- });
572
- req.on("end", () => resolve(Buffer.concat(chunks)));
573
- req.on("error", reject);
574
- });
575
- }
576
- /**
577
- * Start the loopback endpoint. Requests carry any bearer; the loopback bind
578
- * is the boundary, and the upstream credential comes from the store alone.
579
- */
580
- function createQoderShim(options) {
581
- const { store, client, catalog } = options;
582
- const logger = options.logger;
583
- const providerId = options.providerId;
584
- const SHARED_SECRET = randomBytes(32).toString("base64url");
585
- /** Constant-time bearer check; absent or mismatched bearers are rejected. */
586
- function bearerOk(req) {
587
- const header = req.headers.authorization;
588
- if (typeof header !== "string") return false;
589
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
590
- if (match === null) return false;
591
- const presented = match[1];
592
- const expected = SHARED_SECRET;
593
- const a = Buffer.from(presented);
594
- const b = Buffer.from(expected);
595
- if (a.length !== b.length) return false;
596
- return timingSafeEqual(a, b);
597
- }
598
- const server = createServer((req, res) => {
599
- handle(req, res).catch((error) => {
600
- logger?.warn("dsh-qoder-connect: loopback request failed", error);
601
- try {
602
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", "loopback request failed");
603
- else res.end();
604
- } catch {}
605
- });
606
- });
607
- const ready = new Promise((resolve, reject) => {
608
- server.once("listening", () => resolve());
609
- server.once("error", reject);
610
- });
611
- server.listen(0, "127.0.0.1");
612
- const baseUrl = () => {
613
- const address = server.address();
614
- if (address === null || typeof address === "string") throw new Error(`${providerId} shim has no listening address`);
615
- return `http://127.0.0.1:${address.port}`;
616
- };
617
- async function handle(req, res) {
618
- try {
619
- if (!hostIsLoopback(req.headers.host)) {
620
- writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
621
- return;
622
- }
623
- if (!originIsLoopback(req.headers.origin)) {
624
- writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
625
- return;
626
- }
627
- if (!bearerOk(req)) {
628
- writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
629
- return;
630
- }
631
- const url = req.url ?? "/";
632
- if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
633
- writeJson(res, 200, { ok: true });
634
- return;
635
- }
636
- if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
637
- writeJson(res, 200, {
638
- object: "list",
639
- data: catalog.current().map((model) => ({
640
- id: model.id,
641
- object: "model",
642
- created: 0,
643
- owned_by: providerId
644
- }))
645
- });
646
- return;
647
- }
648
- if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
649
- await chatCompletions(req, res);
650
- return;
651
- }
652
- writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
653
- } catch (error) {
654
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
655
- else res.end();
656
- }
657
- }
658
- async function chatCompletions(req, res) {
659
- if (!isJsonContentType(req)) {
660
- writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
661
- return;
662
- }
663
- try {
664
- await store.resolve();
665
- } catch (error) {
666
- writeOpenAIError(res, KIND_STATUS.missing_credential, "not_signed_in", String(error));
667
- return;
668
- }
669
- const raw = (await readBody$1(req)).toString("utf8");
670
- const controller = new AbortController();
671
- req.on("close", () => controller.abort());
672
- const result = await client.chatStream(raw, controller.signal);
673
- if (!result.ok) {
674
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${KIND_STATUS[result.kind]}): ${result.message.slice(0, 400)}`);
675
- return;
676
- }
677
- res.writeHead(200, {
678
- "Content-Type": "text/event-stream",
679
- "Cache-Control": "no-cache",
680
- "Connection": "keep-alive",
681
- "X-Accel-Buffering": "no"
682
- });
683
- let sawDone = false;
684
- const body = Readable.fromWeb(result.response.body);
685
- body.on("data", (chunk) => {
686
- if (chunk.includes("[DONE]")) sawDone = true;
687
- });
688
- body.on("error", (error) => {
689
- logger?.warn("dsh-qoder-connect: upstream stream failed mid-flight", error);
690
- if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
691
- });
692
- body.pipe(res);
693
- }
694
- return {
695
- ready,
696
- baseUrl,
697
- token: () => SHARED_SECRET,
698
- close: () => new Promise((resolve, reject) => {
699
- server.close(() => resolve());
700
- server.closeAllConnections();
701
- server.once("error", reject);
702
- })
703
- };
704
- }
705
- //#endregion
706
817
  //#region src/probe-service.ts
707
818
  /**
708
819
  * Serial probe runner. One instance is shared by the manual API and any
@@ -4625,6 +4625,17 @@ var ChunkEncoder = class {
4625
4625
  } }), SSE_DONE];
4626
4626
  }
4627
4627
  };
4628
+ /**
4629
+ * Normalize a provider retry hint for {@link QoderChatResult}.
4630
+ *
4631
+ * Only a positive finite number is a hint: the truncation keeps the shim's
4632
+ * `Retry-After` header an integer number of seconds, and `undefined` keeps the
4633
+ * optional field off the result entirely (no `retryAfterMs: undefined` noise in
4634
+ * the shim's error body).
4635
+ */
4636
+ function retryHintOf(value) {
4637
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? { retryAfterMs: Math.floor(value) } : {};
4638
+ }
4628
4639
  /** Turn any thrown failure into the shim's classified pre-stream result. */
4629
4640
  function failureResult(error) {
4630
4641
  if (error instanceof UpstreamRequestError) return {
@@ -4639,7 +4650,8 @@ function failureResult(error) {
4639
4650
  ok: false,
4640
4651
  status: typeof error.failure.status === "number" && error.failure.status >= 100 && error.failure.status <= 599 ? error.failure.status : KIND_STATUS[kind],
4641
4652
  kind,
4642
- message: error.failure.message
4653
+ message: error.failure.message,
4654
+ ...retryHintOf(error.failure.providerRetryAfterMs)
4643
4655
  };
4644
4656
  }
4645
4657
  return {
@@ -4819,7 +4831,8 @@ var QoderUpstreamClient = class {
4819
4831
  ok: false,
4820
4832
  status: earlyFailure.status ?? KIND_STATUS[kind],
4821
4833
  kind,
4822
- message: earlyFailure.message
4834
+ message: earlyFailure.message,
4835
+ ...retryHintOf(earlyFailure.providerRetryAfterMs)
4823
4836
  };
4824
4837
  }
4825
4838
  }
@@ -5234,7 +5247,7 @@ function newestFirst(records) {
5234
5247
  }
5235
5248
  //#endregion
5236
5249
  //#region src/version.ts
5237
- const QODER_CONNECT_VERSION = "0.2.7";
5250
+ const QODER_CONNECT_VERSION = "0.2.8";
5238
5251
  //#endregion
5239
5252
  //#region src/host-heartbeat.ts
5240
5253
  /**
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-qoder-connect",
3
3
  "displayName": "DSH Qoder Connect",
4
4
  "description": "将 Qoder(国内版 / 国际版)的模型接入 DeepSeek Harness — bring Qoder (CN & international) models into DeepSeek Harness.",
5
- "version": "0.2.7",
5
+ "version": "0.2.8",
6
6
  "author": "corrinehu",
7
7
  "keywords": [
8
8
  "dsh-plugin",