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 +12 -2
- package/README.md +12 -2
- package/lib/bin.js +1 -1
- package/lib/index.d.ts +12 -0
- package/lib/index.js +298 -187
- package/lib/{variants-B_ZzENbC.js → variants-Dl1AbWre.js} +16 -3
- package/package.json +1 -1
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
|
|
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?
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
5
|
+
"version": "0.2.8",
|
|
6
6
|
"author": "corrinehu",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"dsh-plugin",
|