dsh-workbuddy-xdpool 0.3.0 → 0.4.1
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 +75 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/assets/model-picker.png +0 -0
- package/assets/settings-card.png +0 -0
- package/lib/bin.js +252 -46
- package/lib/client.js +128 -13
- package/lib/index.d.ts +101 -17
- package/lib/index.js +280 -66
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,81 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 0.4.1 (2026-09-19)
|
|
8
|
+
|
|
9
|
+
这次主要修了三个毛病。
|
|
10
|
+
|
|
11
|
+
### 一、国际版账号终于能被识别了
|
|
12
|
+
|
|
13
|
+
之前国际版账号一直检测不到。原因是筛选登录文件时只认「点号」拼出来的文件名,而国际版客户端登录后生成的文件是 `workbuddy-desktop-ai.info`(中间是连字符),于是被整个跳过了。
|
|
14
|
+
|
|
15
|
+
现在改成只看文件后缀,不管文件名长什么样都能收进来。实测国内版 4 个、国际版 1 个账号都能正常识别。
|
|
16
|
+
|
|
17
|
+
### 二、国内版和国际版不再共用同一份模型列表
|
|
18
|
+
|
|
19
|
+
之前两个版本共用同一份模型清单,而且这份清单只从国内版账号拉取 —— 换句话说,国际版看到的其实一直是国内版的模型。
|
|
20
|
+
|
|
21
|
+
现在两个版本各查各的:
|
|
22
|
+
|
|
23
|
+
- 国内版:16 个模型
|
|
24
|
+
- 国际版:21 个模型(之前只有凑数的 10 个,而且内容还是国内版的)
|
|
25
|
+
|
|
26
|
+
国际版独有的 Auto / Fast / Balanced 这些模型现在也能正常显示。
|
|
27
|
+
|
|
28
|
+
顺带修了国际版读取模型的方式:它的接口地址和客户端标识跟国内版都不一样,之前照搬国内版的写法,国际版那边会直接报错,所以才一直退回那份凑数的清单。
|
|
29
|
+
|
|
30
|
+
### 三、模型选择器里同一个供应商不再被拆成两组
|
|
31
|
+
|
|
32
|
+
之前模型选择器里「WorkBuddy XD Pool(国内版)」「(国际版)」会各出现两次,免费模型和付费模型被分到不同的组,中间还夹着别的插件,看着像重复注册了。
|
|
33
|
+
|
|
34
|
+
根因是插件把每个模型都上报成「不支持图片」。宿主上如果装了图片增强类插件,它会给这些模型再补一条**名字一模一样**的条目,于是同一个名字就出现了两遍。
|
|
35
|
+
|
|
36
|
+
现在按上游真实的能力上报,分组就合并回来了。
|
|
37
|
+
|
|
38
|
+
另外卡片里的「图片输入」勾选也改了语义:勾选只表示**额外**支持图片,不会再反过来把没勾选的模型降级成「不支持」。
|
|
39
|
+
|
|
40
|
+
## 0.4.0 (2026-09-16)
|
|
41
|
+
|
|
42
|
+
本次更新来自两条用户反馈与一次上游兼容性对齐:账号现在可以**按顺序逐个用尽**(不再平均分摊),积分包**逐条显示具体到期时刻**,并修掉了一处会让所有接口返回 401 的凭据选择缺陷。
|
|
43
|
+
|
|
44
|
+
### 新增
|
|
45
|
+
|
|
46
|
+
- **账号使用方式可切换(优先 / 轮换)**:卡片状态行下方新增开关,默认**优先模式**。
|
|
47
|
+
|
|
48
|
+
- **优先模式**:始终由同一个账号服务,直到它被限流才顺延到下一个;冷却结束后它**立刻回到队首**接管 —— 该账号的积分在冷却期间并未被消耗,所以无需从头排队。
|
|
49
|
+
- **轮换模式**:保留原来的平均分摊行为,供希望把额度摊平到各账号的用户选择。
|
|
50
|
+
- 切换**即时生效**并写入插件设置,**对所有账号、所有切换状态通用**,不需要重启宿主。
|
|
51
|
+
- 优先级顺序是确定性的:桌面 App 当前登录的账号 → 上游签发时间较新者 → 存储过期时间较晚者。此前顺序取决于文件名字典序,实际会出现 `0, 2, 1` 这类非预期排列。
|
|
52
|
+
|
|
53
|
+
- **积分包逐条显示到期时刻**:原先只给出「3 天内到期」的合计数,看不到具体时间;现在每个积分包单独标注它的到期时刻(精确到分钟,例如「到期 09/19 15:36」),3 天内到期的以琥珀色高亮。月度周期套餐显示下次刷新时刻(「刷新 10/01 00:00」)。
|
|
54
|
+
|
|
55
|
+
- 之所以精确到分钟而不是只给日期:上游发放一次性礼包的到期时刻是**任意的钟点**,并非「过 0 点即失效」,只显示日期会让人误判。
|
|
56
|
+
|
|
57
|
+
### 布局
|
|
58
|
+
|
|
59
|
+
- **积分包列表改为两行网格**:包名与数量在首行左右分栏,到期时刻在次行跨栏并与包名左对齐。此前到期时刻被挤到第二行且从容器左边缘起排,列表看起来参差不齐。
|
|
60
|
+
|
|
61
|
+
### 修复
|
|
62
|
+
|
|
63
|
+
- **同一账号存在多个凭据文件时会选中已失效的那一个,导致积分 / 签到 / 模型全部返回 401**。此前按**存储的 `expiresAt` 最大**来挑选凭据,但 `expiresAt` 描述的是「签发时有效期有多长」,**不代表上游仍然接受** —— 上游吊销 token 时不会同步改写该字段,于是一个早已失效的备份可以声称比真实可用的文件**更晚过期**,从而被长期选中。
|
|
64
|
+
|
|
65
|
+
- 现改用 **`auth.lastRefreshTime`(上游自己的签发时间)** 作为新鲜度判据,并在解析凭据时读入该字段。排序优先级:① 桌面 App 当前登录的 live 文件 → ② 签发时间较新者 → ③ 存储过期时间(仅作为文档缺少签发时间时的回退,保证比较是全序的)。
|
|
66
|
+
|
|
67
|
+
- **国际版登录域名识别不全**:参考桌面客户端自身的国际域名清单(`workbuddy.ai`、`workbuddy.cc`),并补充 CodeBuddy CLI 使用的 `codebuddy.ai`。此前只识别 `workbuddy.ai`,落在其他拼写上的国际账号会被误判为国内版,token 被发往国内网关而在 openresty 层被拒绝。
|
|
68
|
+
|
|
69
|
+
- **国际网关写死单一域名**:国际版账号按品牌域名区分且**互不通用**(在 `codebuddy.ai` 签发的 token 会被 `workbuddy.ai` 网关拒绝,反之亦然),因此网关地址改为**跟随凭据自身的域**,未识别的域名回落至桌面端网关。chat / billing / referer / 模型目录四处一并生效。
|
|
70
|
+
|
|
71
|
+
- **上游以 HTML 页面拒绝凭据时给出可执行提示**:识别 openresty / APISIX 的鉴权拒绝(401/403 且响应体是 HTML),改为提示「凭据已被上游网关拒绝,通常说明用的是旧登录留下的失效凭据;请重新登录 WorkBuddy 桌面端后在卡片中选择该账号」,不再原样抛出 HTML 片段。非鉴权类的非 JSON 响应保持原有的通用提示。
|
|
72
|
+
|
|
73
|
+
- **部分模型不显示思考档位**:上游部分模型的推理元数据使用**单数形式**(`effort` 而非 `supportedEfforts`),此前该形态会被直接丢弃。现将其规范化为插件已理解的复数形状,并携带声明的档位到 `defaultEffort`。
|
|
74
|
+
|
|
75
|
+
### 测试
|
|
76
|
+
|
|
77
|
+
- 新增 `tests/pool-priority.test.ts`(10 例):优先模式下连续请求命中同一账号、仅在该账号冷却后顺延、冷却结束后回归队首、按模型冷却不影响其他模型;轮换模式的轮转与运行时切换;凭据新鲜度的四类排序(live 优先、签发时间优先、缺失签发时间时回退过期时间、秒/毫秒单位归一化)。
|
|
78
|
+
- 修正 `tests/failover.test.ts` 中依赖轮转顺序的限流桩:改为按**已出现的不同凭据数**计次,不再假设「token 序号即账号位置」,因此与账号排序解耦。
|
|
79
|
+
- 全套 **36 个测试通过**。
|
|
80
|
+
|
|
81
|
+
|
|
7
82
|
## 0.3.0 (2026-09-16)
|
|
8
83
|
|
|
9
84
|
本次更新把卡片界面按参考插件的形态重做了一遍,并补齐了国内版 / 国际版的完整分离。
|
package/README.en.md
CHANGED
|
@@ -15,11 +15,11 @@ Merge **every WorkBuddy account** you have ever signed into on this machine into
|
|
|
15
15
|
|
|
16
16
|
**Settings card (Settings → Plugins → DSH WorkBuddy XD Pool)**
|
|
17
17
|
|
|
18
|
-

|
|
19
19
|
|
|
20
|
-
**Model picker (rate multiplier baked into model.name
|
|
20
|
+
**Model picker (domestic and international appear as two separate supplier groups; the rate multiplier is baked into model.name because the DSH 0.1.2 composer only reads name)**
|
|
21
21
|
|
|
22
|
-

|
|
23
23
|
|
|
24
24
|
**Domestic / international dual suppliers (each with its own accounts, credits and models, usable at the same time)**
|
|
25
25
|
|
package/README.md
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
**插件配置卡片(设置 → 插件 → DSH WorkBuddy XD Pool)**
|
|
17
17
|
|
|
18
|
-

|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
**模型选择器(国内版 / 国际版 两个独立供应商分组;倍率直接拼进 model.name:DSH 0.1.2 composer 只读 name)**
|
|
21
21
|
|
|
22
|
-

|
|
23
23
|
|
|
24
24
|
**国内版 / 国际版 双供应商(各自独立账号、积分与模型,可同时使用)**
|
|
25
25
|
|
package/assets/model-picker.png
CHANGED
|
Binary file
|
package/assets/settings-card.png
CHANGED
|
Binary file
|
package/lib/bin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
6
|
import "@earendil-works/pi-ai";
|
|
7
7
|
import "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
@@ -15,7 +15,18 @@ const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
|
15
15
|
/** Global base for `workbuddy.ai` logins. */
|
|
16
16
|
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
17
17
|
/** Client UA the desktop CLI uses. */
|
|
18
|
+
/** Client UA the desktop CLI uses — the CN gateway answers this one. */
|
|
18
19
|
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
20
|
+
/**
|
|
21
|
+
* Desktop app UA. The global gateway serves its product config only to this
|
|
22
|
+
* client channel: the CLI UA gets a truncated roster (or an HTTP 500), which
|
|
23
|
+
* is why the international catalog must be read with the desktop spelling.
|
|
24
|
+
*/
|
|
25
|
+
const DESKTOP_UA = "WorkBuddy/5.5.2";
|
|
26
|
+
/** CN model catalog. */
|
|
27
|
+
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
28
|
+
/** Global product config, which carries the international model roster. */
|
|
29
|
+
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
19
30
|
const JSON_TIMEOUT_MS = 3e4;
|
|
20
31
|
const ERROR_BODY_LIMIT = 4096;
|
|
21
32
|
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
@@ -38,20 +49,47 @@ const HARD_CREDIT_MARKERS = [
|
|
|
38
49
|
];
|
|
39
50
|
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
40
51
|
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
52
|
+
/**
|
|
53
|
+
* Hosts the international product answers on, once each has been stripped of a
|
|
54
|
+
* leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
|
|
55
|
+
* the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
|
|
56
|
+
* CLI signs the same international account in at `codebuddy.ai`. All are served
|
|
57
|
+
* by one gateway stack, so all are `global` — missing a spelling sends those
|
|
58
|
+
* tokens to the CN gateway, which rejects them at the openresty layer with an
|
|
59
|
+
* HTML 401 instead of a business JSON error.
|
|
60
|
+
*/
|
|
61
|
+
const GLOBAL_HOSTS = [
|
|
62
|
+
"workbuddy.ai",
|
|
63
|
+
"workbuddy.cc",
|
|
64
|
+
"codebuddy.ai"
|
|
65
|
+
];
|
|
66
|
+
/** Region for a login domain; an empty domain means CN (matching upstream tooling). */
|
|
41
67
|
function regionOf(domain) {
|
|
42
68
|
const lowered = domain.trim().toLowerCase();
|
|
43
|
-
if (lowered
|
|
44
|
-
if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
|
|
69
|
+
for (const host of GLOBAL_HOSTS) if (lowered === host || lowered.endsWith(`.${host}`)) return "global";
|
|
45
70
|
return "cn";
|
|
46
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Gateway for a global credential.
|
|
74
|
+
*
|
|
75
|
+
* International accounts are NOT interchangeable across brand domains: a token
|
|
76
|
+
* issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway and vice
|
|
77
|
+
* versa, so the base must follow the credential's own domain rather than one
|
|
78
|
+
* hardcoded host. Anything unrecognised falls back to the desktop app's gateway.
|
|
79
|
+
*/
|
|
80
|
+
function globalBase(credential) {
|
|
81
|
+
const lowered = credential.domain.trim().toLowerCase();
|
|
82
|
+
if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
|
|
83
|
+
return GLOBAL_BASE;
|
|
84
|
+
}
|
|
47
85
|
function chatBase(credential) {
|
|
48
|
-
return regionOf(credential.domain) === "global" ?
|
|
86
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_CHAT_BASE;
|
|
49
87
|
}
|
|
50
88
|
function billingBase(credential) {
|
|
51
|
-
return regionOf(credential.domain) === "global" ?
|
|
89
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
52
90
|
}
|
|
53
91
|
function originReferer(credential) {
|
|
54
|
-
return regionOf(credential.domain) === "global" ?
|
|
92
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential) : CN_BILLING_BASE;
|
|
55
93
|
}
|
|
56
94
|
/** Headers every upstream request shares. */
|
|
57
95
|
function commonHeaders(credential) {
|
|
@@ -100,8 +138,23 @@ function billingHeaders(credential) {
|
|
|
100
138
|
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
101
139
|
return headers;
|
|
102
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Gateway denials that arrive as an HTML page rather than a JSON envelope.
|
|
143
|
+
*
|
|
144
|
+
* openresty / APISIX reject a request before it reaches the product when the
|
|
145
|
+
* credential is one the gateway no longer honours — most often a stale sign-in
|
|
146
|
+
* left in the auth directory. The status alone (401) is not actionable and the
|
|
147
|
+
* HTML body leaks nothing useful, so this turns it into a sentence the user can
|
|
148
|
+
* act on.
|
|
149
|
+
*/
|
|
150
|
+
function isGatewayHtmlRejection(status, text) {
|
|
151
|
+
if (status !== 401 && status !== 403) return false;
|
|
152
|
+
const head = text.slice(0, 512).toLowerCase();
|
|
153
|
+
return head.includes("<html") || head.includes("openresty") || head.includes("apisix");
|
|
154
|
+
}
|
|
103
155
|
async function readEnvelope(response) {
|
|
104
156
|
const text = await response.text();
|
|
157
|
+
if (isGatewayHtmlRejection(response.status, text)) throw new Error("the WorkBuddy gateway rejected this credential (http 401). This usually means the account is using a stale sign-in the upstream no longer accepts: sign in again in the WorkBuddy desktop app, then pick the account on the plugin card. Run `dsh-workbuddy-xdpool doctor` to list every credential found.");
|
|
105
158
|
let parsed;
|
|
106
159
|
try {
|
|
107
160
|
parsed = JSON.parse(text);
|
|
@@ -146,12 +199,57 @@ function parseCreditMultiplier(value) {
|
|
|
146
199
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
147
200
|
}
|
|
148
201
|
/** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
|
|
202
|
+
/**
|
|
203
|
+
* The effort ladder the upstream's plural-form payloads declare across both
|
|
204
|
+
* gateways (the live union of every `supportedEfforts` list seen; `minimal` has
|
|
205
|
+
* never appeared). Both gateways also accept every level of it on
|
|
206
|
+
* singular-form models — medium/xhigh fold into high, low/max answer with their
|
|
207
|
+
* own budgets — so a singular `effort` value is a DEFAULT, never the model's
|
|
208
|
+
* only level.
|
|
209
|
+
*/
|
|
210
|
+
const SINGULAR_EFFORT_LADDER = [
|
|
211
|
+
"low",
|
|
212
|
+
"medium",
|
|
213
|
+
"high",
|
|
214
|
+
"xhigh",
|
|
215
|
+
"max"
|
|
216
|
+
];
|
|
217
|
+
/**
|
|
218
|
+
* True when `reasoning` arrives in the singular spelling: an `effort` string,
|
|
219
|
+
* with none of the plural-form fields alongside it. Seen on CN
|
|
220
|
+
* `deepseek-v4.1-flash` / `kimi-k3-1` / `glm-5.2` and global
|
|
221
|
+
* `deepseek-v4.1-flash` / `kimi-k3` / `gemini-3.5-flash`.
|
|
222
|
+
*/
|
|
223
|
+
function isSingularEffortForm(raw) {
|
|
224
|
+
return typeof raw["effort"] === "string" && !Array.isArray(raw["supportedEfforts"]) && typeof raw["defaultEffort"] !== "string" && typeof raw["canDisableThinking"] !== "boolean";
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fold a singular-form `effort` into the plural shape the rest of the plugin
|
|
228
|
+
* already understands. Probes on both gateways show these models answer with
|
|
229
|
+
* distinct `reasoning_content` across the whole ladder — and do not think at
|
|
230
|
+
* all when no `reasoning_effort` is sent — so the fold widens
|
|
231
|
+
* `supportedEfforts` and carries the declared value into `defaultEffort`. An
|
|
232
|
+
* unrecognized `effort` passes through as the lone level.
|
|
233
|
+
*/
|
|
234
|
+
function singularEffortLadder(raw) {
|
|
235
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
236
|
+
if (effort === void 0) return void 0;
|
|
237
|
+
return SINGULAR_EFFORT_LADDER.includes(effort) ? [...SINGULAR_EFFORT_LADDER] : [effort];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Parse the upstream's `reasoning` object; unknown shapes degrade to
|
|
241
|
+
* `undefined`. Both spellings normalize here: the plural form passes through as
|
|
242
|
+
* declared, and the singular `effort` form folds via
|
|
243
|
+
* {@link singularEffortLadder}.
|
|
244
|
+
*/
|
|
149
245
|
function parseReasoning(value) {
|
|
150
246
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
151
247
|
const raw = value;
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const
|
|
248
|
+
const singularForm = isSingularEffortForm(raw);
|
|
249
|
+
const effort = typeof raw["effort"] === "string" ? raw["effort"] : void 0;
|
|
250
|
+
const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((entry) => typeof entry === "string") : singularEffortLadder(raw);
|
|
251
|
+
const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : effort;
|
|
252
|
+
const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : singularForm ? true : void 0;
|
|
155
253
|
if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
|
|
156
254
|
return {
|
|
157
255
|
...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
|
|
@@ -174,6 +272,7 @@ function parseUpstreamModel(value) {
|
|
|
174
272
|
const creditMultiplier = parseCreditMultiplier(raw["credits"]);
|
|
175
273
|
const reasoning = parseReasoning(raw["reasoning"]);
|
|
176
274
|
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
275
|
+
const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
|
|
177
276
|
return {
|
|
178
277
|
id,
|
|
179
278
|
name,
|
|
@@ -183,7 +282,8 @@ function parseUpstreamModel(value) {
|
|
|
183
282
|
...reasoning === void 0 ? {} : { reasoning },
|
|
184
283
|
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
185
284
|
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
186
|
-
...supportsToolCall === void 0 ? {} : { supportsToolCall }
|
|
285
|
+
...supportsToolCall === void 0 ? {} : { supportsToolCall },
|
|
286
|
+
...supportsImages === void 0 ? {} : { supportsImages }
|
|
187
287
|
};
|
|
188
288
|
}
|
|
189
289
|
var WorkBuddyUpstreamClient = class {
|
|
@@ -287,16 +387,43 @@ var WorkBuddyUpstreamClient = class {
|
|
|
287
387
|
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
288
388
|
return outcome;
|
|
289
389
|
}
|
|
290
|
-
/**
|
|
390
|
+
/**
|
|
391
|
+
* Fetch the model catalog, keeping the `cli` agent's models only.
|
|
392
|
+
*
|
|
393
|
+
* The two gateways are read differently, because they answer differently:
|
|
394
|
+
*
|
|
395
|
+
* - **CN** serves the roster at `/v2/enterprises/personal/models` and expects
|
|
396
|
+
* the CLI client spelling.
|
|
397
|
+
* - **Global** serves it as part of the product config at `/v3/config`, and
|
|
398
|
+
* only to the DESKTOP client channel. Asking the global host with the CLI UA
|
|
399
|
+
* yields a truncated roster, and the CN path answers HTTP 500 there — which
|
|
400
|
+
* is what left the international provider on its static fallback.
|
|
401
|
+
*
|
|
402
|
+
* Both documents share the `{ models, agents }` entry shape, so the parsing
|
|
403
|
+
* below is common to the two branches.
|
|
404
|
+
*/
|
|
291
405
|
async fetchModels(credential, signal) {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
406
|
+
const global = regionOf(credential.domain) === "global";
|
|
407
|
+
const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
|
|
408
|
+
const headers = global ? {
|
|
409
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
410
|
+
"Accept": "application/json",
|
|
411
|
+
...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid },
|
|
412
|
+
...credential.domain === "" ? {} : { "X-Domain": credential.domain },
|
|
413
|
+
"X-Product": "SaaS",
|
|
414
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
415
|
+
"Connection": "close",
|
|
416
|
+
"User-Agent": DESKTOP_UA
|
|
417
|
+
} : {
|
|
418
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
419
|
+
"Accept": "application/json",
|
|
420
|
+
"Origin": originReferer(credential),
|
|
421
|
+
"Referer": `${originReferer(credential)}/`,
|
|
422
|
+
"User-Agent": CLIENT_UA
|
|
423
|
+
};
|
|
424
|
+
if (!global && credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
425
|
+
const response = await this.fetchImpl(url, {
|
|
426
|
+
headers,
|
|
300
427
|
...signal === void 0 ? {} : { signal }
|
|
301
428
|
});
|
|
302
429
|
const envelope = await readEnvelope(response);
|
|
@@ -466,7 +593,6 @@ var WorkBuddyUpstreamClient = class {
|
|
|
466
593
|
/** Live auth file name the WorkBuddy desktop app writes. */
|
|
467
594
|
const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
468
595
|
/** Snapshot files left behind by previous logins share this prefix. */
|
|
469
|
-
const SNAPSHOT_PREFIX = "workbuddy-desktop.";
|
|
470
596
|
/** Env override for the auth file or its directory. */
|
|
471
597
|
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
472
598
|
function nonEmptyEnv(value) {
|
|
@@ -525,11 +651,13 @@ function parseWorkBuddyAuth(text, sourcePath) {
|
|
|
525
651
|
if (accessToken === "") return void 0;
|
|
526
652
|
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
527
653
|
if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
|
|
654
|
+
const lastRefreshAtMs = typeof auth["lastRefreshTime"] === "number" ? expiryToMs(auth["lastRefreshTime"]) : void 0;
|
|
528
655
|
return {
|
|
529
656
|
accessToken,
|
|
530
657
|
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
531
658
|
expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
|
|
532
659
|
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
660
|
+
...lastRefreshAtMs === void 0 ? {} : { lastRefreshAtMs },
|
|
533
661
|
...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
|
|
534
662
|
...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
|
|
535
663
|
...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
|
|
@@ -542,6 +670,42 @@ function parseWorkBuddyAuth(text, sourcePath) {
|
|
|
542
670
|
* Stable account id. `uin` is the billing identity the upstream keys on and
|
|
543
671
|
* survives re-login; `uid` is the fallback.
|
|
544
672
|
*/
|
|
673
|
+
/**
|
|
674
|
+
* True when `path` is the desktop app's live sign-in (as opposed to a backup
|
|
675
|
+
* snapshot it left behind). The live file always wins: it is the session the
|
|
676
|
+
* app itself is using.
|
|
677
|
+
*/
|
|
678
|
+
function isLiveAuthFile(path) {
|
|
679
|
+
return basename(path) === WORKBUDDY_LIVE_FILENAME;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Which of two credentials for the same account the pool should keep.
|
|
683
|
+
*
|
|
684
|
+
* Ordering, highest first:
|
|
685
|
+
*
|
|
686
|
+
* 1. the live file the desktop app is signed in with;
|
|
687
|
+
* 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
|
|
688
|
+
* 3. the longer stored expiry, as a fallback for documents that carry no issue
|
|
689
|
+
* time (the plugin's own refreshed copy, older builds).
|
|
690
|
+
*
|
|
691
|
+
* The stored expiry alone is NOT a freshness signal: the upstream does not
|
|
692
|
+
* rewrite it when it revokes a token, so a long-dead backup can claim to expire
|
|
693
|
+
* later than the token that actually works. Selecting on it made every upstream
|
|
694
|
+
* call return 401 while a perfectly good credential sat in the same directory.
|
|
695
|
+
*/
|
|
696
|
+
function compareFreshness(a, b) {
|
|
697
|
+
const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
|
|
698
|
+
const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
|
|
699
|
+
if (aLive !== bLive) return bLive - aLive;
|
|
700
|
+
const aIssued = a.lastRefreshAtMs ?? 0;
|
|
701
|
+
const bIssued = b.lastRefreshAtMs ?? 0;
|
|
702
|
+
if (aIssued !== bIssued) return bIssued - aIssued;
|
|
703
|
+
return b.expiresAtMs - a.expiresAtMs;
|
|
704
|
+
}
|
|
705
|
+
/** True when `candidate` should replace `incumbent` for the same account. */
|
|
706
|
+
function isFresher(candidate, incumbent) {
|
|
707
|
+
return compareFreshness(candidate, incumbent) < 0;
|
|
708
|
+
}
|
|
545
709
|
function workbuddyAccountId(credential) {
|
|
546
710
|
const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
|
|
547
711
|
return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
|
|
@@ -553,6 +717,17 @@ function accountLabel(credential) {
|
|
|
553
717
|
return discriminator === "" ? name : `${name}#${discriminator}`;
|
|
554
718
|
}
|
|
555
719
|
/** List the auth files in one directory: the live file plus every snapshot. */
|
|
720
|
+
/**
|
|
721
|
+
* Credential files in one auth directory, freshest first.
|
|
722
|
+
*
|
|
723
|
+
* Every `*.info` file counts, not just the timestamped `workbuddy-desktop.*`
|
|
724
|
+
* snapshots: the international client signs in as `workbuddy-desktop-ai.info`
|
|
725
|
+
* (a hyphen, not a dot), so a prefix test silently dropped every global
|
|
726
|
+
* credential and the global provider then saw an empty pool.
|
|
727
|
+
*
|
|
728
|
+
* Filenames are plain strings, and the ordering here is only a first pass —
|
|
729
|
+
* `isFresher` makes the real call once each file has been parsed.
|
|
730
|
+
*/
|
|
556
731
|
async function authFilesIn(dir) {
|
|
557
732
|
let entries;
|
|
558
733
|
try {
|
|
@@ -560,10 +735,7 @@ async function authFilesIn(dir) {
|
|
|
560
735
|
} catch {
|
|
561
736
|
return [];
|
|
562
737
|
}
|
|
563
|
-
const files = entries.filter((name) =>
|
|
564
|
-
if (name === "workbuddy-desktop.info") return true;
|
|
565
|
-
return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
|
|
566
|
-
});
|
|
738
|
+
const files = entries.filter((name) => name.endsWith(".info"));
|
|
567
739
|
files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
568
740
|
return files.map((name) => join(dir, name));
|
|
569
741
|
}
|
|
@@ -593,6 +765,8 @@ var WorkBuddyAccountPool = class {
|
|
|
593
765
|
client;
|
|
594
766
|
refreshMarginMs;
|
|
595
767
|
accounts = [];
|
|
768
|
+
distribution;
|
|
769
|
+
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
596
770
|
cursor = 0;
|
|
597
771
|
lastScanAtMs = 0;
|
|
598
772
|
preferredId;
|
|
@@ -603,6 +777,7 @@ var WorkBuddyAccountPool = class {
|
|
|
603
777
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
604
778
|
this.client = options.client;
|
|
605
779
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
780
|
+
this.distribution = options.distribution ?? "priority";
|
|
606
781
|
}
|
|
607
782
|
/**
|
|
608
783
|
* Re-apply configuration that only affects discovery and cooldown policy,
|
|
@@ -612,6 +787,7 @@ var WorkBuddyAccountPool = class {
|
|
|
612
787
|
applyConfig(options) {
|
|
613
788
|
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
614
789
|
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
790
|
+
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
615
791
|
}
|
|
616
792
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
617
793
|
async scan() {
|
|
@@ -636,13 +812,15 @@ var WorkBuddyAccountPool = class {
|
|
|
636
812
|
});
|
|
637
813
|
continue;
|
|
638
814
|
}
|
|
639
|
-
if ((credential
|
|
815
|
+
if (isFresher(credential, existing.credential)) byId.set(id, {
|
|
640
816
|
...existing,
|
|
641
817
|
credential,
|
|
642
818
|
label: accountLabel(credential)
|
|
643
819
|
});
|
|
644
820
|
}
|
|
645
|
-
|
|
821
|
+
const ordered = [...byId.values()];
|
|
822
|
+
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
823
|
+
this.accounts = ordered;
|
|
646
824
|
this.lastScanAtMs = Date.now();
|
|
647
825
|
return this.accounts;
|
|
648
826
|
}
|
|
@@ -669,12 +847,24 @@ var WorkBuddyAccountPool = class {
|
|
|
669
847
|
});
|
|
670
848
|
}
|
|
671
849
|
/**
|
|
672
|
-
* Pick the
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
850
|
+
* Pick the account to serve a request.
|
|
851
|
+
*
|
|
852
|
+
* Two distributions, chosen by the `distribution` setting:
|
|
853
|
+
*
|
|
854
|
+
* - **priority** (default, and what the card ships with): one account serves
|
|
855
|
+
* every request until it is rate-limited, then the next in order takes over.
|
|
856
|
+
* Credits drain one account at a time, and a cooling account returns to the
|
|
857
|
+
* head of the queue the moment its window resets — it was never consumed, so
|
|
858
|
+
* it resumes straight away.
|
|
859
|
+
* - **round-robin**: consecutive requests rotate through the pool so spend
|
|
860
|
+
* spreads evenly across every account.
|
|
861
|
+
*
|
|
862
|
+
* In both modes an explicit user selection (`prefer`) heads the list, a
|
|
863
|
+
* cooling account is skipped for that model only, and an unrecognised setting
|
|
864
|
+
* falls back to priority.
|
|
865
|
+
*
|
|
866
|
+
* Scans on first use, and rescans when every known account is cooling down: a
|
|
867
|
+
* fresh desktop login is the usual way out of an exhausted pool.
|
|
678
868
|
*/
|
|
679
869
|
async acquire(modelId, region) {
|
|
680
870
|
if (this.accounts.length === 0) await this.scan();
|
|
@@ -684,21 +874,25 @@ var WorkBuddyAccountPool = class {
|
|
|
684
874
|
pool = this.available(Date.now(), modelId, region);
|
|
685
875
|
}
|
|
686
876
|
if (pool.length === 0) return void 0;
|
|
687
|
-
let start = this.cursor % pool.length;
|
|
688
877
|
if (this.preferredId !== void 0) {
|
|
689
878
|
const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
|
|
690
|
-
if (preferredIndex
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (account === void 0) continue;
|
|
695
|
-
await this.ensureFresh(account);
|
|
696
|
-
this.cursor = (start + step + 1) % pool.length;
|
|
697
|
-
return account;
|
|
879
|
+
if (preferredIndex > 0) {
|
|
880
|
+
const [preferred] = pool.splice(preferredIndex, 1);
|
|
881
|
+
if (preferred !== void 0) pool = [preferred, ...pool];
|
|
882
|
+
}
|
|
698
883
|
}
|
|
699
|
-
|
|
884
|
+
const index = this.distribution === "round-robin" ? this.cursor % pool.length : 0;
|
|
885
|
+
const account = pool[index];
|
|
886
|
+
if (account === void 0) return void 0;
|
|
887
|
+
if (this.distribution === "round-robin") this.cursor = (index + 1) % pool.length;
|
|
888
|
+
await this.ensureFresh(account);
|
|
889
|
+
return account;
|
|
700
890
|
}
|
|
701
891
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
892
|
+
/** How the pool currently spreads requests. Shown on the card. */
|
|
893
|
+
currentDistribution() {
|
|
894
|
+
return this.distribution;
|
|
895
|
+
}
|
|
702
896
|
prefer(accountId) {
|
|
703
897
|
this.preferredId = accountId;
|
|
704
898
|
}
|
|
@@ -891,7 +1085,7 @@ var WorkBuddyCatalog = class {
|
|
|
891
1085
|
const budgets = this.selection.contextBudgets;
|
|
892
1086
|
return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
|
|
893
1087
|
const next = { ...model };
|
|
894
|
-
if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
|
|
1088
|
+
if (imageSet !== void 0) next.supportsImages = next.supportsImages || imageSet.has(model.id);
|
|
895
1089
|
const budget = budgets?.[model.id];
|
|
896
1090
|
if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
|
|
897
1091
|
return next;
|
|
@@ -939,7 +1133,7 @@ function toModelInfo(model) {
|
|
|
939
1133
|
name: model.name,
|
|
940
1134
|
contextWindow: model.contextWindow,
|
|
941
1135
|
maxOutputTokens: model.maxTokens,
|
|
942
|
-
supportsImages: model.
|
|
1136
|
+
supportsImages: model.supportsImages ?? false,
|
|
943
1137
|
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
944
1138
|
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
945
1139
|
};
|
|
@@ -963,11 +1157,20 @@ function formatRates(status) {
|
|
|
963
1157
|
z.object({
|
|
964
1158
|
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
965
1159
|
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
1160
|
+
distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
|
|
966
1161
|
enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
|
|
967
1162
|
imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
|
|
968
1163
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
|
|
969
1164
|
});
|
|
970
1165
|
/** Assemble the runtime objects without registering anything. */
|
|
1166
|
+
/**
|
|
1167
|
+
* Assemble the runtime objects without registering anything.
|
|
1168
|
+
*
|
|
1169
|
+
* One catalog per region, mirroring the two shims: the CN and global gateways
|
|
1170
|
+
* do not advertise the same roster, and a shared catalog meant the picker showed
|
|
1171
|
+
* whichever list happened to be fetched first (always the CN one, since the
|
|
1172
|
+
* seeding step read `accounts[0]`).
|
|
1173
|
+
*/
|
|
971
1174
|
function createCore(logger) {
|
|
972
1175
|
const client = new WorkBuddyUpstreamClient();
|
|
973
1176
|
return {
|
|
@@ -975,7 +1178,10 @@ function createCore(logger) {
|
|
|
975
1178
|
...logger === void 0 ? {} : { logger },
|
|
976
1179
|
client
|
|
977
1180
|
}),
|
|
978
|
-
|
|
1181
|
+
catalogs: {
|
|
1182
|
+
cn: new WorkBuddyCatalog(),
|
|
1183
|
+
global: new WorkBuddyCatalog()
|
|
1184
|
+
},
|
|
979
1185
|
client
|
|
980
1186
|
};
|
|
981
1187
|
}
|
|
@@ -1064,7 +1270,7 @@ async function commandStatus(args) {
|
|
|
1064
1270
|
};
|
|
1065
1271
|
}),
|
|
1066
1272
|
cooling: accounts.filter((account) => account.cooldownUntilMs > Date.now()).length,
|
|
1067
|
-
models: core.
|
|
1273
|
+
models: core.catalogs.cn.current().map((model) => ({
|
|
1068
1274
|
id: model.id,
|
|
1069
1275
|
name: model.name,
|
|
1070
1276
|
multiplier: model.multiplier
|