dsh-xdpool 2.4.2 → 2.4.3
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/CHANGELOG.md +54 -0
- package/lib/bin.js +51 -2
- package/lib/client.js +149 -1
- package/lib/index.d.ts +189 -23
- package/lib/index.js +723 -34
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +97 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
本项目的所有重要变更都会记录在此文件。
|
|
3
3
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
## 2.4.3 — 渠道包补上 11128、粘性绑定跨重启、网关白名单、目录 TTL,外加 CodeArts 重签开关
|
|
5
|
+
|
|
6
|
+
> 2.4.1 与 2.4.2 修的是**池车道**(`src/upstream.ts`);这一版把同一套 11128 规则补进**渠道车道**自己那份 channel pack,并顺带解决三件独立的事:会话粘性绑定不该止于进程边界、网关页面的勾选该是真开关、远端模型目录该会过期。四个 PR 来自社区([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)),复核与发布由维护者完成。另附一个默认关闭的 CodeArts 重签开关(见末节)。
|
|
7
|
+
|
|
8
|
+
### 渠道账号也吃 400 / 11128(PR [#63](https://github.com/XDTrees/dsh-xdpool/pull/63))
|
|
9
|
+
|
|
10
|
+
`workbuddy` 渠道在 `channels.ts` 的 `POOL_OWNED_CHANNELS` 列表里,但它**不走** `src/upstream.ts` —— 它由 channel pack 自己的 WorkBuddy 适配器应答。2.4.1 的两道内容指纹、2.4.2 的 role 转换与三个会话身份头都只加在了池车道,于是渠道账号继续吃到 2.4.2 已经替别人修好的那个 `400 / 11128`:
|
|
11
|
+
|
|
12
|
+
| 项 | 渠道适配器改前 | 改后 |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| 首条消息 role | `developer` | `system`(就地转换) |
|
|
15
|
+
| `powered by DeepSeek Harness` | 原样发出 | `running on DeepSeek Harness` |
|
|
16
|
+
| `DeepSeek Harness Web GUI` | 原样发出 | `DeepSeek Harness web interface` |
|
|
17
|
+
| `X-Conversation-ID` / `-Request-ID` / `-Message-ID` | 一个都不带 | 三个都带 |
|
|
18
|
+
|
|
19
|
+
三处编辑各自标了 `[xdpool-local]`,配套的 `tests/chat-identity-headers.test.ts` 直接对**已发布的 bundle** 做源断言(pack.js 是外部构建产物、无法 `import`,所以只能在字节层 pin 住)—— 下次 pack 刷新若把这三处冲掉,测试会变红,而不是静默回退。
|
|
20
|
+
|
|
21
|
+
### 会话粘性绑定跨过进程边界(PR [#64](https://github.com/XDTrees/dsh-xdpool/pull/64))
|
|
22
|
+
|
|
23
|
+
卡片承诺「一个会话一个账号」,但这个承诺原先止于进程边界:绑定存在一个 `Map` 里,每次重启都重新挑选,于是同一个会话一天里可能被三个账号各服务一遍,每次都按完整 prompt 计费。现在这张表写进插件状态目录(只存哈希到账号 id,不含任何消息内容),构造时加载,写入合并成一次原子 rename,并沿用与内存版相同的 LRU 上限。
|
|
24
|
+
|
|
25
|
+
测试特意把会话绑到一个「默认挑选不会选中」的账号 —— 新建的空池本来就会同意未绑定会话的选择,所以「两个池答案一致」在禁用加载时也能通过;实测把加载去掉会让其中三个用例变红。
|
|
26
|
+
|
|
27
|
+
### 网关页面的勾选不再是纯展示(PR [#65](https://github.com/XDTrees/dsh-xdpool/pull/65))
|
|
28
|
+
|
|
29
|
+
Gateway 页的模型勾选框与 LAN 共享列表此前只是**展示过滤器**:`/v1/models` 会隐藏没勾的模型,但请求路径照单全收。一台还留着旧模型 id 的手机、或照文档猜一个 id 的第二编辑器,就能继续拿卡片上写着「关」的模型花用户的钱。
|
|
30
|
+
|
|
31
|
+
现在强制发生在**调用方实际发出的那个 id 上**,且在任何车道剥前缀或改写 body **之前**,覆盖每条车道:pool、aggregate、free、combo,以及登录渠道。列表与闸门读同一份集合——这正是让卡片上的勾选有意义的原因。
|
|
32
|
+
|
|
33
|
+
### 远端模型目录会过期(PR [#66](https://github.com/XDTrees/dsh-xdpool/pull/66))
|
|
34
|
+
|
|
35
|
+
bundle 里每个适配器都把 `remoteModels` 缓存到进程结束,于是厂商改名或调整倍率要等重启才反映到卡片上——而**改名通常就是改价**,因为倍率是名字的一部分。现在由一道 TTL 闸门决定缓存能否应答,时长十分钟,可用 `DSH_XDPOOL_CATALOG_TTL_MS` 覆盖以便测试。
|
|
36
|
+
|
|
37
|
+
闸门刻意**不清缓存**:加载器只在成功时覆盖,所以刷新失败会继续沿用最后一份已知良好的目录,而不是退化成一堆裸 id;网关侧 30s 的冷却又限住了重试速率。同样无需重建 lib —— `channels.ts` 在运行期按路径加载 `pack.js`。
|
|
38
|
+
|
|
39
|
+
### 可选:CodeArts 请求带重签头(默认关闭,`[xdpool-local]`)
|
|
40
|
+
|
|
41
|
+
在 CodeArts 的 chat 请求上追加两个头:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
x-bili-resign: sdk-hmac-sha256
|
|
45
|
+
x-bili-resign-credential: base64({"ak":"…","sk":"…","token":"…"})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
用途是让 billion-context 用这对凭证**重签**一条它接管的 SDK-HMAC-SHA256 请求:该方案的签名覆盖请求体,压缩改写会让签名失效,而 bili 拿不到重签凭证时会按 compress-or-refuse 契约**本地拒发**(`bili_resign_unavailable`,表现为 403,再被误读成「密钥无效」)。带上这对头,bili 就能重算签名,压缩能力得以保留(billion-context 的 #1884 逃生口)。
|
|
49
|
+
|
|
50
|
+
**为什么默认关闭**:`billion-context` 只在 `optionalDependencies` 里,未安装时这两个头会**直达上游**,而它们携带的是 AK/SK;另外 bili 的进程内 native client 自己会构造这两个头(`resignExtra`),xdpool 加不加都不影响它。开关留给「xdpool 直接对接 bili 代理路径」或将来需要显式携带凭证的场合 —— 启用方式是在 pool 构造时传 `codeartsResignHeaders: true`(当前没有对应的用户级配置项)。
|
|
51
|
+
|
|
52
|
+
配套 `tests/codearts-resign-headers.test.ts` 用源断言 pin 住四件事:两个头名齐全、字段名与 bili `decodeApigCredential` 读取的 `ak`/`sk`/`token` 一致、插入位置在 `signRequestHuawei` 之后、以及默认关闭。
|
|
53
|
+
|
|
54
|
+
### 其他
|
|
55
|
+
|
|
56
|
+
- 新增测试:渠道包 11128 规则源断言、粘性绑定跨重启(含「去掉加载即变红」的对照)、网关白名单与列表一致性、目录 TTL。
|
|
57
|
+
|
|
4
58
|
## 2.4.2 — 第二道内容指纹:`Web GUI` 那句,以及 assistant 历史
|
|
5
59
|
> 2.4.1 之后仍有 11128。这一版补上第二道内容指纹,并把改写范围扩到 assistant 历史。修复来自社区 PR [#62](https://github.com/XDTrees/dsh-xdpool/pull/62)([@jmglsi](https://github.com/jmglsi)),实测复核与发布由维护者完成。
|
|
6
60
|
### 同一条提示词里还有第二道指纹
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
@@ -3363,6 +3363,9 @@ var WorkBuddyAccountPool = class {
|
|
|
3363
3363
|
* the LRU order: re-binding deletes then re-inserts.
|
|
3364
3364
|
*/
|
|
3365
3365
|
affinity = /* @__PURE__ */ new Map();
|
|
3366
|
+
/** Where the bindings above are persisted; undefined keeps them in memory. */
|
|
3367
|
+
affinityPath;
|
|
3368
|
+
affinitySaveTimer;
|
|
3366
3369
|
lastScanAtMs = 0;
|
|
3367
3370
|
/**
|
|
3368
3371
|
* The account id the user pinned, or undefined for none.
|
|
@@ -3472,6 +3475,8 @@ var WorkBuddyAccountPool = class {
|
|
|
3472
3475
|
this.distribution = options.distribution ?? "priority";
|
|
3473
3476
|
this.persistent = options.persistent ?? false;
|
|
3474
3477
|
this.storePath = accountStorePath();
|
|
3478
|
+
this.affinityPath = options.affinityPath;
|
|
3479
|
+
if (this.affinityPath !== void 0) this.loadAffinity();
|
|
3475
3480
|
}
|
|
3476
3481
|
/**
|
|
3477
3482
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -3747,6 +3752,49 @@ var WorkBuddyAccountPool = class {
|
|
|
3747
3752
|
if (oldest === void 0) break;
|
|
3748
3753
|
this.affinity.delete(oldest);
|
|
3749
3754
|
}
|
|
3755
|
+
this.scheduleAffinitySave();
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* Restore bindings written by a previous process.
|
|
3759
|
+
*
|
|
3760
|
+
* Tolerant like the other state files: a malformed document or one bad entry
|
|
3761
|
+
* must not take the pool down, and an account id that no longer exists is
|
|
3762
|
+
* simply never matched (the next acquire re-picks and rebinds).
|
|
3763
|
+
*/
|
|
3764
|
+
loadAffinity() {
|
|
3765
|
+
if (this.affinityPath === void 0) return;
|
|
3766
|
+
try {
|
|
3767
|
+
const bindings = JSON.parse(readFileSync(this.affinityPath, "utf8")).bindings;
|
|
3768
|
+
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return;
|
|
3769
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
3770
|
+
if (key === "" || typeof value !== "string" || value === "") continue;
|
|
3771
|
+
this.affinity.set(key, value);
|
|
3772
|
+
}
|
|
3773
|
+
while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
|
|
3774
|
+
const oldest = this.affinity.keys().next().value;
|
|
3775
|
+
if (oldest === void 0) break;
|
|
3776
|
+
this.affinity.delete(oldest);
|
|
3777
|
+
}
|
|
3778
|
+
} catch {}
|
|
3779
|
+
}
|
|
3780
|
+
/** Write the bindings back, coalescing a burst into one atomic rename. */
|
|
3781
|
+
scheduleAffinitySave() {
|
|
3782
|
+
if (this.affinityPath === void 0 || this.affinitySaveTimer !== void 0) return;
|
|
3783
|
+
this.affinitySaveTimer = setTimeout(() => {
|
|
3784
|
+
this.affinitySaveTimer = void 0;
|
|
3785
|
+
const path = this.affinityPath;
|
|
3786
|
+
if (path === void 0) return;
|
|
3787
|
+
try {
|
|
3788
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3789
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify({
|
|
3790
|
+
version: 1,
|
|
3791
|
+
bindings: Object.fromEntries(this.affinity)
|
|
3792
|
+
}, null, 2)}
|
|
3793
|
+
`, "utf8");
|
|
3794
|
+
renameSync(`${path}.tmp`, path);
|
|
3795
|
+
} catch {}
|
|
3796
|
+
}, 500);
|
|
3797
|
+
this.affinitySaveTimer.unref?.();
|
|
3750
3798
|
}
|
|
3751
3799
|
/**
|
|
3752
3800
|
* `sticky`: the account this conversation already used, when it can still
|
|
@@ -11217,7 +11265,8 @@ function createCore(logger) {
|
|
|
11217
11265
|
const pool = new WorkBuddyAccountPool({
|
|
11218
11266
|
...logger === void 0 ? {} : { logger },
|
|
11219
11267
|
client,
|
|
11220
|
-
persistent: true
|
|
11268
|
+
persistent: true,
|
|
11269
|
+
affinityPath: join(pluginDataDir(), "sticky-affinity.json")
|
|
11221
11270
|
});
|
|
11222
11271
|
return {
|
|
11223
11272
|
pool,
|
package/lib/client.js
CHANGED
|
@@ -1255,6 +1255,21 @@ window.__ModuleLoader__.load({
|
|
|
1255
1255
|
.dsm-xdpool-gw-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
1256
1256
|
.dsm-xdpool-gw-flash{margin:0;padding:6px 10px;border-radius:9px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l1,#ffffff14) 70%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 72%, transparent);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:11px;line-height:17px;word-break:break-word}
|
|
1257
1257
|
.dsm-xdpool-gw-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:11.5px;font-weight:560;line-height:17px;padding:4px 12px;border-radius:8px;border:1px solid color-mix(in srgb, var(--dsw-alias-border-l2,#36373b) 85%, transparent);background:color-mix(in srgb, var(--dsw-alias-bg-layer-3,#2a2c33) 70%, transparent);color:var(--dsw-alias-label-secondary,#c6c9d0);cursor:pointer;white-space:nowrap;transition:border-color .16s ease,color .16s ease,background .16s ease,transform .18s var(--ease-instrument,cubic-bezier(.16,1,.3,1))}
|
|
1258
|
+
/* The gateway's shared-model picker. Grouped by lane, and each row carries BOTH
|
|
1259
|
+
the display name and the wire id, because for the login channels they are
|
|
1260
|
+
different words: the user knows the model as "Qwen3.8-Flash · 免费" and has to
|
|
1261
|
+
type "qfmodel" into the client. Showing only one of the two makes the list
|
|
1262
|
+
either unreadable or unusable. */
|
|
1263
|
+
.dsm-xdpool-gw-group{margin-top:10px;display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
1264
|
+
.dsm-xdpool-gw-group-title{margin:0 0 2px;color:var(--dsw-alias-label-tertiary,#8a90a0);font-size:11px;line-height:16px;font-weight:650;letter-spacing:.1px;text-transform:lowercase}
|
|
1265
|
+
.dsm-xdpool-gw-model{display:grid;grid-template-columns:16px minmax(0,1fr) auto;align-items:center;gap:8px;padding:3px 6px;border-radius:8px;cursor:pointer;min-width:0}
|
|
1266
|
+
.dsm-xdpool-gw-model:hover{background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 60%, transparent)}
|
|
1267
|
+
.dsm-xdpool-gw-model input{appearance:none;width:14px;height:14px;margin:0;border-radius:4px;border:1px solid var(--dsw-alias-border-l2,#36373b);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1,#191a1e) 78%, transparent);cursor:pointer;display:grid;place-items:center}
|
|
1268
|
+
.dsm-xdpool-gw-model input:checked{background:var(--dsw-alias-brand-primary,#5686fe);border-color:var(--dsw-alias-brand-primary,#5686fe)}
|
|
1269
|
+
.dsm-xdpool-gw-model input:checked::after{content:"";width:6px;height:3px;border-left:1.5px solid #fff;border-bottom:1.5px solid #fff;transform:rotate(-45deg) translateY(-1px)}
|
|
1270
|
+
.dsm-xdpool-gw-model input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
1271
|
+
.dsm-xdpool-gw-model-name{color:var(--dsw-alias-label-primary,#e8e8ea);font-size:12px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
1272
|
+
.dsm-xdpool-gw-model-id{font-family:var(--dsw-alias-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8a90a0);white-space:nowrap}
|
|
1258
1273
|
.dsm-xdpool-gw-btn:hover:not(:disabled){transform:translateY(-1px);border-color:color-mix(in srgb, var(--dsw-alias-brand-primary,#5686fe) 55%, var(--dsw-alias-border-l2,#36373b));color:var(--dsw-alias-label-primary,#e8e8ea)}
|
|
1259
1274
|
.dsm-xdpool-gw-btn:active:not(:disabled){transform:translateY(0);box-shadow:inset 0 2px 4px rgb(0 0 0 / 22%)}
|
|
1260
1275
|
.dsm-xdpool-gw-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:2px}
|
|
@@ -10776,6 +10791,35 @@ window.__ModuleLoader__.load({
|
|
|
10776
10791
|
const [revealed, setRevealed] = (0, react.useState)(false);
|
|
10777
10792
|
const [host, setHost] = (0, react.useState)("127.0.0.1");
|
|
10778
10793
|
const [port, setPort] = (0, react.useState)("8326");
|
|
10794
|
+
/**
|
|
10795
|
+
* The in-progress share list, or undefined while it still mirrors the server.
|
|
10796
|
+
*
|
|
10797
|
+
* Held as a draft for the same reason the model card holds one: saving is a
|
|
10798
|
+
* deliberate act here, because every unchecked row stops working on whatever
|
|
10799
|
+
* device is already pointed at the gateway.
|
|
10800
|
+
*/
|
|
10801
|
+
const [draftShared, setDraftShared] = (0, react.useState)(void 0);
|
|
10802
|
+
/**
|
|
10803
|
+
* Which ids are shared right now.
|
|
10804
|
+
*
|
|
10805
|
+
* `status.shared === undefined` means the user has never chosen, and the
|
|
10806
|
+
* gateway is serving everything — so the picker must show every row ticked
|
|
10807
|
+
* rather than an empty list, which would read as "nothing is shared" and invite
|
|
10808
|
+
* a save that closes a gateway that was open.
|
|
10809
|
+
*/
|
|
10810
|
+
const candidates = status?.candidates ?? [];
|
|
10811
|
+
const serverShared = status?.shared ?? candidates.map((row) => row.id);
|
|
10812
|
+
const shared = draftShared ?? new Set(serverShared);
|
|
10813
|
+
const sharedDirty = draftShared !== void 0 && (draftShared.size !== serverShared.length || [...draftShared].some((id) => !serverShared.includes(id)));
|
|
10814
|
+
const toggleShared = (id) => {
|
|
10815
|
+
const next = new Set(shared);
|
|
10816
|
+
if (next.has(id)) next.delete(id);
|
|
10817
|
+
else next.add(id);
|
|
10818
|
+
setDraftShared(next);
|
|
10819
|
+
};
|
|
10820
|
+
const setAllShared = (on) => {
|
|
10821
|
+
setDraftShared(on ? new Set(candidates.map((row) => row.id)) : /* @__PURE__ */ new Set());
|
|
10822
|
+
};
|
|
10779
10823
|
/** True once the server's values were adopted, so polls do not stomp edits. */
|
|
10780
10824
|
const seeded = (0, react.useRef)(false);
|
|
10781
10825
|
const mounted = (0, react.useRef)(true);
|
|
@@ -11015,6 +11059,92 @@ window.__ModuleLoader__.load({
|
|
|
11015
11059
|
})
|
|
11016
11060
|
]
|
|
11017
11061
|
}),
|
|
11062
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11063
|
+
className: "dsm-xdpool-sec",
|
|
11064
|
+
children: [
|
|
11065
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11066
|
+
className: "dsm-xdpool-sectitle",
|
|
11067
|
+
children: t?.("gw.share") ?? "Shared models"
|
|
11068
|
+
}),
|
|
11069
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11070
|
+
className: "dsm-xdpool-gw-note",
|
|
11071
|
+
children: t?.("gw.shareHint") ?? ""
|
|
11072
|
+
}),
|
|
11073
|
+
candidates.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11074
|
+
className: "dsm-xdpool-gw-note",
|
|
11075
|
+
children: t?.("gw.shareNone") ?? ""
|
|
11076
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
11077
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11078
|
+
className: "dsm-xdpool-gw-actions",
|
|
11079
|
+
children: [
|
|
11080
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11081
|
+
type: "button",
|
|
11082
|
+
className: "dsm-xdpool-gw-btn",
|
|
11083
|
+
"data-on": "false",
|
|
11084
|
+
disabled: busy,
|
|
11085
|
+
onClick: () => {
|
|
11086
|
+
setAllShared(true);
|
|
11087
|
+
},
|
|
11088
|
+
children: t?.("gw.shareAll") ?? "Select all"
|
|
11089
|
+
}),
|
|
11090
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11091
|
+
type: "button",
|
|
11092
|
+
className: "dsm-xdpool-gw-btn",
|
|
11093
|
+
"data-on": "false",
|
|
11094
|
+
disabled: busy,
|
|
11095
|
+
onClick: () => {
|
|
11096
|
+
setAllShared(false);
|
|
11097
|
+
},
|
|
11098
|
+
children: t?.("gw.shareNoneBtn") ?? "Select none"
|
|
11099
|
+
}),
|
|
11100
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
11101
|
+
type: "button",
|
|
11102
|
+
className: "dsm-xdpool-gw-btn",
|
|
11103
|
+
"data-on": "true",
|
|
11104
|
+
disabled: busy || !sharedDirty,
|
|
11105
|
+
onClick: () => {
|
|
11106
|
+
write({ models: [...shared] }).then(() => {
|
|
11107
|
+
setDraftShared(void 0);
|
|
11108
|
+
});
|
|
11109
|
+
},
|
|
11110
|
+
children: t?.("gw.shareSave") ?? "Save"
|
|
11111
|
+
})
|
|
11112
|
+
]
|
|
11113
|
+
}),
|
|
11114
|
+
sharedDirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11115
|
+
className: "dsm-xdpool-gw-warn",
|
|
11116
|
+
children: t?.("gw.shareDirty") ?? ""
|
|
11117
|
+
}) : null,
|
|
11118
|
+
[...new Set(candidates.map((row) => row.source))].map((source) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11119
|
+
className: "dsm-xdpool-gw-group",
|
|
11120
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
11121
|
+
className: "dsm-xdpool-gw-group-title",
|
|
11122
|
+
children: source
|
|
11123
|
+
}), candidates.filter((row) => row.source === source).map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
11124
|
+
className: "dsm-xdpool-gw-model",
|
|
11125
|
+
children: [
|
|
11126
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
11127
|
+
type: "checkbox",
|
|
11128
|
+
checked: shared.has(row.id),
|
|
11129
|
+
disabled: busy,
|
|
11130
|
+
onChange: () => {
|
|
11131
|
+
toggleShared(row.id);
|
|
11132
|
+
}
|
|
11133
|
+
}),
|
|
11134
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11135
|
+
className: "dsm-xdpool-gw-model-name",
|
|
11136
|
+
children: row.name
|
|
11137
|
+
}),
|
|
11138
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11139
|
+
className: "dsm-xdpool-gw-model-id",
|
|
11140
|
+
children: row.id
|
|
11141
|
+
})
|
|
11142
|
+
]
|
|
11143
|
+
}, row.id))]
|
|
11144
|
+
}, source))
|
|
11145
|
+
] })
|
|
11146
|
+
]
|
|
11147
|
+
}),
|
|
11018
11148
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
11019
11149
|
className: "dsm-xdpool-sec",
|
|
11020
11150
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
@@ -12195,7 +12325,7 @@ window.__ModuleLoader__.load({
|
|
|
12195
12325
|
*
|
|
12196
12326
|
* @module dsh-xdpool/version
|
|
12197
12327
|
*/
|
|
12198
|
-
const POOL_VERSION = "2.4.
|
|
12328
|
+
const POOL_VERSION = "2.4.3";
|
|
12199
12329
|
//#endregion
|
|
12200
12330
|
//#region src/client/PoolCard.tsx
|
|
12201
12331
|
/**
|
|
@@ -14961,6 +15091,15 @@ window.__ModuleLoader__.load({
|
|
|
14961
15091
|
"gw.usageHint": "Any OpenAI-compatible app works. Set its base URL to the endpoint above, pick a model out of /v1/models, and send this key as the API key. The relay passes /v1/models and /v1/chat/completions only; everything else is refused before a byte is forwarded.",
|
|
14962
15092
|
"gw.loading": "Reading the relay status…",
|
|
14963
15093
|
"gw.saveFailed": "Could not save",
|
|
15094
|
+
"gw.share": "Shared models",
|
|
15095
|
+
"gw.shareHint": "Only the ticked models are reachable over the gateway. Unticking one does not just hide it from /v1/models — the gateway refuses it, because the relay key is a single secret shared by every device on the network, and a listing a client can ignore is not a restriction.",
|
|
15096
|
+
"gw.shareNone": "Nothing to offer yet: no model is reachable until an account pool, an aggregate key or a signed-in channel provides one.",
|
|
15097
|
+
"gw.shareAll": "Select all",
|
|
15098
|
+
"gw.shareNoneBtn": "Select none",
|
|
15099
|
+
"gw.shareSave": "Save sharing",
|
|
15100
|
+
"gw.shareSaved": "Gateway sharing updated",
|
|
15101
|
+
"gw.shareDirty": "Not saved yet. A device already using an unticked model will start getting 403.",
|
|
15102
|
+
"gw.shareCount": "{shared} of {total} shared",
|
|
14964
15103
|
"ledger.title": "Data dashboard",
|
|
14965
15104
|
"ledger.sub": "Every number here is counted from calls this pool actually made.",
|
|
14966
15105
|
"ledger.window": "last {count} call(s)",
|
|
@@ -15463,6 +15602,15 @@ window.__ModuleLoader__.load({
|
|
|
15463
15602
|
"gw.usageHint": "任何 OpenAI 兼容的客户端都行:接口地址填上面那个入口,模型从 /v1/models 里挑,密钥填到这个 API 密钥的位置。中继只放行 /v1/models 和 /v1/chat/completions,其余路径在转发任何字节之前就拒掉。",
|
|
15464
15603
|
"gw.loading": "正在读取中继状态……",
|
|
15465
15604
|
"gw.saveFailed": "没能保存",
|
|
15605
|
+
"gw.share": "共享哪些模型",
|
|
15606
|
+
"gw.shareHint": "只有勾选的模型能通过网关访问。取消勾选不只是把它从 /v1/models 里藏起来——网关会直接拒绝它。因为中继密钥是整台网络上所有设备共用的同一把,而一份客户端可以无视的列表算不上限制。",
|
|
15607
|
+
"gw.shareNone": "暂时无可提供:在账号池、聚合渠道密钥或已登录的渠道给出模型之前,这里不会有任何条目。",
|
|
15608
|
+
"gw.shareAll": "全选",
|
|
15609
|
+
"gw.shareNoneBtn": "全不选",
|
|
15610
|
+
"gw.shareSave": "保存共享设置",
|
|
15611
|
+
"gw.shareSaved": "网关共享设置已更新",
|
|
15612
|
+
"gw.shareDirty": "尚未保存。已在使用某个被取消勾选模型的设备,会开始收到 403。",
|
|
15613
|
+
"gw.shareCount": "已共享 {shared} / {total}",
|
|
15466
15614
|
"ledger.title": "数据看板",
|
|
15467
15615
|
"ledger.sub": "这里每个数字,都是这个池子真实发出去的调用数出来的。",
|
|
15468
15616
|
"ledger.window": "最近 {count} 次调用",
|
package/lib/index.d.ts
CHANGED
|
@@ -934,6 +934,15 @@ export declare function candidateAuthDirs(env?: NodeJS.ProcessEnv): string[];
|
|
|
934
934
|
/** How the pool chooses which account serves the next request. */
|
|
935
935
|
type AccountDistribution = 'priority' | 'round-robin' | 'balanced' | 'sticky' | 'expiry';
|
|
936
936
|
interface AccountPoolOptions {
|
|
937
|
+
/**
|
|
938
|
+
* File the `sticky` bindings are remembered in, or undefined for memory only.
|
|
939
|
+
*
|
|
940
|
+
* The card promises one account per conversation, and that promise used to end
|
|
941
|
+
* at the process boundary: every restart re-picked, so one session could be
|
|
942
|
+
* served by three different accounts in a day and pay full prompt cost each
|
|
943
|
+
* time. The caller passes the plugin's own state path; tests pass a temp file.
|
|
944
|
+
*/
|
|
945
|
+
affinityPath?: string;
|
|
937
946
|
/** Logger for discovery and rotation events. */
|
|
938
947
|
logger?: {
|
|
939
948
|
info?(...args: unknown[]): void;
|
|
@@ -1026,6 +1035,9 @@ export declare class WorkBuddyAccountPool {
|
|
|
1026
1035
|
* the LRU order: re-binding deletes then re-inserts.
|
|
1027
1036
|
*/
|
|
1028
1037
|
private readonly affinity;
|
|
1038
|
+
/** Where the bindings above are persisted; undefined keeps them in memory. */
|
|
1039
|
+
private readonly affinityPath;
|
|
1040
|
+
private affinitySaveTimer;
|
|
1029
1041
|
private lastScanAtMs;
|
|
1030
1042
|
/**
|
|
1031
1043
|
* The account id the user pinned, or undefined for none.
|
|
@@ -1205,6 +1217,16 @@ export declare class WorkBuddyAccountPool {
|
|
|
1205
1217
|
private pickRoundRobin;
|
|
1206
1218
|
/** Remember which account a conversation is bound to, keeping LRU order. */
|
|
1207
1219
|
private bindAffinity;
|
|
1220
|
+
/**
|
|
1221
|
+
* Restore bindings written by a previous process.
|
|
1222
|
+
*
|
|
1223
|
+
* Tolerant like the other state files: a malformed document or one bad entry
|
|
1224
|
+
* must not take the pool down, and an account id that no longer exists is
|
|
1225
|
+
* simply never matched (the next acquire re-picks and rebinds).
|
|
1226
|
+
*/
|
|
1227
|
+
private loadAffinity;
|
|
1228
|
+
/** Write the bindings back, coalescing a burst into one atomic rename. */
|
|
1229
|
+
private scheduleAffinitySave;
|
|
1208
1230
|
/**
|
|
1209
1231
|
* `sticky`: the account this conversation already used, when it can still
|
|
1210
1232
|
* serve the model being asked for.
|
|
@@ -1763,6 +1785,69 @@ interface PoolWebRelayPatch {
|
|
|
1763
1785
|
host?: string;
|
|
1764
1786
|
port?: number;
|
|
1765
1787
|
key?: string;
|
|
1788
|
+
/**
|
|
1789
|
+
* The models to share with LAN clients, by gateway id.
|
|
1790
|
+
*
|
|
1791
|
+
* An empty array is an answer, not an absence: it means the user unticked
|
|
1792
|
+
* everything, and the gateway then refuses every model. Only "never set"
|
|
1793
|
+
* leaves the gateway open.
|
|
1794
|
+
*/
|
|
1795
|
+
models?: string[];
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* What {@link POOL_RELAY_PATH} answers with.
|
|
1799
|
+
*
|
|
1800
|
+
* `listening` is separate from `enabled` because "you turned it on and it could
|
|
1801
|
+
* not bind" is a state the user has to be able to see. Collapsing the two would
|
|
1802
|
+
* tell them to switch on something that is already on.
|
|
1803
|
+
*
|
|
1804
|
+
* `key` is the RELAY key, in full, on purpose: the card has to show it so it can
|
|
1805
|
+
* be pasted into another device. It is the key the plugin minted for LAN
|
|
1806
|
+
* clients — the shim's own per-process secret is never returned by any route.
|
|
1807
|
+
*
|
|
1808
|
+
* The type lives here rather than beside the listener because the client card
|
|
1809
|
+
* renders it, and importing the listener's module would drag `node:http` into a
|
|
1810
|
+
* browser bundle.
|
|
1811
|
+
*/
|
|
1812
|
+
interface PoolWebRelayStatus {
|
|
1813
|
+
enabled: boolean;
|
|
1814
|
+
listening: boolean;
|
|
1815
|
+
host: string;
|
|
1816
|
+
port: number;
|
|
1817
|
+
/** Where a client should point; '' until something is listening. */
|
|
1818
|
+
endpoint: string;
|
|
1819
|
+
key: string;
|
|
1820
|
+
/** Why nothing is bound; '' when there is nothing to explain. */
|
|
1821
|
+
error: string;
|
|
1822
|
+
/**
|
|
1823
|
+
* Every model the gateway could serve, with the name a human reads.
|
|
1824
|
+
*
|
|
1825
|
+
* Built from the shim's own roster, so the picker cannot offer a model the
|
|
1826
|
+
* gateway would then refuse. The name is here because the OpenAI `/v1/models`
|
|
1827
|
+
* schema has no such field: `qfmodel` is what a client sends, and
|
|
1828
|
+
* `Qwen3.8-Flash · 免费` is what the user knows it as, and only this card can
|
|
1829
|
+
* show both together.
|
|
1830
|
+
*
|
|
1831
|
+
* REQUIRED, and required on the POST answer too: the card treats a write
|
|
1832
|
+
* response as the new state, so a save that answered without the roster emptied
|
|
1833
|
+
* the picker until the next poll. Declaring it optional let the write path
|
|
1834
|
+
* return the bare listener status and typecheck clean.
|
|
1835
|
+
*/
|
|
1836
|
+
candidates: readonly PoolWebGatewayModel[];
|
|
1837
|
+
/**
|
|
1838
|
+
* The shared list in force. `undefined` means the user has never chosen, which
|
|
1839
|
+
* the card must render as "everything is shared" rather than an empty picker.
|
|
1840
|
+
*/
|
|
1841
|
+
shared?: readonly string[];
|
|
1842
|
+
}
|
|
1843
|
+
/** One row of the gateway's model picker. */
|
|
1844
|
+
interface PoolWebGatewayModel {
|
|
1845
|
+
/** What a client sends, and what the allowlist stores. */
|
|
1846
|
+
id: string;
|
|
1847
|
+
/** The display name from the lane that serves it. */
|
|
1848
|
+
name: string;
|
|
1849
|
+
/** The lane or channel it belongs to; what the picker groups under. */
|
|
1850
|
+
source: string;
|
|
1766
1851
|
}
|
|
1767
1852
|
/**
|
|
1768
1853
|
* What a key probe answered.
|
|
@@ -2451,6 +2536,21 @@ interface RelaySetting {
|
|
|
2451
2536
|
port?: number;
|
|
2452
2537
|
/** URL-safe shared secret LAN clients authenticate with. */
|
|
2453
2538
|
key?: string;
|
|
2539
|
+
/**
|
|
2540
|
+
* Which models the gateway may serve, by gateway id (`buddy:hy3`,
|
|
2541
|
+
* `qoder:qfmodel`, `free:…`, `provider/model`).
|
|
2542
|
+
*
|
|
2543
|
+
* Absent and empty are DIFFERENT answers and the distinction is load-bearing:
|
|
2544
|
+
* absent means "no selection has ever been made, serve everything", so
|
|
2545
|
+
* upgrading does not silently break a gateway a device is already pointed at;
|
|
2546
|
+
* empty means "the user unticked everything", and is honoured as such.
|
|
2547
|
+
*
|
|
2548
|
+
* This is an allowlist enforced on the request, not just on the listing. A
|
|
2549
|
+
* relay key is one value shared by every device on the LAN, so "hidden from
|
|
2550
|
+
* /v1/models" would leave every other lane reachable to anything that guessed
|
|
2551
|
+
* an id — including the login channels, whose quota is this account's.
|
|
2552
|
+
*/
|
|
2553
|
+
models?: string[];
|
|
2454
2554
|
}
|
|
2455
2555
|
//#endregion
|
|
2456
2556
|
//#region src/aggregate.d.ts
|
|
@@ -2717,6 +2817,40 @@ declare class UsageRecorder {
|
|
|
2717
2817
|
private days;
|
|
2718
2818
|
}
|
|
2719
2819
|
//#endregion
|
|
2820
|
+
//#region src/channel-lane.d.ts
|
|
2821
|
+
/** One model as the gateway lists and routes it. */
|
|
2822
|
+
interface ChannelLaneRow {
|
|
2823
|
+
/** The composite id a client sends: `<channel>:<model>`. */
|
|
2824
|
+
id: string;
|
|
2825
|
+
channel: string;
|
|
2826
|
+
modelId: string;
|
|
2827
|
+
name: string;
|
|
2828
|
+
}
|
|
2829
|
+
/** One translated event, so the caller decides how to frame it as SSE. */
|
|
2830
|
+
type ChannelLaneEvent = {
|
|
2831
|
+
kind: 'delta';
|
|
2832
|
+
delta: Record<string, unknown>;
|
|
2833
|
+
} | {
|
|
2834
|
+
kind: 'usage';
|
|
2835
|
+
usage: Record<string, number>;
|
|
2836
|
+
} | {
|
|
2837
|
+
kind: 'done';
|
|
2838
|
+
finishReason: string;
|
|
2839
|
+
};
|
|
2840
|
+
interface ChannelLane {
|
|
2841
|
+
rows: () => Promise<readonly ChannelLaneRow[]>;
|
|
2842
|
+
resolve: (id: string) => {
|
|
2843
|
+
channel: string;
|
|
2844
|
+
modelId: string;
|
|
2845
|
+
} | undefined;
|
|
2846
|
+
turn: (input: {
|
|
2847
|
+
channel: string;
|
|
2848
|
+
modelId: string;
|
|
2849
|
+
body: Record<string, unknown>;
|
|
2850
|
+
signal: AbortSignal;
|
|
2851
|
+
}) => AsyncGenerator<ChannelLaneEvent>;
|
|
2852
|
+
}
|
|
2853
|
+
//#endregion
|
|
2720
2854
|
//#region src/shim.d.ts
|
|
2721
2855
|
interface ShimLogger {
|
|
2722
2856
|
info?(...args: unknown[]): void;
|
|
@@ -2727,8 +2861,29 @@ interface WorkBuddyShim {
|
|
|
2727
2861
|
ready: Promise<void>;
|
|
2728
2862
|
baseUrl(): string;
|
|
2729
2863
|
token(): string;
|
|
2864
|
+
/**
|
|
2865
|
+
* Everything the gateway could serve, with display names.
|
|
2866
|
+
*
|
|
2867
|
+
* The card's model picker reads this; `/v1/models` maps the same entries down
|
|
2868
|
+
* to ids. One source so the card cannot offer a model the gateway then refuses.
|
|
2869
|
+
*/
|
|
2870
|
+
roster(): Promise<readonly GatewayModelEntry[]>;
|
|
2730
2871
|
close(): Promise<void>;
|
|
2731
2872
|
}
|
|
2873
|
+
/**
|
|
2874
|
+
* One gateway-visible model: the id a client sends, the name a human reads, and
|
|
2875
|
+
* the lane it belongs to.
|
|
2876
|
+
*
|
|
2877
|
+
* Both are needed because they disagree in exactly the places that matter. A
|
|
2878
|
+
* qoder model is `qfmodel` on the wire and `Qwen3.8-Flash · 免费` in every
|
|
2879
|
+
* interface the user has met it in, and the OpenAI `/v1/models` schema has no
|
|
2880
|
+
* field for the second — so the name can only ever be shown here, on the card.
|
|
2881
|
+
*/
|
|
2882
|
+
interface GatewayModelEntry {
|
|
2883
|
+
id: string;
|
|
2884
|
+
name: string;
|
|
2885
|
+
source: string;
|
|
2886
|
+
}
|
|
2732
2887
|
/**
|
|
2733
2888
|
* Everything the aggregate lane needs to answer one request.
|
|
2734
2889
|
*
|
|
@@ -2752,9 +2907,18 @@ interface ExtraLaneOptions {
|
|
|
2752
2907
|
free: ExtraLaneSource;
|
|
2753
2908
|
}
|
|
2754
2909
|
interface ExtraLaneSource {
|
|
2910
|
+
/**
|
|
2911
|
+
* The rows this lane offers.
|
|
2912
|
+
*
|
|
2913
|
+
* `name` is the human-facing display name the free catalog already keeps (a
|
|
2914
|
+
* raw upstream id is deliberately never shown to a picker); it is optional
|
|
2915
|
+
* here because the lane's own contract only ever needed the ids, and the
|
|
2916
|
+
* gateway's model picker is the first consumer that wants the label too.
|
|
2917
|
+
*/
|
|
2755
2918
|
current: () => readonly {
|
|
2756
2919
|
modelId: string;
|
|
2757
2920
|
id: string;
|
|
2921
|
+
name?: string;
|
|
2758
2922
|
}[];
|
|
2759
2923
|
/**
|
|
2760
2924
|
* Run one turn, handing back the frames to write.
|
|
@@ -2809,6 +2973,29 @@ interface WorkBuddyShimOptions {
|
|
|
2809
2973
|
* give the user two names for one lane.
|
|
2810
2974
|
*/
|
|
2811
2975
|
extra?: ExtraLaneOptions;
|
|
2976
|
+
/**
|
|
2977
|
+
* The fifth lane: the 扫码登录 channels, reached through the host's own LLM
|
|
2978
|
+
* seam rather than a socket this plugin owns.
|
|
2979
|
+
*
|
|
2980
|
+
* CN-shim-only for the aggregate lane's reason — a second copy under the
|
|
2981
|
+
* global provider id would list every channel twice — and the relay is wired to
|
|
2982
|
+
* this shim, so this is also the only shim the LAN gateway can see.
|
|
2983
|
+
*/
|
|
2984
|
+
channels?: ChannelLane;
|
|
2985
|
+
/**
|
|
2986
|
+
* The gateway allowlist, re-read per request.
|
|
2987
|
+
*
|
|
2988
|
+
* `undefined` means "no selection has been made: serve everything to LAN
|
|
2989
|
+
* clients too", so upgrading cannot silently break a device already pointed at
|
|
2990
|
+
* the relay. An empty array is the opposite answer — the user unticked
|
|
2991
|
+
* everything — and is enforced as such.
|
|
2992
|
+
*
|
|
2993
|
+
* Only consulted for requests carrying {@link RELAY_HOP_HEADER}, which is the
|
|
2994
|
+
* relay's own marker. The host's adapter reaches this same process without it,
|
|
2995
|
+
* so the models DSH offers in its picker are never narrowed by a list meant
|
|
2996
|
+
* for other machines.
|
|
2997
|
+
*/
|
|
2998
|
+
lanModels?: () => readonly string[] | undefined;
|
|
2812
2999
|
}
|
|
2813
3000
|
export declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddyShim;
|
|
2814
3001
|
//#endregion
|
|
@@ -3618,27 +3805,6 @@ export declare function readPreferredIdSync(path?: string): string | undefined;
|
|
|
3618
3805
|
*/
|
|
3619
3806
|
export declare function writePreferredId(accountId: string | undefined, path?: string): Promise<void>;
|
|
3620
3807
|
//#endregion
|
|
3621
|
-
//#region src/relay.d.ts
|
|
3622
|
-
interface PoolRelayStatus {
|
|
3623
|
-
enabled: boolean;
|
|
3624
|
-
/** Whether a listener is actually bound right now. */
|
|
3625
|
-
listening: boolean;
|
|
3626
|
-
host: string;
|
|
3627
|
-
port: number;
|
|
3628
|
-
/** Where a client should point; '' until something is listening. */
|
|
3629
|
-
endpoint: string;
|
|
3630
|
-
/**
|
|
3631
|
-
* The relay key in full.
|
|
3632
|
-
*
|
|
3633
|
-
* Returned on purpose: the card has to show it once so the user can copy it
|
|
3634
|
-
* into the other device. This is the key WE minted for LAN clients, not the
|
|
3635
|
-
* shim secret, and it grants nothing beyond reaching the pool.
|
|
3636
|
-
*/
|
|
3637
|
-
key: string;
|
|
3638
|
-
/** Why nothing is bound; '' when there is nothing to explain. */
|
|
3639
|
-
error: string;
|
|
3640
|
-
}
|
|
3641
|
-
//#endregion
|
|
3642
3808
|
//#region src/web-status.d.ts
|
|
3643
3809
|
/** Constructor dependencies — a narrow slice of the pool runtime. */
|
|
3644
3810
|
interface PoolStatusRouteOptions {
|
|
@@ -3836,9 +4002,9 @@ interface PoolStatusRouteOptions {
|
|
|
3836
4002
|
* the plugin minted for LAN clients. The shim's own per-process secret is never
|
|
3837
4003
|
* exposed by any route.
|
|
3838
4004
|
*/
|
|
3839
|
-
relayDocument?: () => Promise<
|
|
4005
|
+
relayDocument?: () => Promise<PoolWebRelayStatus>;
|
|
3840
4006
|
/** Apply a relay patch, re-evaluate the listener, and answer with the new state. */
|
|
3841
|
-
saveRelay?: (patch: PoolWebRelayPatch) => Promise<
|
|
4007
|
+
saveRelay?: (patch: PoolWebRelayPatch) => Promise<PoolWebRelayStatus>;
|
|
3842
4008
|
}
|
|
3843
4009
|
/**
|
|
3844
4010
|
* Assemble the card's status document. Per-account credits and check-in state
|