dsh-qoder-connect 0.1.0 → 0.1.2

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
@@ -1,147 +1,147 @@
1
- # DSH Qoder Connect
2
-
3
- **English** | [简体中文](./README.md)
4
-
5
- A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that brings your **Qoder subscription** models into DSH through a Personal Access Token (PAT). One plugin serves both Qoder product lines — each with its own settings card, provider id, credential file and saved catalog, never mixed:
6
-
7
- | Variant | Provider id | Card title | Where the PAT is minted |
8
- |---|---|---|---|
9
- | China (国内版) | `qoder` | Qoder | qoder.com.cn |
10
- | International (国际版) | `qoder-global` | Qoder Global | qoder.com |
11
-
12
- Configure only what you use: a variant with no saved PAT shows no model group and never interferes with the other one. Streaming, reasoning content and tool calls ride the plugin's built-in Qoder transport; the conversation loop, compaction and permissions stay Harness-owned.
13
-
14
- ## Features
15
-
16
- - **PAT: save it and it works** — paste a PAT into the card and press *Save*; the token is validated live against that variant's region before anything is written, and the model group appears immediately — no DSH restart. Once signed in, *Replace PAT* (the new token overwrites only after it validates, so a mistyped paste can never strand a working credential) and *Clear PAT* are one click away.
17
- - **Two independent variants** — `qoder` (china) and `qoder-global` (global) keep separate credential files, routes and saved catalogs: a token minted on qoder.com does not work against the China deployment and vice versa.
18
- - **Three-tier model catalog (live → saved → fallback)** — the card states where the list on screen came from: *“Model list updated …”* (fetched now), *“Showing the saved model list from …”* (this account's last successful fetch, restored after a restart or a failed fetch), or *“Showing the built-in model list (not yet updated from Qoder)”* (the roster compiled into the plugin). The reason of the most recent failed fetch is shown too, and a *Refresh model list* button sits right there.
19
- - **One-toggle context window** — each variant card's *Context window* tab lists every model's capacity (the default and the largest declared window) and carries its own *“Use the largest declared context window”* preference (on by default): on, requests declare the maximum (e.g. Qwen3.8-Max at 1M); off, the default (200K). The two variants keep independent toggle state.
20
- - **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”* — plus the cycle share and reset time. Click the same widget again to close; click the other to switch variants.
21
- - **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.
22
-
23
- ## Screenshots
24
-
25
- ### Sidebar quota widgets & credit details
26
-
27
- Once enabled on the *“Qoder sidebar display”* card, a per-variant quota widget sits at the sidebar's foot; clicking it opens the *Qoder quota* panel with one row per credit package, switchable between the two variants at any time.
28
-
29
- **China (after clicking the sidebar widget):**
30
-
31
- ![China sidebar quota details](assets/1.png)
32
-
33
- **International (after clicking the sidebar widget):**
34
-
35
- ![International sidebar quota details](assets/2.png)
36
-
37
- ### Sidebar toggles
38
-
39
- The *“Qoder sidebar display”* card controls both widgets' visibility and the shared refresh interval:
40
-
41
- ![Sidebar display toggles](assets/3.png)
42
-
43
- ### Picking a model in a session
44
-
45
- Once a PAT is saved, the model picker gains a Qoder group with per-model rate multipliers (`x0.5`, `x0`, …):
46
-
47
- ![Model picker in a session](assets/4.png)
48
-
49
- ### Context-window settings
50
-
51
- Each variant card's *Context window* tab lists capacities per model under the *“Use the largest declared context window”* preference:
52
-
53
- **China:**
54
-
55
- ![China context-window settings](assets/5.png)
56
-
57
- **International:**
58
-
59
- ![International context-window settings](assets/6.png)
60
-
61
- ## Install
62
-
63
- Prerequisites:
64
-
65
- - DSH core `0.1.5-rc.1` or newer (this plugin peers on the `@deepseek-ai/*` `^0.1.5-rc.1` line);
66
- - Node.js `^22.19.0 || >=24.0.0` (per `package.json` engines);
67
- - your own Qoder account and at least one Personal Access Token.
68
-
69
- ```sh
70
- # From GitHub (recommended)
71
- dsh plugin --profile web add github:masknull/dsh-qoder-connect
72
- # Or from npm, once published
73
- dsh plugin --profile web add dsh-qoder-connect
74
- ```
75
-
76
- 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.
77
-
78
- ## Configuration
79
-
80
- ### Mint a PAT
81
-
82
- - **Qoder (China)**: sign in at qoder.com.cn → account settings → Personal Access Token.
83
- - **Qoder Global**: sign in at qoder.com → account settings → Personal Access Token.
84
-
85
- (The card repeats the same guidance: *“Generate a PAT in your qoder.com account settings (Account → Personal Access Token), then paste it here.”*)
86
-
87
- ### Save it on the card
88
-
89
- DSH → Settings → Plugins → the variant's card: paste the PAT into the password field and press *Save* (*“Validating and saving…”* while it runs). The token is validated against that variant's region first — a refusal saves nothing and the card says *“That PAT was rejected — generate a new one in your account settings and try again.”*. Do this on both cards to run both groups side by side.
90
-
91
- ### Environment-variable fallback
92
-
93
- For headless setups, the plugin reads an environment fallback **only when no credential file exists**:
94
-
95
- | Variant | Environment variable |
96
- |---|---|
97
- | `qoder` (china) | `QODER_CN_PERSONAL_ACCESS_TOKEN` |
98
- | `qoder-global` | `QODER_PERSONAL_ACCESS_TOKEN` |
99
-
100
- The precedence is **file > env**: as soon as a valid credential file is saved, a stray environment token stops mattering (the saved token always wins). Env-sourced credentials show as *source: environment variable* on the card, with no saved-at time.
101
-
102
- ## Data & privacy
103
-
104
- ### File layout
105
-
106
- Everything the plugin owns lives in one data directory: `<profile>/.dsh-qoder-connect/` — credentials at the root, rebuildable caches under `state/` (so “clear the cache” can never touch a credential):
107
-
108
- ```text
109
- <profile>/.dsh-qoder-connect/
110
- ├── .qoder-auth.json # China PAT ({version:2, pat, region, savedAt})
111
- ├── .qoder-global-auth.json # Global PAT
112
- └── state/
113
- ├── .qoder-catalog.json # China per-account saved catalog
114
- ├── .qoder-global-catalog.json
115
- ├── .qoder-probe.json # China reasoning-effort probe records
116
- ├── .qoder-global-probe.json
117
- ├── .qoder-host-heartbeat.json # host heartbeat (doctor uses it)
118
- └── .qoder-machine-id # transport machine-id seed (never written to ~/.qoder)
119
- ```
120
-
121
- The directory follows the profile that declares this plugin (found under `$DSH_HOME/profiles/`), falling back to the Harness home when none can be determined; `DSH_QODER_DATA_DIR` overrides it explicitly.
122
-
123
- ## Development
124
-
125
- ```sh
126
- pnpm install
127
- pnpm run build # tsdown → lib/ (host bundle + client bundle)
128
- pnpm test # vitest run (tests/**/*.spec.ts)
129
- pnpm run test:qoder # node --test (tests/qoder/*.test.ts, the Qoder transport suite)
130
- pnpm run typecheck # tsc across the host and client tsconfigs
131
- pnpm run check # typecheck + test + test:qoder + build in one gate
132
- ```
133
-
134
- The upstream `node:test` suite for the Qoder transport layer now lives in `tests/qoder/` (run it with `pnpm run test:qoder`; the pristine `reference-qoder-tests/` copy has been removed) — `package.json` scripts are the source of truth.
135
-
136
- ## Provenance & license
137
-
138
- - Derived from [masknull/dsh-workbuddy-connect](https://github.com/masknull/dsh-workbuddy-connect) (MIT) — the connect-plugin skeleton this project was refactored from.
139
- - Qoder transport layer ported from [mo-n/dsh-provider-qoder](https://github.com/mo-n/dsh-provider-qoder) (MIT).
140
-
141
- Released under the [MIT](./LICENSE) license; see [NOTICE](./NOTICE) for the upstream attributions. This is a community adapter — not affiliated with, authorized by, or endorsed by Qoder or DeepSeek, and not an official implementation.
142
-
143
- ## Disclaimer
144
-
145
- - Qoder's endpoints come from the upstream repositories: upstream may change, rate-limit or block them at any time, breaking part or all of this plugin with no compatibility window to promise.
146
- - For personal learning and research only; this tool drives **your own** Qoder account. Do not use it commercially or beyond reasonable personal use. You are responsible for complying with Qoder's terms of service and for any consequence — account restriction, quota loss, service interruption — of using this project.
147
- - The authors are not liable for any direct or indirect loss arising from use or misuse of this project. The names Qoder, DeepSeek and related marks belong to their respective owners and appear here only to describe compatibility.
1
+ # DSH Qoder Connect
2
+
3
+ **English** | [简体中文](./README.md)
4
+
5
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that brings your **Qoder subscription** models into DSH through a Personal Access Token (PAT). One plugin serves both Qoder product lines — each with its own settings card, provider id, credential file and saved catalog, never mixed:
6
+
7
+ | Variant | Provider id | Card title | Where the PAT is minted |
8
+ |---|---|---|---|
9
+ | China (国内版) | `qoder` | Qoder | qoder.com.cn |
10
+ | International (国际版) | `qoder-global` | Qoder Global | qoder.com |
11
+
12
+ Configure only what you use: a variant with no saved PAT shows no model group and never interferes with the other one. Streaming, reasoning content and tool calls ride the plugin's built-in Qoder transport; the conversation loop, compaction and permissions stay Harness-owned.
13
+
14
+ ## Features
15
+
16
+ - **PAT: save it and it works** — paste a PAT into the card and press *Save*; the token is validated live against that variant's region before anything is written, and the model group appears immediately — no DSH restart. Once signed in, *Replace PAT* (the new token overwrites only after it validates, so a mistyped paste can never strand a working credential) and *Clear PAT* are one click away.
17
+ - **Two independent variants** — `qoder` (china) and `qoder-global` (global) keep separate credential files, routes and saved catalogs: a token minted on qoder.com does not work against the China deployment and vice versa.
18
+ - **Three-tier model catalog (live → saved → fallback)** — the card states where the list on screen came from: *“Model list updated …”* (fetched now), *“Showing the saved model list from …”* (this account's last successful fetch, restored after a restart or a failed fetch), or *“Showing the built-in model list (not yet updated from Qoder)”* (the roster compiled into the plugin). The reason of the most recent failed fetch is shown too, and a *Refresh model list* button sits right there.
19
+ - **One-toggle context window** — each variant card's *Context window* tab lists every model's capacity (the default and the largest declared window) and carries its own *“Use the largest declared context window”* preference (on by default): on, requests declare the maximum (e.g. Qwen3.8-Max at 1M); off, the default (200K). The two variants keep independent toggle state.
20
+ - **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”* — plus the cycle share and reset time. Click the same widget again to close; click the other to switch variants.
21
+ - **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.
22
+
23
+ ## Screenshots
24
+
25
+ ### Sidebar quota widgets & credit details
26
+
27
+ Once enabled on the *“Qoder sidebar display”* card, a per-variant quota widget sits at the sidebar's foot; clicking it opens the *Qoder quota* panel with one row per credit package, switchable between the two variants at any time.
28
+
29
+ **China (after clicking the sidebar widget):**
30
+
31
+ ![China sidebar quota details](assets/1.png)
32
+
33
+ **International (after clicking the sidebar widget):**
34
+
35
+ ![International sidebar quota details](assets/2.png)
36
+
37
+ ### Sidebar toggles
38
+
39
+ The *“Qoder sidebar display”* card controls both widgets' visibility and the shared refresh interval:
40
+
41
+ ![Sidebar display toggles](assets/3.png)
42
+
43
+ ### Picking a model in a session
44
+
45
+ Once a PAT is saved, the model picker gains a Qoder group with per-model rate multipliers (`x0.5`, `x0`, …):
46
+
47
+ ![Model picker in a session](assets/4.png)
48
+
49
+ ### Context-window settings
50
+
51
+ Each variant card's *Context window* tab lists capacities per model under the *“Use the largest declared context window”* preference:
52
+
53
+ **China:**
54
+
55
+ ![China context-window settings](assets/5.png)
56
+
57
+ **International:**
58
+
59
+ ![International context-window settings](assets/6.png)
60
+
61
+ ## Install
62
+
63
+ Prerequisites:
64
+
65
+ - DSH core `0.1.5-rc.1` or newer (this plugin peers on the `@deepseek-ai/*` `^0.1.5-rc.1` line);
66
+ - Node.js `^22.19.0 || >=24.0.0` (per `package.json` engines);
67
+ - your own Qoder account and at least one Personal Access Token.
68
+
69
+ ```sh
70
+ # From GitHub (recommended)
71
+ dsh plugin --profile web add github:masknull/dsh-qoder-connect
72
+ # Or from npm, once published
73
+ dsh plugin --profile web add dsh-qoder-connect
74
+ ```
75
+
76
+ 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.
77
+
78
+ ## Configuration
79
+
80
+ ### Mint a PAT
81
+
82
+ - **Qoder (China)**: sign in at qoder.com.cn → account settings → Personal Access Token, then copy it. [Open the page](https://qoder.cn/account/integrations)
83
+ - **Qoder Global**: sign in at qoder.com → account settings → Personal Access Token, then copy it. [Open the page](https://qoder.com/account/integrations)
84
+
85
+ (The card repeats the same guidance: *“Generate a PAT in your qoder.com account settings (Account → Personal Access Token), then paste it here.”*)
86
+
87
+ ### Save it on the card
88
+
89
+ DSH → Settings → Plugins → the variant's card: paste the PAT into the password field and press *Save* (*“Validating and saving…”* while it runs). The token is validated against that variant's region first — a refusal saves nothing and the card says *“That PAT was rejected — generate a new one in your account settings and try again.”*. Do this on both cards to run both groups side by side.
90
+
91
+ ### Environment-variable fallback
92
+
93
+ For headless setups, the plugin reads an environment fallback **only when no credential file exists**:
94
+
95
+ | Variant | Environment variable |
96
+ |---|---|
97
+ | `qoder` (china) | `QODER_CN_PERSONAL_ACCESS_TOKEN` |
98
+ | `qoder-global` | `QODER_PERSONAL_ACCESS_TOKEN` |
99
+
100
+ The precedence is **file > env**: as soon as a valid credential file is saved, a stray environment token stops mattering (the saved token always wins). Env-sourced credentials show as *source: environment variable* on the card, with no saved-at time.
101
+
102
+ ## Data & privacy
103
+
104
+ ### File layout
105
+
106
+ Everything the plugin owns lives in one data directory: `<profile>/.dsh-qoder-connect/` — credentials at the root, rebuildable caches under `state/` (so “clear the cache” can never touch a credential):
107
+
108
+ ```text
109
+ <profile>/.dsh-qoder-connect/
110
+ ├── .qoder-auth.json # China PAT ({version:2, pat, region, savedAt})
111
+ ├── .qoder-global-auth.json # Global PAT
112
+ └── state/
113
+ ├── .qoder-catalog.json # China per-account saved catalog
114
+ ├── .qoder-global-catalog.json
115
+ ├── .qoder-probe.json # China reasoning-effort probe records
116
+ ├── .qoder-global-probe.json
117
+ ├── .qoder-host-heartbeat.json # host heartbeat (doctor uses it)
118
+ └── .qoder-machine-id # transport machine-id seed (never written to ~/.qoder)
119
+ ```
120
+
121
+ The directory follows the profile that declares this plugin (found under `$DSH_HOME/profiles/`), falling back to the Harness home when none can be determined; `DSH_QODER_DATA_DIR` overrides it explicitly.
122
+
123
+ ## Development
124
+
125
+ ```sh
126
+ pnpm install
127
+ pnpm run build # tsdown → lib/ (host bundle + client bundle)
128
+ pnpm test # vitest run (tests/**/*.spec.ts)
129
+ pnpm run test:qoder # node --test (tests/qoder/*.test.ts, the Qoder transport suite)
130
+ pnpm run typecheck # tsc across the host and client tsconfigs
131
+ pnpm run check # typecheck + test + test:qoder + build in one gate
132
+ ```
133
+
134
+ The upstream `node:test` suite for the Qoder transport layer now lives in `tests/qoder/` (run it with `pnpm run test:qoder`; the pristine `reference-qoder-tests/` copy has been removed) — `package.json` scripts are the source of truth.
135
+
136
+ ## Provenance & license
137
+
138
+ - Derived from [masknull/dsh-workbuddy-connect](https://github.com/masknull/dsh-workbuddy-connect) (MIT) — the connect-plugin skeleton this project was refactored from.
139
+ - Qoder transport layer ported from [mo-n/dsh-provider-qoder](https://github.com/mo-n/dsh-provider-qoder) (MIT).
140
+
141
+ Released under the [MIT](./LICENSE) license; see [NOTICE](./NOTICE) for the upstream attributions. This is a community adapter — not affiliated with, authorized by, or endorsed by Qoder or DeepSeek, and not an official implementation.
142
+
143
+ ## Disclaimer
144
+
145
+ - Qoder's endpoints come from the upstream repositories: upstream may change, rate-limit or block them at any time, breaking part or all of this plugin with no compatibility window to promise.
146
+ - For personal learning and research only; this tool drives **your own** Qoder account. Do not use it commercially or beyond reasonable personal use. You are responsible for complying with Qoder's terms of service and for any consequence — account restriction, quota loss, service interruption — of using this project.
147
+ - The authors are not liable for any direct or indirect loss arising from use or misuse of this project. The names Qoder, DeepSeek and related marks belong to their respective owners and appear here only to describe compatibility.
package/README.md CHANGED
@@ -79,8 +79,8 @@ dsh plugin --profile web add dsh-qoder-connect
79
79
 
80
80
  ### 生成 PAT
81
81
 
82
- - **Qoder(中国版)**:登录 qoder.com.cn → 账号设置 → 个人访问令牌,生成后复制。
83
- - **Qoder Global(国际版)**:登录 qoder.com → 账号设置 → 个人访问令牌,生成后复制。
82
+ - **Qoder(国内版)**:登录 qoder.com.cn → 账号设置 → 个人访问令牌,生成后复制。[直达获取](https://qoder.cn/account/integrations)
83
+ - **Qoder Global(国际版)**:登录 qoder.com → 账号设置 → 个人访问令牌,生成后复制。[直达获取](https://qoder.com/account/integrations)
84
84
 
85
85
  (卡片内也有同样指引:「请先在 qoder.com.cn 的账号设置(账号 → 个人访问令牌)中生成 PAT,再粘贴到下方输入框。」)
86
86
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
- # Register the Qoder provider without changing the profile's current
2
- # default model. Credentials are Personal Access Tokens (PATs) the user
3
- # pastes into the plugin's card; no OAuth or device flow is started.
4
- - insert:
5
- - id: llm-qoder
6
- name: dsh-qoder-connect
1
+ # Register the Qoder provider without changing the profile's current
2
+ # default model. Credentials are Personal Access Tokens (PATs) the user
3
+ # pastes into the plugin's card; no OAuth or device flow is started.
4
+ - insert:
5
+ - id: llm-qoder
6
+ name: dsh-qoder-connect
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { B as qoderCredentialIdentity, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, R as QoderCredentialStore, T as QoderCatalogStore, b as createQoderTransport, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, y as validateApiKey } from "./variants-jbFhMKO8.js";
2
+ import { B as qoderCredentialIdentity, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, R as QoderCredentialStore, T as QoderCatalogStore, b as createQoderTransport, f as QODER_CONNECT_VERSION, i as variantFor, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, y as validateApiKey } from "./variants-C0iZD8AD.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/client.js CHANGED
@@ -1107,6 +1107,10 @@ window.__ModuleLoader__.load({
1107
1107
  status?.status === "signed-in" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1108
1108
  status.pat === void 0 ? null : patSummaryLine(status.pat),
1109
1109
  replacing ? patEntry() : null,
1110
+ status.jobTokenRefreshedAt === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1111
+ style: bodyStyle,
1112
+ children: t("jobTokenRefreshed", { time: formatTime(status.jobTokenRefreshedAt) })
1113
+ }),
1110
1114
  status.catalog === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1111
1115
  style: rowStyle$1,
1112
1116
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -3022,6 +3026,7 @@ window.__ModuleLoader__.load({
3022
3026
  refreshing: "Refreshing…",
3023
3027
  refreshModels: "Refresh model list",
3024
3028
  refreshingModels: "Refreshing models…",
3029
+ jobTokenRefreshed: "Job token was auto-refreshed at {time} (upstream rejected the old one; the message recovered).",
3025
3030
  catalogLive: "Model list updated {time}",
3026
3031
  catalogSaved: "Showing the saved model list from {time}",
3027
3032
  catalogFallback: "Showing the built-in model list (not yet updated from Qoder)",
@@ -3150,6 +3155,7 @@ window.__ModuleLoader__.load({
3150
3155
  refresh: "刷新",
3151
3156
  refreshing: "正在刷新…",
3152
3157
  refreshModels: "刷新模型列表",
3158
+ jobTokenRefreshed: "jobToken 已于 {time} 自动刷新(上游拒绝了旧令牌,消息已自动恢复)。",
3153
3159
  refreshingModels: "正在刷新模型…",
3154
3160
  catalogLive: "模型列表更新于 {time}",
3155
3161
  catalogSaved: "当前显示已保存的模型列表,更新于 {time}",
package/lib/index.d.ts CHANGED
@@ -246,6 +246,13 @@ type QoderWebStatus = {
246
246
  probe?: QoderWebProbeSection;
247
247
  /** Card preference selecting larger declared context windows. */
248
248
  useMaximumContextWindow?: boolean;
249
+ /**
250
+ * The last automatic job-token refresh the self-heal performed, epoch ms.
251
+ * Absent when no refresh has happened in this process. The card renders
252
+ * this as a visible "token auto-refreshed" notice, so an otherwise
253
+ * invisible recovery is observable.
254
+ */
255
+ jobTokenRefreshedAt?: number;
249
256
  /**
250
257
  * In-process key authorizing probe control writes. Handed to the card with
251
258
  * the status document (the card is same-origin and already had to pass the
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as QODER_GLOBAL_AUTH_PATH, B as qoderCredentialIdentity, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_AUTH_FILENAME, G as qoderPluginDataDir, H as QODER_DATA_DIR_ENV, I as QODER_PAT_ENV_CN, K as qoderStateDir, L as QODER_PAT_ENV_GLOBAL, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_STATUS_PATH, R as QoderCredentialStore, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_NAME, V as qoderOwnAuthPath, W as qoderMachineIdPath, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as patTail } from "./variants-jbFhMKO8.js";
1
+ import { A as QODER_GLOBAL_AUTH_PATH, B as qoderCredentialIdentity, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_AUTH_FILENAME, G as qoderPluginDataDir, H as QODER_DATA_DIR_ENV, I as QODER_PAT_ENV_CN, K as qoderStateDir, L as QODER_PAT_ENV_GLOBAL, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_STATUS_PATH, R as QoderCredentialStore, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_NAME, V as qoderOwnAuthPath, W as qoderMachineIdPath, _ as modelInfoOf, a as QODER_HOST_HEARTBEAT_FILENAME, b as createQoderTransport, c as processStartTimeMs, d as writeHostHeartbeat, f as QODER_CONNECT_VERSION, g as kindFromQoderFailure, h as classifyUpstreamError, i as variantFor, j as QODER_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as patTail } from "./variants-C0iZD8AD.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -539,7 +539,13 @@ function createQoderShim(options) {
539
539
  return timingSafeEqual(a, b);
540
540
  }
541
541
  const server = createServer((req, res) => {
542
- handle(req, res);
542
+ handle(req, res).catch((error) => {
543
+ logger?.warn("dsh-qoder-connect: loopback request failed", error);
544
+ try {
545
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", "loopback request failed");
546
+ else res.end();
547
+ } catch {}
548
+ });
543
549
  });
544
550
  const ready = new Promise((resolve, reject) => {
545
551
  server.once("listening", () => resolve());
@@ -1045,15 +1051,20 @@ async function qoderWebStatus(deps) {
1045
1051
  ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
1046
1052
  ...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() }
1047
1053
  };
1054
+ const refreshedAt = deps.jobTokenRefreshedAt?.();
1055
+ const withRefreshNotice = refreshedAt === void 0 ? probed : {
1056
+ ...probed,
1057
+ jobTokenRefreshedAt: refreshedAt
1058
+ };
1048
1059
  try {
1049
1060
  const credits = await deps.client.fetchCredits();
1050
1061
  return {
1051
- ...probed,
1062
+ ...withRefreshNotice,
1052
1063
  credits
1053
1064
  };
1054
1065
  } catch (error) {
1055
1066
  return {
1056
- ...probed,
1067
+ ...withRefreshNotice,
1057
1068
  creditsError: safeMessage(error)
1058
1069
  };
1059
1070
  }
@@ -1243,6 +1254,57 @@ function registerQoderProbeRoute(ctx, deps, key) {
1243
1254
  }, "dsh-qoder-connect: probe control route");
1244
1255
  }
1245
1256
  //#endregion
1257
+ //#region src/job-token-hint.ts
1258
+ /** The name recorded on the hint row (and therefore its displayed title). */
1259
+ const JOB_TOKEN_HINT_NAME = "qoder";
1260
+ /** Refresh time per agent id, so a later read can name when it happened. */
1261
+ const lastRefreshAt = /* @__PURE__ */ new Map();
1262
+ /** The most recent agent observed entering a running turn. */
1263
+ let lastRunningAgent;
1264
+ /** Monotonic suffix keeping appended ids unique within this process. */
1265
+ let hintSeq = 0;
1266
+ /**
1267
+ * Start tracking which agent is running.
1268
+ *
1269
+ * `agent/status` is a plain `emit` event: a listener that returns nothing
1270
+ * cannot disturb the emitting path (unlike a waterfall event, where a listener
1271
+ * owes the chain its `next()` call). The self-heal fires inside that agent's
1272
+ * request, so the most recently running agent is the conversation to print
1273
+ * into.
1274
+ */
1275
+ function installJobTokenHint(ctx) {
1276
+ ctx.on("agent/status", ((payload) => {
1277
+ if (payload.status === "running" && payload.agent !== void 0) lastRunningAgent = payload.agent;
1278
+ }));
1279
+ }
1280
+ /**
1281
+ * Print the refresh row into the conversation whose request triggered it.
1282
+ *
1283
+ * Best effort by design: a missing agent, an absent session, or a projection
1284
+ * that rejects the append must never affect the chat that just recovered.
1285
+ *
1286
+ * @param at - When the job token was rotated.
1287
+ * @param text - The row's summary line.
1288
+ */
1289
+ function emitJobTokenHint(at, text) {
1290
+ const agent = lastRunningAgent;
1291
+ if (agent?.session === void 0) return;
1292
+ lastRefreshAt.set(String(agent.id), at);
1293
+ const commandId = `cmd-qoder-hint-${Date.now().toString(36)}-${++hintSeq}`;
1294
+ try {
1295
+ agent.session.append("command/run", {
1296
+ commandId,
1297
+ name: JOB_TOKEN_HINT_NAME,
1298
+ source: { kind: "user" }
1299
+ });
1300
+ agent.session.append("command/done", {
1301
+ commandId,
1302
+ kind: "success",
1303
+ text
1304
+ });
1305
+ } catch {}
1306
+ }
1307
+ //#endregion
1246
1308
  //#region src/index.ts
1247
1309
  /** Stable Cordis plugin name. */
1248
1310
  const name = "llm-qoder";
@@ -1403,16 +1465,32 @@ function settingsNamespaceFor(variant) {
1403
1465
  function fallbackFor(_variant) {
1404
1466
  return FALLBACK_QODER_MODELS;
1405
1467
  }
1468
+ /**
1469
+ * The hint row's summary line: what happened, and when.
1470
+ *
1471
+ * Named here rather than in the hint module because the text is this plugin's
1472
+ * user-facing wording, and the transport callback that produces it runs before
1473
+ * the plugin's own scope exists.
1474
+ */
1475
+ function jobTokenHintText(at) {
1476
+ return `jobToken 已自动刷新(${new Date(at).toLocaleTimeString("zh-CN", { hour12: false })})— 旧令牌被上游拒绝,已自动重换并恢复`;
1477
+ }
1406
1478
  /** Build one variant's stores, transport, and probe state. */
1407
1479
  function createVariantRuntime(ctx, config, variant, current, identityOf) {
1408
1480
  const store = new QoderCredentialStore({
1409
1481
  variant,
1410
1482
  logger: ctx.logger
1411
1483
  });
1484
+ let jobTokenRefreshedAt;
1412
1485
  const transport = createQoderTransport({
1413
1486
  region: variant.region,
1414
1487
  resolvePat: () => store.patPromise(),
1415
- resolveMachineId: () => getMachineId([qoderMachineIdPath()])
1488
+ resolveMachineId: () => getMachineId([qoderMachineIdPath()]),
1489
+ onJobTokenRefreshed: (info) => {
1490
+ jobTokenRefreshedAt = info.at;
1491
+ ctx.logger.warn(`dsh-qoder-connect: ${variant.displayName} job token was auto-refreshed after an upstream rejection`);
1492
+ emitJobTokenHint(info.at, jobTokenHintText(info.at));
1493
+ }
1416
1494
  });
1417
1495
  const client = new QoderUpstreamClient({
1418
1496
  region: variant.region,
@@ -1442,6 +1520,7 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1442
1520
  variant,
1443
1521
  store,
1444
1522
  client,
1523
+ transport,
1445
1524
  catalog,
1446
1525
  probeStore,
1447
1526
  probeService: new QoderProbeService({
@@ -1461,7 +1540,8 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1461
1540
  catalogGeneration: 0,
1462
1541
  inflightFetch: void 0,
1463
1542
  invalidate: () => {},
1464
- registered: false
1543
+ registered: false,
1544
+ jobTokenRefreshedAt: () => jobTokenRefreshedAt
1465
1545
  };
1466
1546
  }
1467
1547
  /** The catalog provenance the card displays. */
@@ -1508,6 +1588,21 @@ function probeSection(runtime, consent) {
1508
1588
  };
1509
1589
  }
1510
1590
  /**
1591
+ * Run a detached promise without letting its failure kill the host.
1592
+ *
1593
+ * Node terminates the whole process on an unhandled rejection (exit code 1),
1594
+ * which the desktop shell reports as the backend having "exited unexpectedly"
1595
+ * — so a failing heartbeat write, loopback close, or background sweep in this
1596
+ * plugin would take the entire Harness down with it. Every fire-and-forget
1597
+ * call therefore carries a handler; a failure is a diagnostic, never a reason
1598
+ * for the host to die.
1599
+ */
1600
+ function detach(ctx, work, what) {
1601
+ work.catch((error) => {
1602
+ ctx.logger.warn(`dsh-qoder-connect: ${what} failed`, error);
1603
+ });
1604
+ }
1605
+ /**
1511
1606
  * Start one variant: its loopback endpoint, provider registration, and
1512
1607
  * configuration-card wiring.
1513
1608
  *
@@ -1567,18 +1662,18 @@ async function startVariant(ctx, runtime) {
1567
1662
  ctx.effect(() => () => {
1568
1663
  releaseAdapter?.();
1569
1664
  releaseDirectory?.();
1570
- shim.close();
1665
+ detach(ctx, shim.close(), "loopback endpoint close");
1571
1666
  });
1572
1667
  } catch {
1573
1668
  releaseAdapter?.();
1574
1669
  releaseDirectory?.();
1575
- shim.close();
1670
+ detach(ctx, shim.close(), "loopback endpoint close");
1576
1671
  }
1577
1672
  runtime.registered = true;
1578
1673
  return true;
1579
1674
  } catch (error) {
1580
1675
  ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
1581
- shim.close();
1676
+ detach(ctx, shim.close(), "loopback endpoint close");
1582
1677
  return false;
1583
1678
  }
1584
1679
  }
@@ -1604,6 +1699,7 @@ function apply(ctx, config) {
1604
1699
  */
1605
1700
  const lastIdentities = /* @__PURE__ */ new Map();
1606
1701
  const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
1702
+ installJobTokenHint(ctx);
1607
1703
  const probeKey = createProbeKey();
1608
1704
  /**
1609
1705
  * The in-process key authorizing PAT writes, minted separately from the
@@ -1685,7 +1781,8 @@ function apply(ctx, config) {
1685
1781
  probe: () => probeSection(runtime, current().probeConsent === true),
1686
1782
  probeKey,
1687
1783
  authKey,
1688
- ...runtime.variant.id === CHINA_VARIANT.id ? { useMaximumContextWindow: () => current().useMaximumContextWindowCN === true } : { useMaximumContextWindow: () => current().useMaximumContextWindow === true }
1784
+ ...runtime.variant.id === CHINA_VARIANT.id ? { useMaximumContextWindow: () => current().useMaximumContextWindowCN === true } : { useMaximumContextWindow: () => current().useMaximumContextWindow === true },
1785
+ jobTokenRefreshedAt: runtime.jobTokenRefreshedAt
1689
1786
  });
1690
1787
  registerQoderAuthRoute(webCtx, {
1691
1788
  path: runtime.variant.authPath,
@@ -1697,7 +1794,7 @@ function apply(ctx, config) {
1697
1794
  const credential = await runtime.store.save(pat);
1698
1795
  const identity = credentialIdentity(credential);
1699
1796
  adoptIdentity(runtime, identity);
1700
- fetchCatalog(runtime, identity);
1797
+ await fetchCatalog(runtime, identity);
1701
1798
  return {
1702
1799
  ok: true,
1703
1800
  status: await runtime.store.status()
@@ -1830,7 +1927,7 @@ function apply(ctx, config) {
1830
1927
  stopped = true;
1831
1928
  for (const timer of timers) clearInterval(timer);
1832
1929
  timers.length = 0;
1833
- clearHostHeartbeat();
1930
+ detach(ctx, clearHostHeartbeat(), "host heartbeat cleanup");
1834
1931
  });
1835
1932
  /**
1836
1933
  * Fetch one variant's catalog for the current credential.
@@ -1891,11 +1988,15 @@ function apply(ctx, config) {
1891
1988
  runtime.catalogSource = "live";
1892
1989
  runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
1893
1990
  runtime.catalogError = void 0;
1894
- if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
1895
- source: runtime.client.lastCatalog?.source ?? "unknown",
1896
- fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
1897
- models: [...models]
1898
- });
1991
+ if (lastIdentities.get(runtime.variant.id) === identity) try {
1992
+ runtime.savedCatalogs.set(identity, {
1993
+ source: runtime.client.lastCatalog?.source ?? "unknown",
1994
+ fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
1995
+ models: [...models]
1996
+ });
1997
+ } catch (error) {
1998
+ ctx.logger.warn(`dsh-qoder-connect: ${runtime.variant.displayName} catalog could not be saved for this account`, error);
1999
+ }
1899
2000
  runtime.invalidate();
1900
2001
  })().finally(() => {
1901
2002
  if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
@@ -1946,16 +2047,23 @@ function apply(ctx, config) {
1946
2047
  const syncAll = async () => {
1947
2048
  for (const runtime of runtimes) await syncVariant(runtime);
1948
2049
  };
1949
- Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
2050
+ /**
2051
+ * Start both variants, then begin the credential sweep.
2052
+ *
2053
+ * The chain carries its own failure handler: without one, a rejection here
2054
+ * would be an unhandled rejection — which Node turns into process
2055
+ * termination, taking the whole Harness down over one plugin's startup.
2056
+ */
2057
+ detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
1950
2058
  if (stopped) return;
1951
- if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
1952
- syncAll();
2059
+ if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
2060
+ detach(ctx, syncAll(), "credential sweep");
1953
2061
  const timer = setInterval(() => {
1954
- syncAll();
2062
+ detach(ctx, syncAll(), "credential sweep");
1955
2063
  }, credentialPollMs());
1956
2064
  timer.unref?.();
1957
2065
  timers.push(timer);
1958
- });
2066
+ }), "variant startup");
1959
2067
  }
1960
2068
  //#endregion
1961
2069
  export { CHINA_VARIANT, Config, FALLBACK_QODER_MODELS, GLOBAL_VARIANT, KIND_STATUS, PROBE_EFFORT_CANDIDATES, QODER_AUTH_FILENAME, QODER_AUTH_PATH, QODER_DATA_DIR_ENV, QODER_DATA_DIR_NAME, QODER_GLOBAL_AUTH_PATH, QODER_GLOBAL_PROBE_PATH, QODER_GLOBAL_SETTINGS_NS, QODER_GLOBAL_STATUS_PATH, QODER_HOST_HEARTBEAT_FILENAME, QODER_PAT_ENV_CN, QODER_PAT_ENV_GLOBAL, QODER_PROBE_FILENAME, QODER_PROBE_PATH, QODER_PROVIDER, QODER_QUOTA_SETTINGS_NS, QODER_SETTINGS_NS, QODER_STATUS_PATH, QODER_STREAM_IDLE_TIMEOUT_MS, QODER_VARIANTS, QUOTA_POLL_DEFAULT_MS, QUOTA_POLL_MIN_MS, QoderCatalog, QoderCatalogStore, QoderCredentialStore, QoderProbeService, QoderProbeStore, QoderUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createAuthKey, createQoderAdapter, createQoderShim, fingerprintModel, inject, isHeartbeatProcessAlive, kindFromQoderFailure, modelInfoOf, name, normalizeCredits, patTail, probeModel, processStartTimeMs, qoderAuthHandler, qoderCatalogPath, qoderCredentialIdentity, qoderHostHeartbeatPath, qoderOwnAuthPath, qoderPluginDataDir, qoderProbePath, randomSentinel, readHostHeartbeat, registerQoderAuthRoute, variantFor };
@@ -903,6 +903,20 @@ function qoderRequestId(headers) {
903
903
  const normalized = (headers?.get("x-request-id") ?? headers?.get("request-id") ?? headers?.get("x-amzn-requestid"))?.trim();
904
904
  return normalized ? ProviderRequestId(normalized) : void 0;
905
905
  }
906
+ /**
907
+ * Whether this failure is an upstream authorization rejection worth one
908
+ * re-auth retry.
909
+ *
910
+ * The job token the transport signs requests with is cached in memory; an
911
+ * upstream that invalidates it mid-lifetime (gateway rotation or a fault
912
+ * window) answers HTTP 401/403 before any payload is produced. That state is
913
+ * distinguishable from a genuinely revoked PAT only by trying a fresh
914
+ * exchange, so callers clear their credential cache and retry once before
915
+ * reporting `AUTH` to the user.
916
+ */
917
+ function isQoderAuthRejection(error) {
918
+ return error instanceof LlmError && (error.code === "AUTH" || error.failure.status === 401 || error.failure.status === 403);
919
+ }
906
920
  function retryAfterMs(value, nowMs = Date.now()) {
907
921
  const normalized = value?.trim();
908
922
  if (!normalized) return void 0;
@@ -1296,6 +1310,17 @@ var QoderAuthService = class {
1296
1310
  if (pat) this.cache.delete(`${this.region}:${opaqueCredentialKey(pat)}`);
1297
1311
  else this.cache.clear();
1298
1312
  }
1313
+ /**
1314
+ * Exchange a fresh job token OUTSIDE the single-flight, for the self-heal
1315
+ * retry. The shared flight can be aborted by a departing concurrent waiter
1316
+ * (the quota poll, the catalog sweep), and a retry that joins it would then
1317
+ * be cancelled by a path that has nothing to do with the chat. The result
1318
+ * replaces the cache entry.
1319
+ */
1320
+ async exchangeFresh(pat, signal) {
1321
+ this.clear(pat);
1322
+ return await this.exchangeAndResolve(pat, signal ?? AbortSignal.timeout(this.timeoutMs));
1323
+ }
1299
1324
  async getCredentials(pat, signal) {
1300
1325
  if (!pat || typeof pat !== "string") throw new QoderLlmError("Qoder Personal Access Token is missing or invalid. Configure Qoder in the Qoder settings page.", "MISSING_CREDENTIAL");
1301
1326
  if (signal?.aborted) throw abortedError();
@@ -2283,6 +2308,16 @@ var QoderUsageReader = class {
2283
2308
  return this.flights.run(cacheKey, options?.signal, (sharedSignal) => this.loadAccount(pat, sharedSignal, cacheKey), () => new QoderLlmError("Qoder account request was aborted.", "ABORTED"));
2284
2309
  }
2285
2310
  async loadAccount(pat, signal, cacheKey) {
2311
+ try {
2312
+ return await this.loadAccountWith(pat, signal, cacheKey);
2313
+ } catch (error) {
2314
+ if (!isQoderAuthRejection(error) || signal.aborted) throw error;
2315
+ this.logger?.warn?.("[Qoder Account] Usage read rejected as unauthorized; exchanging a fresh job token and retrying once");
2316
+ }
2317
+ this.authService.clear(pat);
2318
+ return this.loadAccountWith(pat, signal, cacheKey);
2319
+ }
2320
+ async loadAccountWith(pat, signal, cacheKey) {
2286
2321
  const creds = await this.authService.getCredentials(pat, signal);
2287
2322
  const profile = {
2288
2323
  id: creds.userID,
@@ -2380,27 +2415,38 @@ var QoderUsageReader = class {
2380
2415
  * Re-arranges standard Base64 chunks and translates characters against
2381
2416
  * a custom alphabet to satisfy Qoder upstream WAF requirements.
2382
2417
  *
2418
+ * The translation runs through a byte table applied to a Buffer instead of a
2419
+ * per-character string loop. The loop form (`out += char` plus
2420
+ * `alphabet.indexOf(char)`) costs ~800 ms on a 1 MB body and runs on the main
2421
+ * thread once per model request, which saturated the Harness event loop as
2422
+ * soon as a few large-context sessions were in flight. The table form is
2423
+ * byte-for-byte identical (the input is always Base64, i.e. ASCII) and about
2424
+ * 24x faster.
2425
+ *
2383
2426
  * @module dsh-provider-qoder/qoder/transport/wire/encoding
2384
2427
  */
2385
2428
  const qoderCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
2386
2429
  const qoderStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2430
+ /**
2431
+ * Byte translation table: every byte maps to itself unless it is a Base64
2432
+ * alphabet character (→ the custom alphabet) or the `=` pad (→ `$`).
2433
+ */
2434
+ const encodeTable = (() => {
2435
+ const table = /* @__PURE__ */ new Uint8Array(256);
2436
+ for (let index = 0; index < 256; index++) table[index] = index;
2437
+ for (let index = 0; index < 64; index++) table[qoderStdAlphabet.charCodeAt(index)] = qoderCustomAlphabet.charCodeAt(index);
2438
+ table["=".charCodeAt(0)] = "$".charCodeAt(0);
2439
+ return table;
2440
+ })();
2387
2441
  function qoderEncodeBody(plaintext) {
2388
2442
  const std = Buffer.isBuffer(plaintext) ? plaintext.toString("base64") : Buffer.from(plaintext).toString("base64");
2389
2443
  const n = std.length;
2390
2444
  const a = Math.floor(n / 3);
2391
2445
  const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
2392
- let out = "";
2393
- for (let i = 0; i < n; i++) {
2394
- const c = rearranged[i];
2395
- if (c === void 0) continue;
2396
- if (c === "=") out += "$";
2397
- else {
2398
- const idx = qoderStdAlphabet.indexOf(c);
2399
- if (idx >= 0) out += qoderCustomAlphabet[idx];
2400
- else out += c;
2401
- }
2402
- }
2403
- return out;
2446
+ const source = Buffer.from(rearranged, "latin1");
2447
+ const target = Buffer.allocUnsafe(n);
2448
+ for (let index = 0; index < n; index++) target[index] = encodeTable[source[index] ?? 0] ?? 0;
2449
+ return target.toString("latin1");
2404
2450
  }
2405
2451
  //#endregion
2406
2452
  //#region src/qoder/transport/wire/thinking.ts
@@ -3001,6 +3047,15 @@ var DefaultQoderTransport = class {
3001
3047
  attachments;
3002
3048
  imageUploader;
3003
3049
  preserveThinking;
3050
+ onJobTokenRefreshed;
3051
+ /**
3052
+ * Set when the self-heal exchanged a fresh job token, cleared when a chat
3053
+ * afterwards succeeds. The notice reports "the token was rotated because the
3054
+ * old one was rejected", which is true from that exchange on — so it must
3055
+ * not be tied to the heal's OWN retry, which can still lose a race with a
3056
+ * transient upstream timeout and be rescued by a later attempt.
3057
+ */
3058
+ pendingRefreshAt;
3004
3059
  constructor(options) {
3005
3060
  this.region = options.region;
3006
3061
  this.resolvePat = options.resolvePat;
@@ -3011,6 +3066,7 @@ var DefaultQoderTransport = class {
3011
3066
  this.metadataTimeoutMs = options.metadataTimeoutMs;
3012
3067
  this.attachments = options.attachments;
3013
3068
  this.preserveThinking = options.preserveThinking;
3069
+ this.onJobTokenRefreshed = options.onJobTokenRefreshed;
3014
3070
  this.auth = new QoderAuthService({
3015
3071
  fetch: this.fetchImpl,
3016
3072
  logger: this.logger,
@@ -3040,6 +3096,23 @@ var DefaultQoderTransport = class {
3040
3096
  stream(options, model) {
3041
3097
  return this.generate(options, model);
3042
3098
  }
3099
+ /**
3100
+ * Report the pending self-heal once a chat is accepted, then clear it.
3101
+ *
3102
+ * The notice means "the stale token was rejected, so it was rotated, and the
3103
+ * chat works again" — all three are true by the time a chat is accepted
3104
+ * after the refresh, no matter which attempt delivered it.
3105
+ */
3106
+ flushPendingRefreshNotice() {
3107
+ if (this.pendingRefreshAt === void 0) return;
3108
+ const at = this.pendingRefreshAt;
3109
+ this.pendingRefreshAt = void 0;
3110
+ this.logger?.warn?.("[Qoder Stream] Job token was auto-refreshed after an upstream rejection; the chat has recovered");
3111
+ this.onJobTokenRefreshed?.({
3112
+ region: this.region,
3113
+ at
3114
+ });
3115
+ }
3043
3116
  async discoverModels(signal) {
3044
3117
  const pat = await this.requirePat(signal);
3045
3118
  const key = opaqueCredentialKey(pat);
@@ -3068,6 +3141,30 @@ var DefaultQoderTransport = class {
3068
3141
  if (signal?.aborted) throw aborted("Qoder request was aborted.");
3069
3142
  return pat;
3070
3143
  }
3144
+ /**
3145
+ * Stream one chat, reporting acceptance as soon as the FIRST chunk arrives.
3146
+ *
3147
+ * `streamQoderChat` throws before yielding anything when the upstream rejects
3148
+ * the request, so a first chunk means the credential was accepted. Reporting
3149
+ * at that moment — rather than after the whole stream drains — matters
3150
+ * because the consumer may close the stream early, and code after a
3151
+ * completed `yield*` would then never run.
3152
+ *
3153
+ * @param onAccepted - Called once, before the first chunk is forwarded.
3154
+ */
3155
+ async *streamChat(options, model, credentials, messages, onAccepted) {
3156
+ const stream = streamQoderChat(options, model, credentials, messages, {
3157
+ fetch: this.fetchImpl,
3158
+ logger: this.logger,
3159
+ region: this.region,
3160
+ responseHeaderTimeoutMs: this.responseHeaderTimeoutMs,
3161
+ streamIdleTimeoutMs: this.streamIdleTimeoutMs
3162
+ });
3163
+ const first = await stream.next();
3164
+ onAccepted();
3165
+ if (!first.done) yield first.value;
3166
+ yield* stream;
3167
+ }
3071
3168
  async *generate(options, model) {
3072
3169
  if (options.signal?.aborted) throw aborted("Request was aborted prior to generation.");
3073
3170
  validateQoderRequestShape(options, model);
@@ -3078,13 +3175,44 @@ var DefaultQoderTransport = class {
3078
3175
  credentials,
3079
3176
  preserveThinking: this.preserveThinking
3080
3177
  });
3081
- yield* streamQoderChat(options, model, await this.auth.getCredentials(pat, options.signal), messages, {
3082
- fetch: this.fetchImpl,
3083
- logger: this.logger,
3084
- region: this.region,
3085
- responseHeaderTimeoutMs: this.responseHeaderTimeoutMs,
3086
- streamIdleTimeoutMs: this.streamIdleTimeoutMs
3087
- });
3178
+ const chatCredentials = await this.auth.getCredentials(pat, options.signal);
3179
+ try {
3180
+ yield* this.streamChat(options, model, chatCredentials, messages, () => {
3181
+ this.flushPendingRefreshNotice();
3182
+ });
3183
+ return;
3184
+ } catch (error) {
3185
+ if (!isQoderAuthRejection(error) || options.signal?.aborted) throw error;
3186
+ this.logger?.warn?.("[Qoder Stream] Chat rejected as unauthorized; exchanging a fresh job token and retrying once", { status: error.failure.status });
3187
+ }
3188
+ let lastRejection;
3189
+ for (let round = 0; round < 2; round++) {
3190
+ const refreshed = await this.auth.exchangeFresh(pat, options.signal);
3191
+ this.pendingRefreshAt = Date.now();
3192
+ try {
3193
+ yield* this.streamChat(options, model, refreshed, messages, () => {
3194
+ this.flushPendingRefreshNotice();
3195
+ });
3196
+ return;
3197
+ } catch (error) {
3198
+ if (!isQoderAuthRejection(error) || options.signal?.aborted) throw error;
3199
+ lastRejection = error;
3200
+ if (round + 1 < 2) {
3201
+ this.logger?.warn?.("[Qoder Stream] Fresh job token also rejected; waiting 2s and retrying once more", {
3202
+ status: error.failure.status,
3203
+ round: round + 1
3204
+ });
3205
+ await new Promise((resolve, reject) => {
3206
+ const timer = setTimeout(resolve, 2e3);
3207
+ options.signal?.addEventListener("abort", () => {
3208
+ clearTimeout(timer);
3209
+ reject(aborted("Request was aborted during the re-auth backoff."));
3210
+ }, { once: true });
3211
+ });
3212
+ }
3213
+ }
3214
+ }
3215
+ throw lastRejection;
3088
3216
  }
3089
3217
  };
3090
3218
  //#endregion
@@ -3167,6 +3295,9 @@ function classifyUpstreamError(status, body) {
3167
3295
  for (const marker of [
3168
3296
  "invalid token",
3169
3297
  "invalid pat",
3298
+ "invalid job token",
3299
+ "token expired",
3300
+ "token has expired",
3170
3301
  "unauthorized",
3171
3302
  "forbidden",
3172
3303
  "not authenticated"
@@ -3882,7 +4013,7 @@ const DEFAULT_MAX_TOKENS = 32768;
3882
4013
  const DEFAULT_CONTEXT_WINDOW = 18e4;
3883
4014
  //#endregion
3884
4015
  //#region src/version.ts
3885
- const QODER_CONNECT_VERSION = "0.1.0";
4016
+ const QODER_CONNECT_VERSION = "0.1.2";
3886
4017
  //#endregion
3887
4018
  //#region src/host-heartbeat.ts
3888
4019
  /**
package/package.json CHANGED
@@ -1,116 +1,116 @@
1
- {
2
- "name": "dsh-qoder-connect",
3
- "displayName": "DSH Qoder Connect",
4
- "description": "将 Qoder(中国版/国际版)的模型接入 DeepSeek Harness — bring Qoder models into DeepSeek Harness.",
5
- "version": "0.1.0",
6
- "author": "corrinehu",
7
- "keywords": [
8
- "dsh-plugin",
9
- "deepseek-harness",
10
- "deepseek",
11
- "qoder",
12
- "model-provider"
13
- ],
14
- "type": "module",
15
- "engines": {
16
- "node": "^22.19.0 || >=24.0.0"
17
- },
18
- "main": "lib/index.js",
19
- "types": "lib/index.d.ts",
20
- "bin": {
21
- "dsh-qoder-connect": "lib/bin.js"
22
- },
23
- "scripts": {
24
- "prepack": "npm run build",
25
- "build": "tsdown",
26
- "test": "vitest run",
27
- "test:qoder": "node --test --test-isolation=none --experimental-strip-types tests/qoder/*.test.ts",
28
- "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
29
- "check": "pnpm run typecheck && pnpm run test && pnpm run test:qoder && pnpm run build"
30
- },
31
- "exports": {
32
- ".": {
33
- "types": "./lib/index.d.ts",
34
- "default": "./lib/index.js"
35
- },
36
- "./client": "./lib/client.js",
37
- "./cordis.patch.yml": "./cordis.patch.yml",
38
- "./package.json": "./package.json"
39
- },
40
- "files": [
41
- "lib",
42
- "assets",
43
- "cordis.patch.yml",
44
- "README.md",
45
- "README.en.md",
46
- "LICENSE"
47
- ],
48
- "license": "MIT",
49
- "repository": {
50
- "type": "git",
51
- "url": "git+https://github.com/masknull/dsh-qoder-connect.git"
52
- },
53
- "homepage": "https://github.com/masknull/dsh-qoder-connect#readme",
54
- "bugs": "https://github.com/masknull/dsh-qoder-connect/issues",
55
- "dsh": {
56
- "bundle": {
57
- "patch": "./cordis.patch.yml"
58
- },
59
- "client": {
60
- "inject": [
61
- "@deepseek-ai/dsh-api-remotes",
62
- "@deepseek-ai/dsh-api-session-controller",
63
- "@deepseek-ai/dsh-client-ui-renderer",
64
- "@deepseek-ai/dsh-client-ui-settings-plugins",
65
- "@deepseek-ai/dsh-client-ui-settings",
66
- "@deepseek-ai/dsh-client-ui-conversation",
67
- "@deepseek-ai/dsh-client-ui-model-selection",
68
- "@deepseek-ai/dsh-client-ui-sidebar",
69
- "@deepseek-ai/dsh-client-locale"
70
- ],
71
- "platform": "web"
72
- }
73
- },
74
- "peerDependencies": {
75
- "@deepseek-ai/cordis": "^4.0.2",
76
- "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
77
- "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
78
- "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
79
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
80
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
81
- "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
82
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
83
- "@deepseek-ai/schemastery": "^3.18.2",
84
- "@earendil-works/pi-ai": "^0.85.1",
85
- "react": "^18.2.0"
86
- },
87
- "devDependencies": {
88
- "@deepseek-ai/cordis": "^4.0.2",
89
- "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
90
- "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
91
- "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
92
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
93
- "@deepseek-ai/dsh-client-ui-model-selection": "0.1.5-rc.1",
94
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
95
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
96
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
97
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.1",
98
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
99
- "@deepseek-ai/dsh-credentials": "^0.1.5-rc.1",
100
- "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
101
- "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
102
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
103
- "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
104
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
105
- "@deepseek-ai/schemastery": "^3.18.2",
106
- "@earendil-works/pi-ai": "^0.85.1",
107
- "@types/node": "^22.20.0",
108
- "@types/react": "~18.3.1",
109
- "@types/react-test-renderer": "^18.3.1",
110
- "react": "^18.2.0",
111
- "react-test-renderer": "18.3.1",
112
- "tsdown": "^0.22.2",
113
- "typescript": "^5.9.3",
114
- "vitest": "^4.1.8"
115
- }
116
- }
1
+ {
2
+ "name": "dsh-qoder-connect",
3
+ "displayName": "DSH Qoder Connect",
4
+ "description": "将 Qoder(国内版 / 国际版)的模型接入 DeepSeek Harness — bring Qoder (CN & international) models into DeepSeek Harness.",
5
+ "version": "0.1.2",
6
+ "author": "corrinehu",
7
+ "keywords": [
8
+ "dsh-plugin",
9
+ "deepseek-harness",
10
+ "deepseek",
11
+ "qoder",
12
+ "model-provider"
13
+ ],
14
+ "type": "module",
15
+ "engines": {
16
+ "node": "^22.19.0 || >=24.0.0"
17
+ },
18
+ "main": "lib/index.js",
19
+ "types": "lib/index.d.ts",
20
+ "bin": {
21
+ "dsh-qoder-connect": "lib/bin.js"
22
+ },
23
+ "scripts": {
24
+ "prepack": "npm run build",
25
+ "build": "tsdown",
26
+ "test": "vitest run",
27
+ "test:qoder": "node --test --test-isolation=none --experimental-strip-types tests/qoder/*.test.ts",
28
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
29
+ "check": "pnpm run typecheck && pnpm run test && pnpm run test:qoder && pnpm run build"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./lib/index.d.ts",
34
+ "default": "./lib/index.js"
35
+ },
36
+ "./client": "./lib/client.js",
37
+ "./cordis.patch.yml": "./cordis.patch.yml",
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "lib",
42
+ "assets",
43
+ "cordis.patch.yml",
44
+ "README.md",
45
+ "README.en.md",
46
+ "LICENSE"
47
+ ],
48
+ "license": "MIT",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/masknull/dsh-qoder-connect.git"
52
+ },
53
+ "homepage": "https://github.com/masknull/dsh-qoder-connect#readme",
54
+ "bugs": "https://github.com/masknull/dsh-qoder-connect/issues",
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "inject": [
61
+ "@deepseek-ai/dsh-api-remotes",
62
+ "@deepseek-ai/dsh-api-session-controller",
63
+ "@deepseek-ai/dsh-client-ui-renderer",
64
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
65
+ "@deepseek-ai/dsh-client-ui-settings",
66
+ "@deepseek-ai/dsh-client-ui-conversation",
67
+ "@deepseek-ai/dsh-client-ui-model-selection",
68
+ "@deepseek-ai/dsh-client-ui-sidebar",
69
+ "@deepseek-ai/dsh-client-locale"
70
+ ],
71
+ "platform": "web"
72
+ }
73
+ },
74
+ "peerDependencies": {
75
+ "@deepseek-ai/cordis": "^4.0.2",
76
+ "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
77
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
78
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
79
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
80
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
81
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
82
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
83
+ "@deepseek-ai/schemastery": "^3.18.2",
84
+ "@earendil-works/pi-ai": "^0.85.1",
85
+ "react": "^18.2.0"
86
+ },
87
+ "devDependencies": {
88
+ "@deepseek-ai/cordis": "^4.0.2",
89
+ "@deepseek-ai/dsh-atomic-write": "^0.1.5-rc.1",
90
+ "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
91
+ "@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
92
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.1",
93
+ "@deepseek-ai/dsh-client-ui-model-selection": "0.1.5-rc.1",
94
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.5-rc.1",
95
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.5-rc.1",
96
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.5-rc.1",
97
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.5-rc.1",
98
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
99
+ "@deepseek-ai/dsh-credentials": "^0.1.5-rc.1",
100
+ "@deepseek-ai/dsh-home-paths": "^0.1.5-rc.1",
101
+ "@deepseek-ai/dsh-host-webserver": "^0.1.5-rc.1",
102
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
103
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.5-rc.1",
104
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
105
+ "@deepseek-ai/schemastery": "^3.18.2",
106
+ "@earendil-works/pi-ai": "^0.85.1",
107
+ "@types/node": "^22.20.0",
108
+ "@types/react": "~18.3.1",
109
+ "@types/react-test-renderer": "^18.3.1",
110
+ "react": "^18.2.0",
111
+ "react-test-renderer": "18.3.1",
112
+ "tsdown": "^0.22.2",
113
+ "typescript": "^5.9.3",
114
+ "vitest": "^4.1.8"
115
+ }
116
+ }