dsh-connect-workbuddy 1.4.0 → 2.0.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 +52 -0
- package/README.en.md +14 -10
- package/README.md +14 -10
- package/lib/bin.d.ts +3 -1
- package/lib/bin.js +103 -69
- package/lib/client.js +185 -64
- package/lib/{host-heartbeat-DQQwiUtm.js → host-heartbeat-DjNRPvQf.js} +1245 -1141
- package/lib/index.d.ts +145 -29
- package/lib/index.js +197 -97
- package/package.json +1 -1
|
@@ -5,1228 +5,1332 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
6
6
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
-
//#region src/
|
|
8
|
+
//#region src/upstream.ts
|
|
9
|
+
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
10
|
+
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
11
|
+
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
9
12
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 到期更晚者、按需刷新(5 分钟余量)与单飞去重、刷新失败但 token
|
|
15
|
-
* 未过期则继续沿用旧 token。这些机制已在该项目验证,此处沿用。
|
|
16
|
-
* 改动:原版只解析单个 `workbuddy-desktop.info`。WorkBuddy 桌面端在
|
|
17
|
-
* 同一 auth 目录留下带时间戳的备份文件(`workbuddy-desktop.<stamp>.info`),
|
|
18
|
-
* 实测这些文件各自持有不同账号的可用凭据(本机 6 个文件 → 2 个账号)。
|
|
19
|
-
* 本实现改为扫描整个 auth 目录,按 uin 去重为多个可选账号。跟随 App
|
|
20
|
-
* 当前登录(live 文件)仍是默认行为;用户显式选择的账号被严格绑定,
|
|
21
|
-
* 不因积分多少而切换,失效时也不会静默改选其他账号。
|
|
22
|
-
*
|
|
23
|
-
* @module dsh-connect-workbuddy/auth
|
|
13
|
+
* Model-catalog path used by the CN region (and the global fallback). The CN
|
|
14
|
+
* gateway answers it with the same bytes as its legacy
|
|
15
|
+
* `/console/enterprises/personal/models` alias. See {@link GLOBAL_CONFIG_PATH}
|
|
16
|
+
* for why the international region reads a different document.
|
|
24
17
|
*/
|
|
25
|
-
|
|
26
|
-
const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
|
|
27
|
-
/** Env variable that overrides the desktop auth-file location. */
|
|
28
|
-
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
29
|
-
/** Basename of the live WorkBuddy desktop auth file. */
|
|
30
|
-
const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
31
|
-
/** Current on-disk format of the plugin-owned copy; readers reject others. */
|
|
32
|
-
const OWN_FORMAT_VERSION = 1;
|
|
33
|
-
/** Plugin-owned copy path inside the Harness home. */
|
|
34
|
-
function workbuddyOwnAuthPath() {
|
|
35
|
-
return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
|
|
36
|
-
}
|
|
18
|
+
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
37
19
|
/**
|
|
38
|
-
*
|
|
20
|
+
* Remote product-config path on the global gateway. This is the document the
|
|
21
|
+
* desktop channel receives; it is the only source that lists the account's
|
|
22
|
+
* full international chat roster (see {@link DESKTOP_UA}).
|
|
23
|
+
*/
|
|
24
|
+
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
25
|
+
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
26
|
+
/**
|
|
27
|
+
* User agent of the WorkBuddy desktop app.
|
|
39
28
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
29
|
+
* The config service serves a DIFFERENT product configuration per client
|
|
30
|
+
* channel, selected by this product token — the version suffix is ignored
|
|
31
|
+
* (`WorkBuddy/5.5.2`, `WorkBuddy/1.0.0` and a bare `WorkBuddy` answer
|
|
32
|
+
* identically). On the INTERNATIONAL gateway the split decides which models
|
|
33
|
+
* exist at all:
|
|
44
34
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
35
|
+
* - CLI channel (`CLI/… CodeBuddy/…`) → 35 models that OMIT
|
|
36
|
+
* `deepseek-v4.1-flash` and `gpt-6-astra`, even though both are perfectly
|
|
37
|
+
* chat-usable (verified: `deepseek-v4.1-flash` streams HTTP 200 and is
|
|
38
|
+
* billed `x0.00`);
|
|
39
|
+
* - desktop channel → the account's real 20-model chat roster including both.
|
|
40
|
+
*
|
|
41
|
+
* The plugin emulates the CLI channel for CHAT but reads the desktop channel's
|
|
42
|
+
* configuration to learn the account's actual model list. The CN gateway needs
|
|
43
|
+
* no such switch: its desktop config carries no `cli` agent roster at all, so
|
|
44
|
+
* CN keeps reading the shared `/v2/enterprises/personal/models` path.
|
|
47
45
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
46
|
+
const DESKTOP_UA = "WorkBuddy/5.5.2";
|
|
47
|
+
const JSON_TIMEOUT_MS = 3e4;
|
|
48
|
+
const ERROR_BODY_LIMIT = 4096;
|
|
49
|
+
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
50
|
+
const HARD_CREDIT_MARKERS = [
|
|
51
|
+
"insufficient credit",
|
|
52
|
+
"no credit",
|
|
53
|
+
"credit exhausted",
|
|
54
|
+
"out of credit",
|
|
55
|
+
"quota exceeded",
|
|
56
|
+
"quota exhaust",
|
|
57
|
+
"payment required",
|
|
58
|
+
"credit not enough",
|
|
59
|
+
"not enough credit",
|
|
60
|
+
"积分不足",
|
|
61
|
+
"额度不足",
|
|
62
|
+
"余额不足",
|
|
63
|
+
"积分用完",
|
|
64
|
+
"额度用尽",
|
|
65
|
+
"没有积分"
|
|
66
|
+
];
|
|
67
|
+
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
68
|
+
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
69
|
+
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
70
|
+
function classifyUpstreamError(status, body) {
|
|
71
|
+
if (status === 402) return "hard_credit";
|
|
72
|
+
const lower = body.toLowerCase();
|
|
73
|
+
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
74
|
+
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
75
|
+
if (status === 429) return "soft_rate";
|
|
76
|
+
if (status === 404) return "not_found";
|
|
77
|
+
if (status >= 500) return "server";
|
|
78
|
+
if (status >= 400) return "client";
|
|
79
|
+
return "client";
|
|
80
80
|
}
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* Region for a login domain; an empty domain means CN (matching upstream tooling).
|
|
83
|
+
*
|
|
84
|
+
* The international product is reachable under TWO brand domains: the WorkBuddy
|
|
85
|
+
* AI desktop app signs in at `workbuddy.ai`, while the CodeBuddy CLI signs the
|
|
86
|
+
* same international account in at `codebuddy.ai` (verified against a real
|
|
87
|
+
* credential file, issue #4). Both are served by the same gateway stack — a
|
|
88
|
+
* read-only probe shows `/v3/config` answering HTTP 200 with the same JSON
|
|
89
|
+
* envelope on both hosts — so both classify as `global`. Missing the
|
|
90
|
+
* `codebuddy.ai` spelling sent those tokens to the CN gateway, which rejected
|
|
91
|
+
* them at the openresty layer with an HTML 401.
|
|
85
92
|
*/
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
94
|
-
const document = parsed;
|
|
95
|
-
let auth;
|
|
96
|
-
let identity;
|
|
97
|
-
if (typeof document["auth"] === "object" && document["auth"] !== null) {
|
|
98
|
-
auth = document["auth"];
|
|
99
|
-
identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
|
|
100
|
-
} else {
|
|
101
|
-
auth = document;
|
|
102
|
-
identity = document;
|
|
103
|
-
}
|
|
104
|
-
const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
|
|
105
|
-
if (accessToken === "") return void 0;
|
|
106
|
-
const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
|
|
107
|
-
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
108
|
-
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
109
|
-
const nickname = optionalString(identity["nickname"]);
|
|
110
|
-
const uin = optionalString(identity["uin"]);
|
|
111
|
-
return {
|
|
112
|
-
accessToken,
|
|
113
|
-
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
114
|
-
expiresAtMs,
|
|
115
|
-
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
116
|
-
domain: optionalString(auth["domain"]) ?? "",
|
|
117
|
-
uid: optionalString(identity["uid"]) ?? "",
|
|
118
|
-
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
119
|
-
...nickname === void 0 ? {} : { nickname },
|
|
120
|
-
...uin === void 0 ? {} : { uin },
|
|
121
|
-
source: "desktop",
|
|
122
|
-
filePath
|
|
123
|
-
};
|
|
93
|
+
function regionOf(domain) {
|
|
94
|
+
const lowered = domain.trim().toLowerCase();
|
|
95
|
+
if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
|
|
96
|
+
if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "global";
|
|
97
|
+
return "cn";
|
|
124
98
|
}
|
|
125
99
|
/**
|
|
126
|
-
*
|
|
100
|
+
* Gateway for a global credential.
|
|
127
101
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* backups, never the primary ordering.
|
|
102
|
+
* International accounts are NOT interchangeable across brand domains: a token
|
|
103
|
+
* issued at `codebuddy.ai` is rejected by the `workbuddy.ai` gateway (and vice
|
|
104
|
+
* versa), so the base must follow the credential's OWN domain rather than a
|
|
105
|
+
* single hardcoded host. Anything unrecognised falls back to `workbuddy.ai`,
|
|
106
|
+
* the desktop app's gateway.
|
|
134
107
|
*/
|
|
135
|
-
function
|
|
136
|
-
|
|
108
|
+
function globalBase(domain) {
|
|
109
|
+
const lowered = domain.trim().toLowerCase();
|
|
110
|
+
if (lowered === "codebuddy.ai" || lowered.endsWith(".codebuddy.ai")) return "https://www.codebuddy.ai";
|
|
111
|
+
return GLOBAL_BASE;
|
|
137
112
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
* and this helper must keep working when a Windows path is compared on a
|
|
141
|
-
* POSIX host (e.g. tests injecting a Windows-style auth dir).
|
|
142
|
-
*/
|
|
143
|
-
function authFileName(path) {
|
|
144
|
-
const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
145
|
-
return separator === -1 ? path : path.slice(separator + 1);
|
|
113
|
+
function chatBase(credential) {
|
|
114
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_CHAT_BASE;
|
|
146
115
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* survives across re-login; `uid` is the fallback for documents without one.
|
|
150
|
-
*/
|
|
151
|
-
function workbuddyAccountId(credential) {
|
|
152
|
-
const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
|
|
153
|
-
return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 24);
|
|
116
|
+
function billingBase(credential) {
|
|
117
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_BILLING_BASE;
|
|
154
118
|
}
|
|
155
|
-
|
|
156
|
-
|
|
119
|
+
function originReferer(credential) {
|
|
120
|
+
return regionOf(credential.domain) === "global" ? globalBase(credential.domain) : CN_BILLING_BASE;
|
|
121
|
+
}
|
|
122
|
+
/** Headers every upstream request shares. */
|
|
123
|
+
function commonHeaders(credential) {
|
|
157
124
|
return {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
credential
|
|
125
|
+
"Accept": "application/json, text/plain, */*",
|
|
126
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
127
|
+
"Origin": originReferer(credential),
|
|
128
|
+
"Referer": `${originReferer(credential)}/`,
|
|
129
|
+
"User-Agent": CLIENT_UA
|
|
161
130
|
};
|
|
162
131
|
}
|
|
163
|
-
/**
|
|
164
|
-
function
|
|
165
|
-
let parsed;
|
|
166
|
-
try {
|
|
167
|
-
parsed = JSON.parse(text);
|
|
168
|
-
} catch {
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
172
|
-
const document = parsed;
|
|
173
|
-
if (document["version"] !== OWN_FORMAT_VERSION) return void 0;
|
|
174
|
-
if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
|
|
175
|
-
const credential = parseWorkBuddyAuth(JSON.stringify({ auth: document["credential"] }), workbuddyOwnAuthPath());
|
|
176
|
-
if (credential === void 0) return void 0;
|
|
132
|
+
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
133
|
+
function chatHeaders(credential) {
|
|
177
134
|
return {
|
|
178
|
-
...credential,
|
|
179
|
-
|
|
135
|
+
...commonHeaders(credential),
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
138
|
+
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
139
|
+
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
140
|
+
"X-Product": "SaaS"
|
|
180
141
|
};
|
|
181
142
|
}
|
|
182
|
-
/**
|
|
183
|
-
function
|
|
184
|
-
|
|
143
|
+
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
144
|
+
function refreshHeaders(credential) {
|
|
145
|
+
const headers = {
|
|
146
|
+
...commonHeaders(credential),
|
|
147
|
+
"X-Refresh-Token": credential.refreshToken,
|
|
148
|
+
"X-Auth-Refresh-Source": "workbuddy"
|
|
149
|
+
};
|
|
150
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
151
|
+
return headers;
|
|
185
152
|
}
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
153
|
+
/** Billing request headers. */
|
|
154
|
+
function billingHeaders(credential) {
|
|
155
|
+
const headers = {
|
|
156
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
157
|
+
"Accept": "application/json",
|
|
158
|
+
"Content-Type": "application/json"
|
|
159
|
+
};
|
|
160
|
+
if (credential.uid !== "") headers["X-User-Id"] = credential.uid;
|
|
161
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
|
|
162
|
+
headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
163
|
+
headers["X-Tenant-Id"] = credential.enterpriseId;
|
|
193
164
|
}
|
|
165
|
+
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
166
|
+
return headers;
|
|
194
167
|
}
|
|
195
168
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* Refresh policy: refresh only when the access token is inside the margin
|
|
200
|
-
* (or already expired), keep the refreshed credential in the plugin-owned
|
|
201
|
-
* copy, and never write the desktop app's files. A failed refresh still
|
|
202
|
-
* returns a not-yet-expired token so an unreachable refresh endpoint does
|
|
203
|
-
* not take down a working session.
|
|
169
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
170
|
+
* force `stream: true` (the upstream rejects non-streaming) and flatten
|
|
171
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400).
|
|
204
172
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
accountId;
|
|
212
|
-
inflight;
|
|
213
|
-
constructor(options) {
|
|
214
|
-
this.refresh = options.refresh;
|
|
215
|
-
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
216
|
-
this.ownPath = options.ownPath ?? workbuddyOwnAuthPath();
|
|
217
|
-
this.authDirs = options.authDirs;
|
|
218
|
-
this.desktopPathOverride = options.desktopPath;
|
|
219
|
-
}
|
|
220
|
-
/** Repoint the desktop file or directory; applies on the next read. */
|
|
221
|
-
setDesktopPath(path) {
|
|
222
|
-
this.desktopPathOverride = path;
|
|
223
|
-
this.inflight = void 0;
|
|
173
|
+
function prepareChatBody(source) {
|
|
174
|
+
let body;
|
|
175
|
+
try {
|
|
176
|
+
body = JSON.parse(source);
|
|
177
|
+
} catch {
|
|
178
|
+
return source;
|
|
224
179
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
180
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return source;
|
|
181
|
+
const obj = body;
|
|
182
|
+
obj["stream"] = true;
|
|
183
|
+
if (Array.isArray(obj["messages"])) for (const value of obj["messages"]) {
|
|
184
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
185
|
+
const message = value;
|
|
186
|
+
if (message["role"] === "developer") message["role"] = "system";
|
|
229
187
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
188
|
+
normalizeToolChoice(obj);
|
|
189
|
+
return JSON.stringify(obj);
|
|
190
|
+
}
|
|
191
|
+
/** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
|
|
192
|
+
function normalizeToolChoice(obj) {
|
|
193
|
+
const suppress = () => {
|
|
194
|
+
delete obj["tools"];
|
|
195
|
+
delete obj["functions"];
|
|
196
|
+
};
|
|
197
|
+
if (!("tool_choice" in obj)) return;
|
|
198
|
+
const choice = obj["tool_choice"];
|
|
199
|
+
if (typeof choice === "string") {
|
|
200
|
+
if (choice.trim().toLowerCase() === "none") {
|
|
201
|
+
delete obj["tool_choice"];
|
|
202
|
+
suppress();
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
233
205
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
206
|
+
if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
|
|
207
|
+
const wrapped = choice;
|
|
208
|
+
const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
|
|
209
|
+
if (type === "none") {
|
|
210
|
+
delete obj["tool_choice"];
|
|
211
|
+
suppress();
|
|
212
|
+
} else if (type === "auto" || type === "required") obj["tool_choice"] = type;
|
|
213
|
+
else if (type === "function") {
|
|
214
|
+
const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
|
|
215
|
+
let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
|
|
216
|
+
if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
|
|
217
|
+
name = name.trim();
|
|
218
|
+
obj["tool_choice"] = name !== "" ? name : "auto";
|
|
219
|
+
} else delete obj["tool_choice"];
|
|
220
|
+
return;
|
|
240
221
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
222
|
+
delete obj["tool_choice"];
|
|
223
|
+
}
|
|
224
|
+
async function readEnvelope(response) {
|
|
225
|
+
const text = await response.text();
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(text);
|
|
229
|
+
} catch {
|
|
230
|
+
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
244
231
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
232
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
|
|
233
|
+
const document = parsed;
|
|
234
|
+
return {
|
|
235
|
+
code: typeof document["code"] === "number" ? document["code"] : 0,
|
|
236
|
+
msg: typeof document["msg"] === "string" ? document["msg"] : "",
|
|
237
|
+
data: "data" in document ? document["data"] : void 0
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
|
|
241
|
+
function envelopeError(status, envelope) {
|
|
242
|
+
const kind = classifyUpstreamError(status, envelope.msg);
|
|
243
|
+
return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Parse the upstream's `credits` string into a multiplier.
|
|
247
|
+
*
|
|
248
|
+
* Observed forms: `"x0.79 credits"`, `"x0.05"`, `"x0.00 credits"`,
|
|
249
|
+
* and absent. Unparsable values yield undefined rather than a guess — the
|
|
250
|
+
* card simply omits the rate instead of displaying a fabricated one.
|
|
251
|
+
*/
|
|
252
|
+
function parseCreditMultiplier(value) {
|
|
253
|
+
if (typeof value !== "string") return void 0;
|
|
254
|
+
const match = /x\s*([0-9]*\.?[0-9]+)/iu.exec(value);
|
|
255
|
+
if (match === null) return void 0;
|
|
256
|
+
const parsed = Number(match[1]);
|
|
257
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
258
|
+
}
|
|
259
|
+
/** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
|
|
260
|
+
function parseReasoning(value) {
|
|
261
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
262
|
+
const raw = value;
|
|
263
|
+
const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
|
|
264
|
+
const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
|
|
265
|
+
const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
|
|
266
|
+
if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
|
|
267
|
+
return {
|
|
268
|
+
...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
|
|
269
|
+
...defaultEffort === void 0 ? {} : { defaultEffort },
|
|
270
|
+
...canDisableThinking === void 0 ? {} : { canDisableThinking }
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Parse one catalog entry; entries without usable token limits are dropped. */
|
|
274
|
+
function parseUpstreamModel(value) {
|
|
275
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
276
|
+
const raw = value;
|
|
277
|
+
const id = typeof raw["id"] === "string" ? raw["id"] : "";
|
|
278
|
+
if (id === "" || raw["disabled"] === true) return void 0;
|
|
279
|
+
const input = typeof raw["maxInputTokens"] === "number" ? raw["maxInputTokens"] : 0;
|
|
280
|
+
const output = typeof raw["maxOutputTokens"] === "number" ? raw["maxOutputTokens"] : 0;
|
|
281
|
+
if (input <= 0 || output <= 0) return void 0;
|
|
282
|
+
const name = typeof raw["name"] === "string" && raw["name"] !== "" ? raw["name"] : id;
|
|
283
|
+
const descriptionZh = typeof raw["descriptionZh"] === "string" && raw["descriptionZh"] !== "" ? raw["descriptionZh"] : void 0;
|
|
284
|
+
const descriptionEn = typeof raw["descriptionEn"] === "string" && raw["descriptionEn"] !== "" ? raw["descriptionEn"] : void 0;
|
|
285
|
+
const creditMultiplier = parseCreditMultiplier(raw["credits"]);
|
|
286
|
+
const reasoning = parseReasoning(raw["reasoning"]);
|
|
287
|
+
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
288
|
+
return {
|
|
289
|
+
id,
|
|
290
|
+
name,
|
|
291
|
+
contextWindow: input,
|
|
292
|
+
maxTokens: output,
|
|
293
|
+
...creditMultiplier === void 0 ? {} : { creditMultiplier },
|
|
294
|
+
...reasoning === void 0 ? {} : { reasoning },
|
|
295
|
+
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
296
|
+
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
297
|
+
...supportsToolCall === void 0 ? {} : { supportsToolCall }
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Select the chat-capable models from a catalog-shaped document: parse every
|
|
302
|
+
* entry, then keep the `cli` agent's roster in its declared order.
|
|
303
|
+
*
|
|
304
|
+
* Both the CN personal-models document and the global `/v3/config` document
|
|
305
|
+
* carry `models` plus an `agents` roster with the same entry shape, so one
|
|
306
|
+
* selector serves them. Without a usable `cli` roster the whole parsed catalog
|
|
307
|
+
* is exposed rather than nothing: the roster is an upstream detail that may
|
|
308
|
+
* change, and an empty answer would silently disarm the provider.
|
|
309
|
+
*/
|
|
310
|
+
function selectCliModels(rawModels, agents) {
|
|
311
|
+
const byId = /* @__PURE__ */ new Map();
|
|
312
|
+
for (const model of Array.isArray(rawModels) ? rawModels : []) {
|
|
313
|
+
const parsed = parseUpstreamModel(model);
|
|
314
|
+
if (parsed !== void 0) byId.set(parsed.id, parsed);
|
|
248
315
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
* expects account switching to work the same way it does on the default
|
|
256
|
-
* path. Only the file ordering changes.
|
|
257
|
-
*
|
|
258
|
-
* A corrupt or signed-out file must never hide the others, so each read is
|
|
259
|
-
* independent and failures are skipped rather than propagated.
|
|
260
|
-
*/
|
|
261
|
-
async candidateFiles() {
|
|
262
|
-
const explicitPath = this.desktopPathOverride ?? ((process.env["WORKBUDDY_AUTH_FILE"] ?? "").trim() !== "" ? process.env["WORKBUDDY_AUTH_FILE"] : void 0);
|
|
263
|
-
const files = [];
|
|
264
|
-
if (explicitPath !== void 0) {
|
|
265
|
-
files.push(explicitPath);
|
|
266
|
-
for (const backup of await this.backupsBeside(explicitPath)) files.push(backup);
|
|
267
|
-
return files;
|
|
268
|
-
}
|
|
269
|
-
const dirs = this.authDirs ?? defaultDesktopAuthDirs();
|
|
270
|
-
for (const dir of dirs) {
|
|
271
|
-
const live = join(dir, WORKBUDDY_LIVE_FILENAME);
|
|
272
|
-
files.push(live);
|
|
273
|
-
for (const backup of await this.backupsBeside(live)) files.push(backup);
|
|
316
|
+
let cliIds;
|
|
317
|
+
for (const agent of Array.isArray(agents) ? agents : []) if (typeof agent === "object" && agent !== null) {
|
|
318
|
+
const wrapped = agent;
|
|
319
|
+
if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
|
|
320
|
+
cliIds = wrapped["models"].filter((id) => typeof id === "string");
|
|
321
|
+
break;
|
|
274
322
|
}
|
|
275
|
-
return files;
|
|
276
323
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
324
|
+
const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
325
|
+
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
326
|
+
return models;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Upstream HTTP client. One instance serves the whole plugin; requests take
|
|
330
|
+
* the credential explicitly so token refreshes apply on the next call.
|
|
331
|
+
*/
|
|
332
|
+
var WorkBuddyUpstreamClient = class {
|
|
333
|
+
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
334
|
+
async chatStream(credential, bodyJson, signal) {
|
|
335
|
+
let response;
|
|
281
336
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const files = await this.candidateFiles();
|
|
293
|
-
const byId = /* @__PURE__ */ new Map();
|
|
294
|
-
for (const file of files) {
|
|
295
|
-
const credential = await readAuthFile(file);
|
|
296
|
-
if (credential === void 0) continue;
|
|
297
|
-
const id = workbuddyAccountId(credential);
|
|
298
|
-
const existing = byId.get(id);
|
|
299
|
-
if (existing === void 0) {
|
|
300
|
-
byId.set(id, credential);
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
if (fileRank(credential.filePath) < fileRank(existing.filePath) || fileRank(credential.filePath) === fileRank(existing.filePath) && credential.expiresAtMs > existing.expiresAtMs) byId.set(id, credential);
|
|
304
|
-
}
|
|
305
|
-
const own = await this.readOwn();
|
|
306
|
-
if (own !== void 0) {
|
|
307
|
-
const id = workbuddyAccountId(own);
|
|
308
|
-
const existing = byId.get(id);
|
|
309
|
-
if (existing === void 0 || own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
|
|
310
|
-
}
|
|
311
|
-
return [...byId.values()];
|
|
312
|
-
}
|
|
313
|
-
/**
|
|
314
|
-
* Default when no account is explicitly selected: the live sign-in, else the
|
|
315
|
-
* freshest credential. Following the app's current sign-in is the documented
|
|
316
|
-
* default behaviour; the backups exist so the user can switch explicitly.
|
|
317
|
-
* This is NOT credit-seeking — it never reorders accounts to find one with
|
|
318
|
-
* remaining credit.
|
|
319
|
-
*/
|
|
320
|
-
preferred(credentials) {
|
|
321
|
-
if (credentials.length === 0) return void 0;
|
|
322
|
-
return credentials.reduce((best, credential) => {
|
|
323
|
-
if (fileRank(credential.filePath) < fileRank(best.filePath)) return credential;
|
|
324
|
-
if (fileRank(credential.filePath) === fileRank(best.filePath) && credential.expiresAtMs > best.expiresAtMs) return credential;
|
|
325
|
-
return best;
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
/** Token-free account list for the plugin card. */
|
|
329
|
-
async accounts() {
|
|
330
|
-
const credentials = await this.readAll();
|
|
331
|
-
if (credentials.length === 0) return [];
|
|
332
|
-
const selectedExists = this.accountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
333
|
-
const defaultSelected = this.preferred(credentials);
|
|
334
|
-
return credentials.map((credential) => {
|
|
335
|
-
const id = workbuddyAccountId(credential);
|
|
336
|
-
return {
|
|
337
|
-
id,
|
|
338
|
-
accountName: credential.nickname ?? credential.uin ?? credential.uid,
|
|
339
|
-
...credential.uin === void 0 ? {} : { uin: credential.uin },
|
|
340
|
-
domain: credential.domain,
|
|
341
|
-
source: credential.source,
|
|
342
|
-
tokenExpiresAtMs: credential.expiresAtMs,
|
|
343
|
-
filePath: credential.filePath,
|
|
344
|
-
selected: selectedExists ? id === this.accountId : credential === defaultSelected
|
|
345
|
-
};
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
/** The freshest stored credential for the current selection, no refresh. */
|
|
349
|
-
async current() {
|
|
350
|
-
const credentials = await this.readAll();
|
|
351
|
-
if (this.accountId === void 0) return this.preferred(credentials);
|
|
352
|
-
return credentials.find((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
353
|
-
}
|
|
354
|
-
/** The credential to send upstream: {@link current}, refreshed on demand. */
|
|
355
|
-
async resolve() {
|
|
356
|
-
const credential = await this.current();
|
|
357
|
-
if (credential === void 0) {
|
|
358
|
-
const candidates = this.resolveDesktopCandidates();
|
|
359
|
-
const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
|
|
360
|
-
throw new Error(`workbuddy: no signed-in WorkBuddy account found; sign in once in the WorkBuddy desktop app (expected ${desktop} or ${WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
|
|
361
|
-
}
|
|
362
|
-
if (!this.needsRefresh(credential)) return credential;
|
|
363
|
-
this.inflight ??= this.refreshNow(credential).finally(() => {
|
|
364
|
-
this.inflight = void 0;
|
|
365
|
-
});
|
|
366
|
-
return this.inflight;
|
|
367
|
-
}
|
|
368
|
-
/** Read-only sign-in summary; never refreshes and never throws. */
|
|
369
|
-
async status() {
|
|
370
|
-
try {
|
|
371
|
-
const credential = await this.current();
|
|
372
|
-
if (credential === void 0) return { state: "signed-out" };
|
|
337
|
+
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: {
|
|
340
|
+
...chatHeaders(credential),
|
|
341
|
+
"Authorization": `Bearer ${credential.accessToken}`
|
|
342
|
+
},
|
|
343
|
+
body: bodyJson,
|
|
344
|
+
...signal === void 0 ? {} : { signal }
|
|
345
|
+
});
|
|
346
|
+
} catch (error) {
|
|
373
347
|
return {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
...credential.domain === "" ? {} : { domain: credential.domain },
|
|
379
|
-
source: credential.source
|
|
348
|
+
ok: false,
|
|
349
|
+
status: 0,
|
|
350
|
+
kind: "server",
|
|
351
|
+
message: `transport error: ${String(error)}`
|
|
380
352
|
};
|
|
381
|
-
} catch {
|
|
382
|
-
return { state: "signed-out" };
|
|
383
353
|
}
|
|
354
|
+
if (response.ok) return {
|
|
355
|
+
ok: true,
|
|
356
|
+
response
|
|
357
|
+
};
|
|
358
|
+
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
status: response.status,
|
|
362
|
+
kind: classifyUpstreamError(response.status, text),
|
|
363
|
+
message: text
|
|
364
|
+
};
|
|
384
365
|
}
|
|
385
|
-
/**
|
|
386
|
-
async
|
|
387
|
-
await
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
...credential,
|
|
403
|
-
accessToken: outcome.accessToken,
|
|
404
|
-
...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
|
|
405
|
-
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
406
|
-
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
|
|
407
|
-
source: "dsh"
|
|
408
|
-
};
|
|
409
|
-
await this.saveOwn(refreshed);
|
|
410
|
-
return refreshed;
|
|
411
|
-
} catch (error) {
|
|
412
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
413
|
-
throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
|
|
414
|
-
}
|
|
366
|
+
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
367
|
+
async refreshToken(credential) {
|
|
368
|
+
const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
369
|
+
method: "POST",
|
|
370
|
+
headers: refreshHeaders(credential),
|
|
371
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
372
|
+
});
|
|
373
|
+
const envelope = await readEnvelope(response);
|
|
374
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
375
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
376
|
+
const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
|
|
377
|
+
if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
|
|
378
|
+
const outcome = { accessToken };
|
|
379
|
+
if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
|
|
380
|
+
if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
|
|
381
|
+
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
382
|
+
return outcome;
|
|
415
383
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Read the model directory for the credential's region.
|
|
386
|
+
*
|
|
387
|
+
* The two regions expose their chat roster through different documents:
|
|
388
|
+
* CN answers `/v2/enterprises/personal/models`, while the global gateway's
|
|
389
|
+
* personal-models path returns HTTP 500 and the CLI channel's `/v3/config`
|
|
390
|
+
* omits chat-usable models — so global reads `/v3/config` as the desktop
|
|
391
|
+
* channel (see {@link DESKTOP_UA}). Both documents share the entry shape, so
|
|
392
|
+
* one parser serves them. No user-side toggle is involved: the region comes
|
|
393
|
+
* from the credential's `domain`.
|
|
394
|
+
*/
|
|
395
|
+
async fetchModels(credential, signal) {
|
|
396
|
+
const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
|
|
397
|
+
if (regionOf(credential.domain) === "global") {
|
|
398
|
+
const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
|
|
399
|
+
headers: {
|
|
400
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
401
|
+
"Accept": "application/json",
|
|
402
|
+
...credential.uid === "" ? {} : { "X-User-Id": credential.uid },
|
|
403
|
+
...credential.domain === "" ? {} : { "X-Domain": credential.domain },
|
|
404
|
+
"X-Product": "SaaS",
|
|
405
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
406
|
+
"Connection": "close",
|
|
407
|
+
"User-Agent": DESKTOP_UA
|
|
408
|
+
},
|
|
409
|
+
signal: timeout
|
|
422
410
|
});
|
|
411
|
+
const envelope = await readEnvelope(response);
|
|
412
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
413
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
414
|
+
return selectCliModels(data["models"], data["agents"]);
|
|
415
|
+
}
|
|
416
|
+
const response = await fetch(`${chatBase(credential)}${MODELS_CATALOG_PATH}`, {
|
|
417
|
+
headers: {
|
|
418
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
419
|
+
"Accept": "application/json",
|
|
420
|
+
"Origin": originReferer(credential),
|
|
421
|
+
"Referer": `${originReferer(credential)}/`,
|
|
422
|
+
"User-Agent": CLIENT_UA
|
|
423
|
+
},
|
|
424
|
+
signal: timeout
|
|
423
425
|
});
|
|
426
|
+
const envelope = await readEnvelope(response);
|
|
427
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
428
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
429
|
+
return selectCliModels(data["models"], data["agents"]);
|
|
424
430
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
+
/** Query today's check-in status without changing account state. */
|
|
432
|
+
async fetchCheckinStatus(credential) {
|
|
433
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: billingHeaders(credential),
|
|
436
|
+
body: "{}",
|
|
437
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
438
|
+
});
|
|
439
|
+
const envelope = await readEnvelope(response);
|
|
440
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
441
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
442
|
+
const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
443
|
+
return {
|
|
444
|
+
active: data["active"] === true,
|
|
445
|
+
todayCheckedIn: data["today_checked_in"] === true,
|
|
446
|
+
streakDays: numberField("streak_days"),
|
|
447
|
+
dailyCredit: numberField("daily_credit"),
|
|
448
|
+
todayCredit: numberField("today_credit"),
|
|
449
|
+
isStreakDay: data["is_streak_day"] === true,
|
|
450
|
+
nextStreakDay: numberField("next_streak_day"),
|
|
451
|
+
streakBonusDays: numberField("streak_bonus_days"),
|
|
452
|
+
streakBonusCredit: numberField("streak_bonus_credit"),
|
|
453
|
+
...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
|
|
454
|
+
};
|
|
431
455
|
}
|
|
432
|
-
/**
|
|
433
|
-
async
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
456
|
+
/** Claim today's check-in reward. The browser route guards this mutation. */
|
|
457
|
+
async claimDailyCheckin(credential) {
|
|
458
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: billingHeaders(credential),
|
|
461
|
+
body: "{}",
|
|
462
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
463
|
+
});
|
|
464
|
+
const envelope = await readEnvelope(response);
|
|
465
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
466
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
467
|
+
const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
468
|
+
return {
|
|
469
|
+
credit: numberField("credit"),
|
|
470
|
+
streakDays: numberField("streak_days"),
|
|
471
|
+
isStreakDay: data["is_streak_day"] === true
|
|
472
|
+
};
|
|
438
473
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
*
|
|
444
|
-
|
|
445
|
-
|
|
474
|
+
/**
|
|
475
|
+
* POST the billing endpoint for the remaining credit, keeping every package
|
|
476
|
+
* separate: the card groups monthly-cycle packages itself and lists the
|
|
477
|
+
* nearest-expiring one-off packages, so aggregation here would lose the
|
|
478
|
+
* dates it needs.
|
|
479
|
+
*/
|
|
480
|
+
async fetchCredits(credential) {
|
|
481
|
+
const now = /* @__PURE__ */ new Date();
|
|
482
|
+
const format = (date) => [
|
|
483
|
+
date.getFullYear().toString().padStart(4, "0"),
|
|
484
|
+
(date.getMonth() + 1).toString().padStart(2, "0"),
|
|
485
|
+
date.getDate().toString().padStart(2, "0")
|
|
486
|
+
].join("-") + " " + [
|
|
487
|
+
date.getHours().toString().padStart(2, "0"),
|
|
488
|
+
date.getMinutes().toString().padStart(2, "0"),
|
|
489
|
+
date.getSeconds().toString().padStart(2, "0")
|
|
490
|
+
].join(":");
|
|
491
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: billingHeaders(credential),
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
PageNumber: 1,
|
|
496
|
+
PageSize: 100,
|
|
497
|
+
ProductCode: "p_tcaca",
|
|
498
|
+
Status: [0, 3],
|
|
499
|
+
PackageEndTimeRangeBegin: format(now),
|
|
500
|
+
PackageEndTimeRangeEnd: format(new Date(now.getTime() + 3185136e6))
|
|
501
|
+
}),
|
|
502
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
503
|
+
});
|
|
504
|
+
const envelope = await readEnvelope(response);
|
|
505
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
506
|
+
const responseWrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
507
|
+
const data = typeof responseWrapper["Response"] === "object" && responseWrapper["Response"] !== null ? responseWrapper["Response"] : {};
|
|
508
|
+
const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
|
|
509
|
+
const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
|
|
510
|
+
let total = 0;
|
|
511
|
+
let nearestExpiryMs;
|
|
512
|
+
let expiringSoon = 0;
|
|
513
|
+
const SOON_MS = 2592e5;
|
|
514
|
+
const parseDate = (raw) => {
|
|
515
|
+
if (typeof raw === "number" && raw > 0xe8d4a51000) return raw;
|
|
516
|
+
if (typeof raw === "string" && raw !== "") {
|
|
517
|
+
const parsed = Date.parse(raw);
|
|
518
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
const packages = [];
|
|
522
|
+
for (const raw of rawAccounts) {
|
|
523
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
524
|
+
const account = raw;
|
|
525
|
+
const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
526
|
+
const monthly = numberField("CapacityType") === 4;
|
|
527
|
+
const size = monthly ? numberField("CycleCapacitySize") : numberField("CapacitySize");
|
|
528
|
+
const remain = monthly ? numberField("CycleCapacityRemain") : numberField("CapacityRemain");
|
|
529
|
+
const cappedRemain = remain < 0 ? 0 : remain;
|
|
530
|
+
const cycleEndMs = parseDate(account["CycleEndTime"]);
|
|
531
|
+
const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
|
|
532
|
+
const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
|
|
533
|
+
if (!monthly && (cappedRemain <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
|
|
534
|
+
total += cappedRemain;
|
|
535
|
+
const expiryMs = expiresAtMs;
|
|
536
|
+
if (expiryMs !== void 0) {
|
|
537
|
+
if (nearestExpiryMs === void 0 || expiryMs < nearestExpiryMs) nearestExpiryMs = expiryMs;
|
|
538
|
+
if (expiryMs - Date.now() <= SOON_MS) expiringSoon += cappedRemain;
|
|
539
|
+
}
|
|
540
|
+
packages.push({
|
|
541
|
+
packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
|
|
542
|
+
remain: cappedRemain,
|
|
543
|
+
size,
|
|
544
|
+
monthly,
|
|
545
|
+
...refreshAtMs === void 0 ? {} : { refreshAtMs },
|
|
546
|
+
...expiresAtMs === void 0 ? {} : { expiresAtMs }
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
total,
|
|
551
|
+
packages,
|
|
552
|
+
expiringSoon,
|
|
553
|
+
...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/auth.ts
|
|
559
|
+
/**
|
|
560
|
+
* WorkBuddy credential resolution.
|
|
561
|
+
*
|
|
562
|
+
* 参考:corrinehu/dsh-workbuddy-connect(MIT,Copyright (c) 2026 Corrine Hu)
|
|
563
|
+
* — 桌面端 auth 文件只读、刷新结果写入 $DSH_HOME 自有副本、双凭据取
|
|
564
|
+
* 到期更晚者、按需刷新(5 分钟余量)与单飞去重、刷新失败但 token
|
|
565
|
+
* 未过期则继续沿用旧 token。这些机制已在该项目验证,此处沿用。
|
|
566
|
+
* 改动:原版只解析单个 `workbuddy-desktop.info`。WorkBuddy 桌面端在
|
|
567
|
+
* 同一 auth 目录留下带时间戳的备份文件(`workbuddy-desktop.<stamp>.info`),
|
|
568
|
+
* 实测这些文件各自持有不同账号的可用凭据(本机 6 个文件 → 2 个账号)。
|
|
569
|
+
* 本实现改为扫描整个 auth 目录,按 uin 去重为多个可选账号。跟随 App
|
|
570
|
+
* 当前登录(live 文件)仍是默认行为;用户显式选择的账号被严格绑定,
|
|
571
|
+
* 不因积分多少而切换,失效时也不会静默改选其他账号。
|
|
572
|
+
* 另:store 可按区域(cn | global)限定可见账号 —— 国内版与国际版各持
|
|
573
|
+
* 一个 store,账号、刷新、选择完全隔离;插件自有刷新副本也按区域分
|
|
574
|
+
* 文件(`.workbuddy-auth.<region>.json`),双账号同时在线互不覆盖,
|
|
575
|
+
* 旧的单文件 `.workbuddy-auth.json` 作为迁移源保留读取。
|
|
576
|
+
*
|
|
577
|
+
* @module dsh-connect-workbuddy/auth
|
|
446
578
|
*/
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
{
|
|
491
|
-
id: "kimi-k3-1",
|
|
492
|
-
name: "Kimi-K3",
|
|
493
|
-
contextWindow: 1e6,
|
|
494
|
-
maxTokens: 32e3
|
|
495
|
-
},
|
|
496
|
-
{
|
|
497
|
-
id: "kimi-k2.7",
|
|
498
|
-
name: "Kimi-K2.7-Code",
|
|
499
|
-
contextWindow: 256e3,
|
|
500
|
-
maxTokens: 32e3
|
|
501
|
-
},
|
|
502
|
-
{
|
|
503
|
-
id: "kimi-k2.6",
|
|
504
|
-
name: "Kimi-K2.6",
|
|
505
|
-
contextWindow: 256e3,
|
|
506
|
-
maxTokens: 32e3
|
|
507
|
-
},
|
|
508
|
-
{
|
|
509
|
-
id: "deepseek-v4-flash",
|
|
510
|
-
name: "Deepseek-V4-Flash",
|
|
511
|
-
contextWindow: 1e6,
|
|
512
|
-
maxTokens: 5e4
|
|
513
|
-
},
|
|
514
|
-
{
|
|
515
|
-
id: "deepseek-v4-pro",
|
|
516
|
-
name: "Deepseek-V4-Pro",
|
|
517
|
-
contextWindow: 1e6,
|
|
518
|
-
maxTokens: 5e4
|
|
579
|
+
/** Legacy single-copy basename (pre-dual-provider); kept as migration source. */
|
|
580
|
+
const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
|
|
581
|
+
/** Env variable that overrides the desktop auth-file location. */
|
|
582
|
+
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
583
|
+
/** Basename of the live WorkBuddy desktop auth file. */
|
|
584
|
+
const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
585
|
+
/** Prefix of the plugin-owned per-region credential copies. */
|
|
586
|
+
const WORKBUDDY_OWN_PREFIX = ".workbuddy-auth";
|
|
587
|
+
/** Current on-disk format of the plugin-owned copy; readers reject others. */
|
|
588
|
+
const OWN_FORMAT_VERSION = 1;
|
|
589
|
+
/**
|
|
590
|
+
* Plugin-owned copy path for one region inside the Harness home. Each
|
|
591
|
+
* region's store refreshes into its own file so two simultaneously signed-in
|
|
592
|
+
* regions never overwrite each other's refreshed token.
|
|
593
|
+
*/
|
|
594
|
+
function workbuddyOwnAuthPath(region) {
|
|
595
|
+
return join(resolveDshHome(), `${WORKBUDDY_OWN_PREFIX}.${region}.json`);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Pre-dual-provider single-copy path. Still read as a migration source (a
|
|
599
|
+
* legacy credential serves the region it belongs to until that region's own
|
|
600
|
+
* first refresh writes the per-region file), and removed by `logout`.
|
|
601
|
+
*/
|
|
602
|
+
function legacyWorkbuddyOwnAuthPath() {
|
|
603
|
+
return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Platform-default directories holding the WorkBuddy desktop app's auth file.
|
|
607
|
+
*
|
|
608
|
+
* Windows and Linux prefer the OS-issued env location and fall back to the
|
|
609
|
+
* home-derived convention when it is unset, so a redirected profile (OneDrive
|
|
610
|
+
* folder backup, enterprise policy) still resolves. macOS has no equivalent
|
|
611
|
+
* env variable; the single Application Support path is used as-is.
|
|
612
|
+
*
|
|
613
|
+
* `platform`, `home`, and `env` are injectable so the platform branches are
|
|
614
|
+
* testable on any host without touching a real machine.
|
|
615
|
+
*/
|
|
616
|
+
function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), env = process.env) {
|
|
617
|
+
if (platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
618
|
+
if (platform === "win32") {
|
|
619
|
+
const local = nonEmptyEnv(env["LOCALAPPDATA"]) ?? join(home, "AppData", "Local");
|
|
620
|
+
const roaming = nonEmptyEnv(env["APPDATA"]) ?? join(home, "AppData", "Roaming");
|
|
621
|
+
return [join(local, "CodeBuddyExtension", "Data", "Public", "auth"), join(roaming, "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
519
622
|
}
|
|
520
|
-
|
|
623
|
+
if (platform === "linux") {
|
|
624
|
+
const config = nonEmptyEnv(env["XDG_CONFIG_HOME"]) ?? join(home, ".config");
|
|
625
|
+
return [join(config, "CodeBuddyExtension", "Data", "Public", "auth")];
|
|
626
|
+
}
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
/** A non-empty, trimmed env value, or undefined when unset/blank. */
|
|
630
|
+
function nonEmptyEnv(value) {
|
|
631
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
632
|
+
}
|
|
633
|
+
/** The live auth file's platform candidates, in probe order. */
|
|
634
|
+
function defaultDesktopAuthCandidates() {
|
|
635
|
+
return defaultDesktopAuthDirs().map((dir) => join(dir, WORKBUDDY_LIVE_FILENAME));
|
|
636
|
+
}
|
|
637
|
+
/** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
|
|
638
|
+
function defaultDesktopAuthPath() {
|
|
639
|
+
return defaultDesktopAuthCandidates()[0];
|
|
640
|
+
}
|
|
641
|
+
/** Normalize an expiry that may arrive in seconds or milliseconds. */
|
|
642
|
+
function expiryToMs(value) {
|
|
643
|
+
if (value <= 0) return 0;
|
|
644
|
+
return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
645
|
+
}
|
|
646
|
+
function optionalString(value) {
|
|
647
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
648
|
+
}
|
|
521
649
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
* so a global account must never be seeded with the CN list. Like the CN
|
|
526
|
-
* fallback this is replaced by the live refresh; it only keeps the provider
|
|
527
|
-
* usable before the first fetch lands. Order and rates mirror the upstream.
|
|
650
|
+
* Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
|
|
651
|
+
* nested form `{"auth":{...},"account":{...}}` and the flat panel form.
|
|
652
|
+
* Returns undefined when the document carries no access token.
|
|
528
653
|
*/
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
creditMultiplier: .79
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
id: "fast-model",
|
|
539
|
-
name: "Fast",
|
|
540
|
-
contextWindow: 2e5,
|
|
541
|
-
maxTokens: 32e3,
|
|
542
|
-
creditMultiplier: .34
|
|
543
|
-
},
|
|
544
|
-
{
|
|
545
|
-
id: "balanced-model",
|
|
546
|
-
name: "Balanced",
|
|
547
|
-
contextWindow: 256e3,
|
|
548
|
-
maxTokens: 32e3,
|
|
549
|
-
creditMultiplier: .59
|
|
550
|
-
},
|
|
551
|
-
{
|
|
552
|
-
id: "primary-model",
|
|
553
|
-
name: "Primary",
|
|
554
|
-
contextWindow: 272e3,
|
|
555
|
-
maxTokens: 72e3,
|
|
556
|
-
creditMultiplier: 3.31
|
|
557
|
-
},
|
|
558
|
-
{
|
|
559
|
-
id: "deep-model",
|
|
560
|
-
name: "Deep",
|
|
561
|
-
contextWindow: 176e3,
|
|
562
|
-
maxTokens: 24e3,
|
|
563
|
-
creditMultiplier: 3.33
|
|
564
|
-
},
|
|
565
|
-
{
|
|
566
|
-
id: "deepseek-v4.1-flash",
|
|
567
|
-
name: "Deepseek-V4.1-Flash",
|
|
568
|
-
contextWindow: 1e6,
|
|
569
|
-
maxTokens: 128e3,
|
|
570
|
-
creditMultiplier: 0
|
|
571
|
-
},
|
|
572
|
-
{
|
|
573
|
-
id: "gpt-6-astra",
|
|
574
|
-
name: "GPT-6-Astra",
|
|
575
|
-
contextWindow: 1e6,
|
|
576
|
-
maxTokens: 128e3,
|
|
577
|
-
creditMultiplier: 6.67
|
|
578
|
-
},
|
|
579
|
-
{
|
|
580
|
-
id: "hy4-preview",
|
|
581
|
-
name: "Hy4 preview",
|
|
582
|
-
contextWindow: 1e6,
|
|
583
|
-
maxTokens: 64e3,
|
|
584
|
-
creditMultiplier: 0
|
|
585
|
-
},
|
|
586
|
-
{
|
|
587
|
-
id: "hy3",
|
|
588
|
-
name: "Hy3",
|
|
589
|
-
contextWindow: 192e3,
|
|
590
|
-
maxTokens: 64e3,
|
|
591
|
-
creditMultiplier: 0
|
|
592
|
-
},
|
|
593
|
-
{
|
|
594
|
-
id: "gpt-5.6-sol",
|
|
595
|
-
name: "GPT-5.6-Sol",
|
|
596
|
-
contextWindow: 1e6,
|
|
597
|
-
maxTokens: 128e3,
|
|
598
|
-
creditMultiplier: 3.47
|
|
599
|
-
},
|
|
600
|
-
{
|
|
601
|
-
id: "gpt-5.6-terra",
|
|
602
|
-
name: "GPT-5.6-Terra",
|
|
603
|
-
contextWindow: 1e6,
|
|
604
|
-
maxTokens: 128e3,
|
|
605
|
-
creditMultiplier: 1.39
|
|
606
|
-
},
|
|
607
|
-
{
|
|
608
|
-
id: "gpt-5.6-luna",
|
|
609
|
-
name: "GPT-5.6-Luna",
|
|
610
|
-
contextWindow: 1e6,
|
|
611
|
-
maxTokens: 128e3,
|
|
612
|
-
creditMultiplier: .14
|
|
613
|
-
},
|
|
614
|
-
{
|
|
615
|
-
id: "gpt-5.5",
|
|
616
|
-
name: "GPT-5.5",
|
|
617
|
-
contextWindow: 1e6,
|
|
618
|
-
maxTokens: 128e3,
|
|
619
|
-
creditMultiplier: 3.31
|
|
620
|
-
},
|
|
621
|
-
{
|
|
622
|
-
id: "gpt-5.4",
|
|
623
|
-
name: "GPT-5.4",
|
|
624
|
-
contextWindow: 272e3,
|
|
625
|
-
maxTokens: 72e3,
|
|
626
|
-
creditMultiplier: 1.65
|
|
627
|
-
},
|
|
628
|
-
{
|
|
629
|
-
id: "gpt-5.3-codex",
|
|
630
|
-
name: "GPT-5.3-Codex",
|
|
631
|
-
contextWindow: 272e3,
|
|
632
|
-
maxTokens: 72e3,
|
|
633
|
-
creditMultiplier: 1.25
|
|
634
|
-
},
|
|
635
|
-
{
|
|
636
|
-
id: "gemini-3.5-flash",
|
|
637
|
-
name: "Gemini-3.5-Flash",
|
|
638
|
-
contextWindow: 1e6,
|
|
639
|
-
maxTokens: 65536,
|
|
640
|
-
creditMultiplier: .99
|
|
641
|
-
},
|
|
642
|
-
{
|
|
643
|
-
id: "glm-5.3",
|
|
644
|
-
name: "GLM-5.3",
|
|
645
|
-
contextWindow: 1e6,
|
|
646
|
-
maxTokens: 48e3,
|
|
647
|
-
creditMultiplier: .79
|
|
648
|
-
},
|
|
649
|
-
{
|
|
650
|
-
id: "glm-5.2",
|
|
651
|
-
name: "GLM-5.2",
|
|
652
|
-
contextWindow: 1e6,
|
|
653
|
-
maxTokens: 48e3,
|
|
654
|
-
creditMultiplier: .79
|
|
655
|
-
},
|
|
656
|
-
{
|
|
657
|
-
id: "kimi-k3",
|
|
658
|
-
name: "Kimi-K3",
|
|
659
|
-
contextWindow: 1e6,
|
|
660
|
-
maxTokens: 32e3,
|
|
661
|
-
creditMultiplier: 1.62
|
|
662
|
-
},
|
|
663
|
-
{
|
|
664
|
-
id: "kimi-k2.6",
|
|
665
|
-
name: "Kimi-K2.6",
|
|
666
|
-
contextWindow: 256e3,
|
|
667
|
-
maxTokens: 32e3,
|
|
668
|
-
creditMultiplier: .52
|
|
654
|
+
function parseWorkBuddyAuth(text, filePath) {
|
|
655
|
+
let parsed;
|
|
656
|
+
try {
|
|
657
|
+
parsed = JSON.parse(text);
|
|
658
|
+
} catch {
|
|
659
|
+
return;
|
|
669
660
|
}
|
|
670
|
-
|
|
661
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
662
|
+
const document = parsed;
|
|
663
|
+
let auth;
|
|
664
|
+
let identity;
|
|
665
|
+
if (typeof document["auth"] === "object" && document["auth"] !== null) {
|
|
666
|
+
auth = document["auth"];
|
|
667
|
+
identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
|
|
668
|
+
} else {
|
|
669
|
+
auth = document;
|
|
670
|
+
identity = document;
|
|
671
|
+
}
|
|
672
|
+
const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
|
|
673
|
+
if (accessToken === "") return void 0;
|
|
674
|
+
const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
|
|
675
|
+
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
676
|
+
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
677
|
+
const nickname = optionalString(identity["nickname"]);
|
|
678
|
+
const uin = optionalString(identity["uin"]);
|
|
679
|
+
return {
|
|
680
|
+
accessToken,
|
|
681
|
+
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
682
|
+
expiresAtMs,
|
|
683
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
684
|
+
domain: optionalString(auth["domain"]) ?? "",
|
|
685
|
+
uid: optionalString(identity["uid"]) ?? "",
|
|
686
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
687
|
+
...nickname === void 0 ? {} : { nickname },
|
|
688
|
+
...uin === void 0 ? {} : { uin },
|
|
689
|
+
source: "desktop",
|
|
690
|
+
filePath
|
|
691
|
+
};
|
|
692
|
+
}
|
|
671
693
|
/**
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
694
|
+
* Rank two candidate files for the same account.
|
|
695
|
+
*
|
|
696
|
+
* The live `workbuddy-desktop.info` always wins: it is the app's current
|
|
697
|
+
* sign-in, and the upstream revokes the tokens in the timestamped backups
|
|
698
|
+
* even though their stored `expiresAt` is still in the future (observed on a
|
|
699
|
+
* real machine — every backup claimed a 2027 expiry while only the live
|
|
700
|
+
* file's token was accepted). Expiry is therefore only a tie-breaker among
|
|
701
|
+
* backups, never the primary ordering.
|
|
675
702
|
*/
|
|
676
|
-
function
|
|
677
|
-
return
|
|
703
|
+
function fileRank(path) {
|
|
704
|
+
return authFileName(path) === WORKBUDDY_LIVE_FILENAME ? 0 : 1;
|
|
678
705
|
}
|
|
679
|
-
/**
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
706
|
+
/**
|
|
707
|
+
* Filename of a path regardless of the host separator: Windows paths use `\`
|
|
708
|
+
* and this helper must keep working when a Windows path is compared on a
|
|
709
|
+
* POSIX host (e.g. tests injecting a Windows-style auth dir).
|
|
710
|
+
*/
|
|
711
|
+
function authFileName(path) {
|
|
712
|
+
const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
713
|
+
return separator === -1 ? path : path.slice(separator + 1);
|
|
685
714
|
}
|
|
686
|
-
|
|
687
|
-
|
|
715
|
+
/**
|
|
716
|
+
* Stable account id. `uin` is the billing identity the upstream keys on and
|
|
717
|
+
* survives across re-login; `uid` is the fallback for documents without one.
|
|
718
|
+
*/
|
|
719
|
+
function workbuddyAccountId(credential) {
|
|
720
|
+
const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
|
|
721
|
+
return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 24);
|
|
688
722
|
}
|
|
689
|
-
/**
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
723
|
+
/** Serialize the plugin-owned copy. */
|
|
724
|
+
function ownDocument(credential, accountId) {
|
|
725
|
+
return {
|
|
726
|
+
version: OWN_FORMAT_VERSION,
|
|
727
|
+
...accountId === void 0 ? {} : { accountId },
|
|
728
|
+
credential
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/** Parse the plugin-owned copy; other versions and shapes are rejected. */
|
|
732
|
+
function parseOwnDocument(text, filePath) {
|
|
733
|
+
let parsed;
|
|
734
|
+
try {
|
|
735
|
+
parsed = JSON.parse(text);
|
|
736
|
+
} catch {
|
|
737
|
+
return;
|
|
695
738
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
739
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
740
|
+
const document = parsed;
|
|
741
|
+
if (document["version"] !== OWN_FORMAT_VERSION) return void 0;
|
|
742
|
+
if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
|
|
743
|
+
const credential = parseWorkBuddyAuth(JSON.stringify({ auth: document["credential"] }), filePath);
|
|
744
|
+
if (credential === void 0) return void 0;
|
|
745
|
+
return {
|
|
746
|
+
...credential,
|
|
747
|
+
source: "dsh"
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/** Whether a filesystem error reports an absent path. */
|
|
751
|
+
function isENOENT(error) {
|
|
752
|
+
return error?.code === "ENOENT";
|
|
753
|
+
}
|
|
754
|
+
/** Read one auth file, tolerating absence and unparsable content. */
|
|
755
|
+
async function readAuthFile(path) {
|
|
756
|
+
try {
|
|
757
|
+
return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (isENOENT(error)) return void 0;
|
|
760
|
+
return;
|
|
700
761
|
}
|
|
701
|
-
}
|
|
702
|
-
//#endregion
|
|
703
|
-
//#region src/upstream.ts
|
|
704
|
-
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
705
|
-
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
706
|
-
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
762
|
+
}
|
|
707
763
|
/**
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
* `/console/enterprises/personal/models` alias. See {@link GLOBAL_CONFIG_PATH}
|
|
711
|
-
* for why the international region reads a different document.
|
|
712
|
-
*/
|
|
713
|
-
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
714
|
-
/**
|
|
715
|
-
* Remote product-config path on the global gateway. This is the document the
|
|
716
|
-
* desktop channel receives; it is the only source that lists the account's
|
|
717
|
-
* full international chat roster (see {@link DESKTOP_UA}).
|
|
718
|
-
*/
|
|
719
|
-
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
720
|
-
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
721
|
-
/**
|
|
722
|
-
* User agent of the WorkBuddy desktop app.
|
|
723
|
-
*
|
|
724
|
-
* The config service serves a DIFFERENT product configuration per client
|
|
725
|
-
* channel, selected by this product token — the version suffix is ignored
|
|
726
|
-
* (`WorkBuddy/5.5.2`, `WorkBuddy/1.0.0` and a bare `WorkBuddy` answer
|
|
727
|
-
* identically). On the INTERNATIONAL gateway the split decides which models
|
|
728
|
-
* exist at all:
|
|
729
|
-
*
|
|
730
|
-
* - CLI channel (`CLI/… CodeBuddy/…`) → 35 models that OMIT
|
|
731
|
-
* `deepseek-v4.1-flash` and `gpt-6-astra`, even though both are perfectly
|
|
732
|
-
* chat-usable (verified: `deepseek-v4.1-flash` streams HTTP 200 and is
|
|
733
|
-
* billed `x0.00`);
|
|
734
|
-
* - desktop channel → the account's real 20-model chat roster including both.
|
|
764
|
+
* Read-only credential store with demand-driven refresh and multi-account
|
|
765
|
+
* discovery.
|
|
735
766
|
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
767
|
+
* Refresh policy: refresh only when the access token is inside the margin
|
|
768
|
+
* (or already expired), keep the refreshed credential in the plugin-owned
|
|
769
|
+
* copy, and never write the desktop app's files. A failed refresh still
|
|
770
|
+
* returns a not-yet-expired token so an unreachable refresh endpoint does
|
|
771
|
+
* not take down a working session.
|
|
740
772
|
*/
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
];
|
|
762
|
-
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
763
|
-
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
764
|
-
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
765
|
-
function classifyUpstreamError(status, body) {
|
|
766
|
-
if (status === 402) return "hard_credit";
|
|
767
|
-
const lower = body.toLowerCase();
|
|
768
|
-
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
769
|
-
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
770
|
-
if (status === 429) return "soft_rate";
|
|
771
|
-
if (status === 404) return "not_found";
|
|
772
|
-
if (status >= 500) return "server";
|
|
773
|
-
if (status >= 400) return "client";
|
|
774
|
-
return "client";
|
|
775
|
-
}
|
|
776
|
-
/** Region for a login domain; an empty domain means CN (matching upstream tooling). */
|
|
777
|
-
function regionOf(domain) {
|
|
778
|
-
const lowered = domain.trim().toLowerCase();
|
|
779
|
-
if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
|
|
780
|
-
return "cn";
|
|
781
|
-
}
|
|
782
|
-
function chatBase(credential) {
|
|
783
|
-
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
|
|
784
|
-
}
|
|
785
|
-
function billingBase(credential) {
|
|
786
|
-
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
787
|
-
}
|
|
788
|
-
function originReferer(credential) {
|
|
789
|
-
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
790
|
-
}
|
|
791
|
-
/** Headers every upstream request shares. */
|
|
792
|
-
function commonHeaders(credential) {
|
|
793
|
-
return {
|
|
794
|
-
"Accept": "application/json, text/plain, */*",
|
|
795
|
-
"X-Requested-With": "XMLHttpRequest",
|
|
796
|
-
"Origin": originReferer(credential),
|
|
797
|
-
"Referer": `${originReferer(credential)}/`,
|
|
798
|
-
"User-Agent": CLIENT_UA
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
802
|
-
function chatHeaders(credential) {
|
|
803
|
-
return {
|
|
804
|
-
...commonHeaders(credential),
|
|
805
|
-
"Content-Type": "application/json",
|
|
806
|
-
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
807
|
-
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
808
|
-
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
809
|
-
"X-Product": "SaaS"
|
|
810
|
-
};
|
|
811
|
-
}
|
|
812
|
-
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
813
|
-
function refreshHeaders(credential) {
|
|
814
|
-
const headers = {
|
|
815
|
-
...commonHeaders(credential),
|
|
816
|
-
"X-Refresh-Token": credential.refreshToken,
|
|
817
|
-
"X-Auth-Refresh-Source": "workbuddy"
|
|
818
|
-
};
|
|
819
|
-
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
820
|
-
return headers;
|
|
821
|
-
}
|
|
822
|
-
/** Billing request headers. */
|
|
823
|
-
function billingHeaders(credential) {
|
|
824
|
-
const headers = {
|
|
825
|
-
"Authorization": `Bearer ${credential.accessToken}`,
|
|
826
|
-
"Accept": "application/json",
|
|
827
|
-
"Content-Type": "application/json"
|
|
828
|
-
};
|
|
829
|
-
if (credential.uid !== "") headers["X-User-Id"] = credential.uid;
|
|
830
|
-
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
|
|
831
|
-
headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
832
|
-
headers["X-Tenant-Id"] = credential.enterpriseId;
|
|
773
|
+
var WorkBuddyCredentialStore = class {
|
|
774
|
+
refresh;
|
|
775
|
+
refreshMarginMs;
|
|
776
|
+
region;
|
|
777
|
+
ownPathExplicit;
|
|
778
|
+
legacyOwnPath;
|
|
779
|
+
legacyOwnPathExplicit;
|
|
780
|
+
authDirs;
|
|
781
|
+
desktopPathOverride;
|
|
782
|
+
accountId;
|
|
783
|
+
inflight;
|
|
784
|
+
constructor(options) {
|
|
785
|
+
this.refresh = options.refresh;
|
|
786
|
+
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
787
|
+
this.region = options.region;
|
|
788
|
+
this.ownPathExplicit = options.ownPath;
|
|
789
|
+
this.legacyOwnPath = options.legacyOwnPath ?? legacyWorkbuddyOwnAuthPath();
|
|
790
|
+
this.legacyOwnPathExplicit = options.legacyOwnPath;
|
|
791
|
+
this.authDirs = options.authDirs;
|
|
792
|
+
this.desktopPathOverride = options.desktopPath;
|
|
833
793
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
839
|
-
* force `stream: true` (the upstream rejects non-streaming) and flatten
|
|
840
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400).
|
|
841
|
-
*/
|
|
842
|
-
function prepareChatBody(source) {
|
|
843
|
-
let body;
|
|
844
|
-
try {
|
|
845
|
-
body = JSON.parse(source);
|
|
846
|
-
} catch {
|
|
847
|
-
return source;
|
|
794
|
+
/** Whether a credential's login domain belongs to this store's region. */
|
|
795
|
+
matchesRegion(domain) {
|
|
796
|
+
return this.region === void 0 || regionOf(domain) === this.region;
|
|
848
797
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
if (
|
|
798
|
+
/**
|
|
799
|
+
* The path this store refreshes into: the per-region file for a
|
|
800
|
+
* region-scoped store, the legacy single file otherwise, or an explicitly
|
|
801
|
+
* injected path in tests.
|
|
802
|
+
*/
|
|
803
|
+
ownAuthPath() {
|
|
804
|
+
if (this.ownPathExplicit !== void 0) return this.ownPathExplicit;
|
|
805
|
+
return this.region !== void 0 ? workbuddyOwnAuthPath(this.region) : this.legacyOwnPath;
|
|
856
806
|
}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
807
|
+
/**
|
|
808
|
+
* Every plugin-owned copy to read, most preferred first. A region-scoped
|
|
809
|
+
* store reads the legacy single copy as its migration source (readAll's
|
|
810
|
+
* region filter drops it when it carries the other region's credential); an
|
|
811
|
+
* unscoped store reads everything so diagnostics see both regions.
|
|
812
|
+
*
|
|
813
|
+
* With an explicitly injected own path the legacy source is read ONLY when
|
|
814
|
+
* it was injected too — a test that pins one file must not accidentally see
|
|
815
|
+
* the real machine's legacy copy.
|
|
816
|
+
*/
|
|
817
|
+
ownCandidates() {
|
|
818
|
+
if (this.ownPathExplicit !== void 0) return this.legacyOwnPathExplicit !== void 0 ? [this.ownPathExplicit, this.legacyOwnPathExplicit] : [this.ownPathExplicit];
|
|
819
|
+
if (this.region !== void 0) return [workbuddyOwnAuthPath(this.region), this.legacyOwnPath];
|
|
820
|
+
return [
|
|
821
|
+
this.legacyOwnPath,
|
|
822
|
+
workbuddyOwnAuthPath("cn"),
|
|
823
|
+
workbuddyOwnAuthPath("global")
|
|
824
|
+
];
|
|
874
825
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
delete obj["tool_choice"];
|
|
880
|
-
suppress();
|
|
881
|
-
} else if (type === "auto" || type === "required") obj["tool_choice"] = type;
|
|
882
|
-
else if (type === "function") {
|
|
883
|
-
const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
|
|
884
|
-
let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
|
|
885
|
-
if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
|
|
886
|
-
name = name.trim();
|
|
887
|
-
obj["tool_choice"] = name !== "" ? name : "auto";
|
|
888
|
-
} else delete obj["tool_choice"];
|
|
889
|
-
return;
|
|
826
|
+
/** Repoint the desktop file or directory; applies on the next read. */
|
|
827
|
+
setDesktopPath(path) {
|
|
828
|
+
this.desktopPathOverride = path;
|
|
829
|
+
this.inflight = void 0;
|
|
890
830
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
let parsed;
|
|
896
|
-
try {
|
|
897
|
-
parsed = JSON.parse(text);
|
|
898
|
-
} catch {
|
|
899
|
-
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
831
|
+
/** Select an account by id; tokens stay outside settings. */
|
|
832
|
+
selectAccount(accountId) {
|
|
833
|
+
this.accountId = accountId;
|
|
834
|
+
this.inflight = void 0;
|
|
900
835
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
836
|
+
/** Selected account id, for diagnostics and route assembly. */
|
|
837
|
+
selectedAccountId() {
|
|
838
|
+
return this.accountId;
|
|
839
|
+
}
|
|
840
|
+
/** The auth-file path candidates, in probe order. */
|
|
841
|
+
resolveDesktopCandidates() {
|
|
842
|
+
const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
|
|
843
|
+
const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
|
|
844
|
+
if (explicit !== void 0) return [explicit];
|
|
845
|
+
return defaultDesktopAuthCandidates();
|
|
846
|
+
}
|
|
847
|
+
/** The resolved desktop auth-file path, for diagnostics. */
|
|
848
|
+
desktopAuthPath() {
|
|
849
|
+
return this.resolveDesktopCandidates()[0];
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Every auth file to scan: the live file plus the timestamped backups
|
|
853
|
+
* WorkBuddy leaves beside it.
|
|
854
|
+
*
|
|
855
|
+
* An explicitly configured path pins the *directory*: its siblings are
|
|
856
|
+
* still scanned, because a user who points the plugin at their auth file
|
|
857
|
+
* expects account switching to work the same way it does on the default
|
|
858
|
+
* path. Only the file ordering changes.
|
|
859
|
+
*
|
|
860
|
+
* A corrupt or signed-out file must never hide the others, so each read is
|
|
861
|
+
* independent and failures are skipped rather than propagated.
|
|
862
|
+
*/
|
|
863
|
+
async candidateFiles() {
|
|
864
|
+
const explicitPath = this.desktopPathOverride ?? ((process.env["WORKBUDDY_AUTH_FILE"] ?? "").trim() !== "" ? process.env["WORKBUDDY_AUTH_FILE"] : void 0);
|
|
865
|
+
const files = [];
|
|
866
|
+
if (explicitPath !== void 0) {
|
|
867
|
+
files.push(explicitPath);
|
|
868
|
+
for (const backup of await this.backupsBeside(explicitPath)) files.push(backup);
|
|
869
|
+
return files;
|
|
870
|
+
}
|
|
871
|
+
const dirs = this.authDirs ?? defaultDesktopAuthDirs();
|
|
872
|
+
for (const dir of dirs) {
|
|
873
|
+
const live = join(dir, WORKBUDDY_LIVE_FILENAME);
|
|
874
|
+
files.push(live);
|
|
875
|
+
for (const backup of await this.backupsBeside(live)) files.push(backup);
|
|
876
|
+
}
|
|
877
|
+
return files;
|
|
878
|
+
}
|
|
879
|
+
/** Timestamped siblings of one auth file, newest first by filename. */
|
|
880
|
+
async backupsBeside(path) {
|
|
881
|
+
const dir = dirname(path);
|
|
882
|
+
const base = path.slice(dir.length + 1);
|
|
883
|
+
try {
|
|
884
|
+
return (await readdir(dir)).filter((name) => name !== base && name.endsWith(".info")).sort().reverse().map((name) => join(dir, name));
|
|
885
|
+
} catch {
|
|
886
|
+
return [];
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Read every local credential, deduplicated by account id. Files are
|
|
891
|
+
* probed newest-first, so the first entry for an account is its freshest.
|
|
892
|
+
*
|
|
893
|
+
* A region-scoped store sees only its own region's credentials: the other
|
|
894
|
+
* region's accounts are invisible to selection, refresh, and status alike,
|
|
895
|
+
* which is what keeps the two regions' providers from cross-billing.
|
|
896
|
+
*/
|
|
897
|
+
async readAll() {
|
|
898
|
+
const files = await this.candidateFiles();
|
|
899
|
+
const byId = /* @__PURE__ */ new Map();
|
|
900
|
+
for (const file of files) {
|
|
901
|
+
const credential = await readAuthFile(file);
|
|
902
|
+
if (credential === void 0 || !this.matchesRegion(credential.domain)) continue;
|
|
903
|
+
const id = workbuddyAccountId(credential);
|
|
904
|
+
const existing = byId.get(id);
|
|
905
|
+
if (existing === void 0) {
|
|
906
|
+
byId.set(id, credential);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (fileRank(credential.filePath) < fileRank(existing.filePath) || fileRank(credential.filePath) === fileRank(existing.filePath) && credential.expiresAtMs > existing.expiresAtMs) byId.set(id, credential);
|
|
910
|
+
}
|
|
911
|
+
for (const own of await this.readOwns()) {
|
|
912
|
+
if (!this.matchesRegion(own.domain)) continue;
|
|
913
|
+
const id = workbuddyAccountId(own);
|
|
914
|
+
const existing = byId.get(id);
|
|
915
|
+
if (existing === void 0 || own.expiresAtMs > existing.expiresAtMs) byId.set(id, own);
|
|
916
|
+
}
|
|
917
|
+
return [...byId.values()];
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Default when no account is explicitly selected: the live sign-in, else the
|
|
921
|
+
* freshest credential. Following the app's current sign-in is the documented
|
|
922
|
+
* default behaviour; the backups exist so the user can switch explicitly.
|
|
923
|
+
* This is NOT credit-seeking — it never reorders accounts to find one with
|
|
924
|
+
* remaining credit.
|
|
925
|
+
*/
|
|
926
|
+
preferred(credentials) {
|
|
927
|
+
if (credentials.length === 0) return void 0;
|
|
928
|
+
return credentials.reduce((best, credential) => {
|
|
929
|
+
if (fileRank(credential.filePath) < fileRank(best.filePath)) return credential;
|
|
930
|
+
if (fileRank(credential.filePath) === fileRank(best.filePath) && credential.expiresAtMs > best.expiresAtMs) return credential;
|
|
931
|
+
return best;
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
/** Token-free account list for the plugin card. */
|
|
935
|
+
async accounts() {
|
|
936
|
+
const credentials = await this.readAll();
|
|
937
|
+
if (credentials.length === 0) return [];
|
|
938
|
+
const selectedExists = this.accountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
939
|
+
const defaultSelected = this.preferred(credentials);
|
|
940
|
+
return credentials.map((credential) => {
|
|
941
|
+
const id = workbuddyAccountId(credential);
|
|
942
|
+
return {
|
|
943
|
+
id,
|
|
944
|
+
accountName: credential.nickname ?? credential.uin ?? credential.uid,
|
|
945
|
+
...credential.uin === void 0 ? {} : { uin: credential.uin },
|
|
946
|
+
domain: credential.domain,
|
|
947
|
+
source: credential.source,
|
|
948
|
+
tokenExpiresAtMs: credential.expiresAtMs,
|
|
949
|
+
filePath: credential.filePath,
|
|
950
|
+
selected: selectedExists ? id === this.accountId : credential === defaultSelected
|
|
951
|
+
};
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
/** The freshest stored credential for the current selection, no refresh. */
|
|
955
|
+
async current() {
|
|
956
|
+
const credentials = await this.readAll();
|
|
957
|
+
if (this.accountId === void 0) return this.preferred(credentials);
|
|
958
|
+
return credentials.find((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
959
|
+
}
|
|
960
|
+
/** The credential to send upstream: {@link current}, refreshed on demand. */
|
|
961
|
+
async resolve() {
|
|
962
|
+
const credential = await this.current();
|
|
963
|
+
if (credential === void 0) {
|
|
964
|
+
const candidates = this.resolveDesktopCandidates();
|
|
965
|
+
const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
|
|
966
|
+
throw new Error(`workbuddy: no signed-in WorkBuddy account found; sign in once in the WorkBuddy desktop app (expected ${desktop} or ${WORKBUDDY_AUTH_FILE_ENV}), or refresh an existing session`);
|
|
967
|
+
}
|
|
968
|
+
if (!this.needsRefresh(credential)) return credential;
|
|
969
|
+
this.inflight ??= this.refreshNow(credential).finally(() => {
|
|
970
|
+
this.inflight = void 0;
|
|
971
|
+
});
|
|
972
|
+
return this.inflight;
|
|
973
|
+
}
|
|
974
|
+
/** Read-only sign-in summary; never refreshes and never throws. */
|
|
975
|
+
async status() {
|
|
976
|
+
try {
|
|
977
|
+
const credential = await this.current();
|
|
978
|
+
if (credential === void 0) return { state: "signed-out" };
|
|
979
|
+
return {
|
|
980
|
+
state: "signed-in",
|
|
981
|
+
expiresAtMs: credential.expiresAtMs,
|
|
982
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
983
|
+
...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
|
|
984
|
+
...credential.domain === "" ? {} : { domain: credential.domain },
|
|
985
|
+
source: credential.source
|
|
986
|
+
};
|
|
987
|
+
} catch {
|
|
988
|
+
return { state: "signed-out" };
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Remove every plugin-owned copy this store could read (per-region file,
|
|
993
|
+
* legacy single file, and their lock siblings); the desktop files are
|
|
994
|
+
* untouched. A region store's logout therefore also clears the legacy
|
|
995
|
+
* migration source — deliberate: `logout` is the user's "forget what the
|
|
996
|
+
* plugin stored" action, not a per-account toggle.
|
|
997
|
+
*/
|
|
998
|
+
async logout() {
|
|
999
|
+
for (const path of this.ownCandidates()) {
|
|
1000
|
+
await rm(path, { force: true });
|
|
1001
|
+
await rm(`${path}.lock`, { force: true });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
needsRefresh(credential) {
|
|
1005
|
+
if (credential.expiresAtMs <= 0) return true;
|
|
1006
|
+
return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
|
|
1007
|
+
}
|
|
1008
|
+
async refreshNow(credential) {
|
|
1009
|
+
if (credential.refreshToken === "") {
|
|
1010
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
1011
|
+
throw new Error("workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app");
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
const outcome = await this.refresh(credential);
|
|
1015
|
+
const refreshed = {
|
|
1016
|
+
...credential,
|
|
1017
|
+
accessToken: outcome.accessToken,
|
|
1018
|
+
...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
|
|
1019
|
+
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
1020
|
+
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
|
|
1021
|
+
source: "dsh"
|
|
1022
|
+
};
|
|
1023
|
+
await this.saveOwn(refreshed);
|
|
1024
|
+
return refreshed;
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
1027
|
+
throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async saveOwn(credential) {
|
|
1031
|
+
const accountId = workbuddyAccountId(credential);
|
|
1032
|
+
const path = this.ownAuthPath();
|
|
1033
|
+
await withFileLock(path, async () => {
|
|
1034
|
+
await writeFileAtomic(path, `${JSON.stringify(ownDocument(credential, accountId), null, 2)}\n`, {
|
|
1035
|
+
mode: 384,
|
|
1036
|
+
dirMode: 448
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Every readable plugin-owned copy, in candidate order; absent or corrupt
|
|
1042
|
+
* files are skipped rather than propagated.
|
|
1043
|
+
*/
|
|
1044
|
+
async readOwns() {
|
|
1045
|
+
const copies = [];
|
|
1046
|
+
for (const path of this.ownCandidates()) try {
|
|
1047
|
+
const parsed = parseOwnDocument(await readFile(path, "utf8"), path);
|
|
1048
|
+
if (parsed !== void 0) copies.push(parsed);
|
|
1049
|
+
} catch {}
|
|
1050
|
+
return copies;
|
|
1051
|
+
}
|
|
1052
|
+
/** Whether any candidate file exists as a regular file; diagnostics only. */
|
|
1053
|
+
async desktopFilePresent() {
|
|
1054
|
+
for (const path of this.resolveDesktopCandidates()) try {
|
|
1055
|
+
if ((await stat(path)).isFile()) return true;
|
|
1056
|
+
} catch {}
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/catalog.ts
|
|
969
1062
|
/**
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
* Both the CN personal-models document and the global `/v3/config` document
|
|
974
|
-
* carry `models` plus an `agents` roster with the same entry shape, so one
|
|
975
|
-
* selector serves them. Without a usable `cli` roster the whole parsed catalog
|
|
976
|
-
* is exposed rather than nothing: the roster is an upstream detail that may
|
|
977
|
-
* change, and an empty answer would silently disarm the provider.
|
|
1063
|
+
* Static CLI models captured from the CN endpoint (2026-08-30). The upstream
|
|
1064
|
+
* refresh replaces this list at startup; it exists so the provider registers
|
|
1065
|
+
* with a usable catalog even while the first fetch is in flight or offline.
|
|
978
1066
|
*/
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1067
|
+
const FALLBACK_WORKBUDDY_MODELS = [
|
|
1068
|
+
{
|
|
1069
|
+
id: "auto",
|
|
1070
|
+
name: "Auto",
|
|
1071
|
+
contextWindow: 168e3,
|
|
1072
|
+
maxTokens: 32e3
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
id: "hy3",
|
|
1076
|
+
name: "Hy3",
|
|
1077
|
+
contextWindow: 192e3,
|
|
1078
|
+
maxTokens: 64e3
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
id: "glm-5v-turbo",
|
|
1082
|
+
name: "GLM-5v-Turbo",
|
|
1083
|
+
contextWindow: 2e5,
|
|
1084
|
+
maxTokens: 64e3
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
id: "glm-5.3",
|
|
1088
|
+
name: "GLM-5.3",
|
|
1089
|
+
contextWindow: 1e6,
|
|
1090
|
+
maxTokens: 48e3
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
id: "glm-5.2",
|
|
1094
|
+
name: "GLM-5.2",
|
|
1095
|
+
contextWindow: 1e6,
|
|
1096
|
+
maxTokens: 48e3
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
id: "glm-5.1",
|
|
1100
|
+
name: "GLM-5.1",
|
|
1101
|
+
contextWindow: 2e5,
|
|
1102
|
+
maxTokens: 48e3
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
id: "minimax-m3",
|
|
1106
|
+
name: "MiniMax-M3",
|
|
1107
|
+
contextWindow: 512e3,
|
|
1108
|
+
maxTokens: 128e3
|
|
1109
|
+
},
|
|
1110
|
+
{
|
|
1111
|
+
id: "kimi-k3-1",
|
|
1112
|
+
name: "Kimi-K3",
|
|
1113
|
+
contextWindow: 1e6,
|
|
1114
|
+
maxTokens: 32e3
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
id: "kimi-k2.7",
|
|
1118
|
+
name: "Kimi-K2.7-Code",
|
|
1119
|
+
contextWindow: 256e3,
|
|
1120
|
+
maxTokens: 32e3
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
id: "kimi-k2.6",
|
|
1124
|
+
name: "Kimi-K2.6",
|
|
1125
|
+
contextWindow: 256e3,
|
|
1126
|
+
maxTokens: 32e3
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
id: "deepseek-v4-flash",
|
|
1130
|
+
name: "Deepseek-V4-Flash",
|
|
1131
|
+
contextWindow: 1e6,
|
|
1132
|
+
maxTokens: 5e4
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
id: "deepseek-v4-pro",
|
|
1136
|
+
name: "Deepseek-V4-Pro",
|
|
1137
|
+
contextWindow: 1e6,
|
|
1138
|
+
maxTokens: 5e4
|
|
984
1139
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1140
|
+
];
|
|
1141
|
+
/**
|
|
1142
|
+
* Static CLI models captured from the INTERNATIONAL gateway's desktop-channel
|
|
1143
|
+
* product config (`www.workbuddy.ai/v3/config`, 2026-09-11). The two regions
|
|
1144
|
+
* expose different rosters — the CN list has no `gpt-*`/`gemini-*` entries —
|
|
1145
|
+
* so a global account must never be seeded with the CN list. Like the CN
|
|
1146
|
+
* fallback this is replaced by the live refresh; it only keeps the provider
|
|
1147
|
+
* usable before the first fetch lands. Order and rates mirror the upstream.
|
|
1148
|
+
*/
|
|
1149
|
+
const FALLBACK_WORKBUDDY_MODELS_GLOBAL = [
|
|
1150
|
+
{
|
|
1151
|
+
id: "default-model",
|
|
1152
|
+
name: "Auto",
|
|
1153
|
+
contextWindow: 176e3,
|
|
1154
|
+
maxTokens: 24e3,
|
|
1155
|
+
creditMultiplier: .79
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
id: "fast-model",
|
|
1159
|
+
name: "Fast",
|
|
1160
|
+
contextWindow: 2e5,
|
|
1161
|
+
maxTokens: 32e3,
|
|
1162
|
+
creditMultiplier: .34
|
|
1163
|
+
},
|
|
1164
|
+
{
|
|
1165
|
+
id: "balanced-model",
|
|
1166
|
+
name: "Balanced",
|
|
1167
|
+
contextWindow: 256e3,
|
|
1168
|
+
maxTokens: 32e3,
|
|
1169
|
+
creditMultiplier: .59
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
id: "primary-model",
|
|
1173
|
+
name: "Primary",
|
|
1174
|
+
contextWindow: 272e3,
|
|
1175
|
+
maxTokens: 72e3,
|
|
1176
|
+
creditMultiplier: 3.31
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
id: "deep-model",
|
|
1180
|
+
name: "Deep",
|
|
1181
|
+
contextWindow: 176e3,
|
|
1182
|
+
maxTokens: 24e3,
|
|
1183
|
+
creditMultiplier: 3.33
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
id: "deepseek-v4.1-flash",
|
|
1187
|
+
name: "Deepseek-V4.1-Flash",
|
|
1188
|
+
contextWindow: 1e6,
|
|
1189
|
+
maxTokens: 128e3,
|
|
1190
|
+
creditMultiplier: 0
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
id: "gpt-6-astra",
|
|
1194
|
+
name: "GPT-6-Astra",
|
|
1195
|
+
contextWindow: 1e6,
|
|
1196
|
+
maxTokens: 128e3,
|
|
1197
|
+
creditMultiplier: 6.67
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
id: "hy4-preview",
|
|
1201
|
+
name: "Hy4 preview",
|
|
1202
|
+
contextWindow: 1e6,
|
|
1203
|
+
maxTokens: 64e3,
|
|
1204
|
+
creditMultiplier: 0
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
id: "hy3",
|
|
1208
|
+
name: "Hy3",
|
|
1209
|
+
contextWindow: 192e3,
|
|
1210
|
+
maxTokens: 64e3,
|
|
1211
|
+
creditMultiplier: 0
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
id: "gpt-5.6-sol",
|
|
1215
|
+
name: "GPT-5.6-Sol",
|
|
1216
|
+
contextWindow: 1e6,
|
|
1217
|
+
maxTokens: 128e3,
|
|
1218
|
+
creditMultiplier: 3.47
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
id: "gpt-5.6-terra",
|
|
1222
|
+
name: "GPT-5.6-Terra",
|
|
1223
|
+
contextWindow: 1e6,
|
|
1224
|
+
maxTokens: 128e3,
|
|
1225
|
+
creditMultiplier: 1.39
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
id: "gpt-5.6-luna",
|
|
1229
|
+
name: "GPT-5.6-Luna",
|
|
1230
|
+
contextWindow: 1e6,
|
|
1231
|
+
maxTokens: 128e3,
|
|
1232
|
+
creditMultiplier: .14
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
id: "gpt-5.5",
|
|
1236
|
+
name: "GPT-5.5",
|
|
1237
|
+
contextWindow: 1e6,
|
|
1238
|
+
maxTokens: 128e3,
|
|
1239
|
+
creditMultiplier: 3.31
|
|
1240
|
+
},
|
|
1241
|
+
{
|
|
1242
|
+
id: "gpt-5.4",
|
|
1243
|
+
name: "GPT-5.4",
|
|
1244
|
+
contextWindow: 272e3,
|
|
1245
|
+
maxTokens: 72e3,
|
|
1246
|
+
creditMultiplier: 1.65
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
id: "gpt-5.3-codex",
|
|
1250
|
+
name: "GPT-5.3-Codex",
|
|
1251
|
+
contextWindow: 272e3,
|
|
1252
|
+
maxTokens: 72e3,
|
|
1253
|
+
creditMultiplier: 1.25
|
|
1254
|
+
},
|
|
1255
|
+
{
|
|
1256
|
+
id: "gemini-3.5-flash",
|
|
1257
|
+
name: "Gemini-3.5-Flash",
|
|
1258
|
+
contextWindow: 1e6,
|
|
1259
|
+
maxTokens: 65536,
|
|
1260
|
+
creditMultiplier: .99
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
id: "glm-5.3",
|
|
1264
|
+
name: "GLM-5.3",
|
|
1265
|
+
contextWindow: 1e6,
|
|
1266
|
+
maxTokens: 48e3,
|
|
1267
|
+
creditMultiplier: .79
|
|
1268
|
+
},
|
|
1269
|
+
{
|
|
1270
|
+
id: "glm-5.2",
|
|
1271
|
+
name: "GLM-5.2",
|
|
1272
|
+
contextWindow: 1e6,
|
|
1273
|
+
maxTokens: 48e3,
|
|
1274
|
+
creditMultiplier: .79
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
id: "kimi-k3",
|
|
1278
|
+
name: "Kimi-K3",
|
|
1279
|
+
contextWindow: 1e6,
|
|
1280
|
+
maxTokens: 32e3,
|
|
1281
|
+
creditMultiplier: 1.62
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
id: "kimi-k2.6",
|
|
1285
|
+
name: "Kimi-K2.6",
|
|
1286
|
+
contextWindow: 256e3,
|
|
1287
|
+
maxTokens: 32e3,
|
|
1288
|
+
creditMultiplier: .52
|
|
992
1289
|
}
|
|
993
|
-
|
|
994
|
-
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
995
|
-
return models;
|
|
996
|
-
}
|
|
1290
|
+
];
|
|
997
1291
|
/**
|
|
998
|
-
*
|
|
999
|
-
* the
|
|
1292
|
+
* Static fallback directory for a region. Each region keeps its own model
|
|
1293
|
+
* slot in settings; the fallback must match the region so an account never
|
|
1294
|
+
* shows the other region's roster.
|
|
1000
1295
|
*/
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
ok: false,
|
|
1018
|
-
status: 0,
|
|
1019
|
-
kind: "server",
|
|
1020
|
-
message: `transport error: ${String(error)}`
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
if (response.ok) return {
|
|
1024
|
-
ok: true,
|
|
1025
|
-
response
|
|
1026
|
-
};
|
|
1027
|
-
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
1028
|
-
return {
|
|
1029
|
-
ok: false,
|
|
1030
|
-
status: response.status,
|
|
1031
|
-
kind: classifyUpstreamError(response.status, text),
|
|
1032
|
-
message: text
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
1036
|
-
async refreshToken(credential) {
|
|
1037
|
-
const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
1038
|
-
method: "POST",
|
|
1039
|
-
headers: refreshHeaders(credential),
|
|
1040
|
-
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1041
|
-
});
|
|
1042
|
-
const envelope = await readEnvelope(response);
|
|
1043
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1044
|
-
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1045
|
-
const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
|
|
1046
|
-
if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
|
|
1047
|
-
const outcome = { accessToken };
|
|
1048
|
-
if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
|
|
1049
|
-
if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
|
|
1050
|
-
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
1051
|
-
return outcome;
|
|
1052
|
-
}
|
|
1296
|
+
function fallbackModelsFor(region) {
|
|
1297
|
+
return region === "global" ? FALLBACK_WORKBUDDY_MODELS_GLOBAL : FALLBACK_WORKBUDDY_MODELS;
|
|
1298
|
+
}
|
|
1299
|
+
/** Apply the saved local DSH budget; models above 200K default to 200K. */
|
|
1300
|
+
function applyContextBudgets(catalog, budgets = {}) {
|
|
1301
|
+
return catalog.map((model) => ({
|
|
1302
|
+
...model,
|
|
1303
|
+
contextWindow: model.contextWindow > 2e5 ? Math.min(model.contextWindow, budgets[model.id] ?? 2e5) : model.contextWindow
|
|
1304
|
+
}));
|
|
1305
|
+
}
|
|
1306
|
+
function deriveCatalog(catalog, enabled, budgets = {}) {
|
|
1307
|
+
return applyContextBudgets(enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id)), budgets);
|
|
1308
|
+
}
|
|
1309
|
+
/** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
|
|
1310
|
+
var WorkBuddyCatalog = class {
|
|
1311
|
+
models;
|
|
1053
1312
|
/**
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
* CN answers `/v2/enterprises/personal/models`, while the global gateway's
|
|
1058
|
-
* personal-models path returns HTTP 500 and the CLI channel's `/v3/config`
|
|
1059
|
-
* omits chat-usable models — so global reads `/v3/config` as the desktop
|
|
1060
|
-
* channel (see {@link DESKTOP_UA}). Both documents share the entry shape, so
|
|
1061
|
-
* one parser serves them. No user-side toggle is involved: the region comes
|
|
1062
|
-
* from the credential's `domain`.
|
|
1313
|
+
* @param region Seeds the static fallback for this region; each region's
|
|
1314
|
+
* provider must never serve the other region's roster before its first
|
|
1315
|
+
* live refresh lands.
|
|
1063
1316
|
*/
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (regionOf(credential.domain) === "global") {
|
|
1067
|
-
const response = await fetch(`${GLOBAL_BASE}${GLOBAL_CONFIG_PATH}`, {
|
|
1068
|
-
headers: {
|
|
1069
|
-
"Authorization": `Bearer ${credential.accessToken}`,
|
|
1070
|
-
"Accept": "application/json",
|
|
1071
|
-
...credential.uid === "" ? {} : { "X-User-Id": credential.uid },
|
|
1072
|
-
...credential.domain === "" ? {} : { "X-Domain": credential.domain },
|
|
1073
|
-
"X-Product": "SaaS",
|
|
1074
|
-
"X-Requested-With": "XMLHttpRequest",
|
|
1075
|
-
"Connection": "close",
|
|
1076
|
-
"User-Agent": DESKTOP_UA
|
|
1077
|
-
},
|
|
1078
|
-
signal: timeout
|
|
1079
|
-
});
|
|
1080
|
-
const envelope = await readEnvelope(response);
|
|
1081
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1082
|
-
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1083
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
1084
|
-
}
|
|
1085
|
-
const response = await fetch(`${chatBase(credential)}${MODELS_CATALOG_PATH}`, {
|
|
1086
|
-
headers: {
|
|
1087
|
-
"Authorization": `Bearer ${credential.accessToken}`,
|
|
1088
|
-
"Accept": "application/json",
|
|
1089
|
-
"Origin": originReferer(credential),
|
|
1090
|
-
"Referer": `${originReferer(credential)}/`,
|
|
1091
|
-
"User-Agent": CLIENT_UA
|
|
1092
|
-
},
|
|
1093
|
-
signal: timeout
|
|
1094
|
-
});
|
|
1095
|
-
const envelope = await readEnvelope(response);
|
|
1096
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1097
|
-
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1098
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
1099
|
-
}
|
|
1100
|
-
/** Query today's check-in status without changing account state. */
|
|
1101
|
-
async fetchCheckinStatus(credential) {
|
|
1102
|
-
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
|
|
1103
|
-
method: "POST",
|
|
1104
|
-
headers: billingHeaders(credential),
|
|
1105
|
-
body: "{}",
|
|
1106
|
-
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1107
|
-
});
|
|
1108
|
-
const envelope = await readEnvelope(response);
|
|
1109
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1110
|
-
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1111
|
-
const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
1112
|
-
return {
|
|
1113
|
-
active: data["active"] === true,
|
|
1114
|
-
todayCheckedIn: data["today_checked_in"] === true,
|
|
1115
|
-
streakDays: numberField("streak_days"),
|
|
1116
|
-
dailyCredit: numberField("daily_credit"),
|
|
1117
|
-
todayCredit: numberField("today_credit"),
|
|
1118
|
-
isStreakDay: data["is_streak_day"] === true,
|
|
1119
|
-
nextStreakDay: numberField("next_streak_day"),
|
|
1120
|
-
streakBonusDays: numberField("streak_bonus_days"),
|
|
1121
|
-
streakBonusCredit: numberField("streak_bonus_credit"),
|
|
1122
|
-
...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
|
|
1123
|
-
};
|
|
1317
|
+
constructor(region = "cn") {
|
|
1318
|
+
this.models = fallbackModelsFor(region);
|
|
1124
1319
|
}
|
|
1125
|
-
/**
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
method: "POST",
|
|
1129
|
-
headers: billingHeaders(credential),
|
|
1130
|
-
body: "{}",
|
|
1131
|
-
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1132
|
-
});
|
|
1133
|
-
const envelope = await readEnvelope(response);
|
|
1134
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1135
|
-
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1136
|
-
const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
|
|
1137
|
-
return {
|
|
1138
|
-
credit: numberField("credit"),
|
|
1139
|
-
streakDays: numberField("streak_days"),
|
|
1140
|
-
isStreakDay: data["is_streak_day"] === true
|
|
1141
|
-
};
|
|
1320
|
+
/** Current entries; the fallback list until the upstream answer lands. */
|
|
1321
|
+
current() {
|
|
1322
|
+
return this.models;
|
|
1142
1323
|
}
|
|
1143
|
-
/**
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
* dates it needs.
|
|
1148
|
-
*/
|
|
1149
|
-
async fetchCredits(credential) {
|
|
1150
|
-
const now = /* @__PURE__ */ new Date();
|
|
1151
|
-
const format = (date) => [
|
|
1152
|
-
date.getFullYear().toString().padStart(4, "0"),
|
|
1153
|
-
(date.getMonth() + 1).toString().padStart(2, "0"),
|
|
1154
|
-
date.getDate().toString().padStart(2, "0")
|
|
1155
|
-
].join("-") + " " + [
|
|
1156
|
-
date.getHours().toString().padStart(2, "0"),
|
|
1157
|
-
date.getMinutes().toString().padStart(2, "0"),
|
|
1158
|
-
date.getSeconds().toString().padStart(2, "0")
|
|
1159
|
-
].join(":");
|
|
1160
|
-
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
1161
|
-
method: "POST",
|
|
1162
|
-
headers: billingHeaders(credential),
|
|
1163
|
-
body: JSON.stringify({
|
|
1164
|
-
PageNumber: 1,
|
|
1165
|
-
PageSize: 100,
|
|
1166
|
-
ProductCode: "p_tcaca",
|
|
1167
|
-
Status: [0, 3],
|
|
1168
|
-
PackageEndTimeRangeBegin: format(now),
|
|
1169
|
-
PackageEndTimeRangeEnd: format(new Date(now.getTime() + 3185136e6))
|
|
1170
|
-
}),
|
|
1171
|
-
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1172
|
-
});
|
|
1173
|
-
const envelope = await readEnvelope(response);
|
|
1174
|
-
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1175
|
-
const responseWrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1176
|
-
const data = typeof responseWrapper["Response"] === "object" && responseWrapper["Response"] !== null ? responseWrapper["Response"] : {};
|
|
1177
|
-
const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
|
|
1178
|
-
const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
|
|
1179
|
-
let total = 0;
|
|
1180
|
-
let nearestExpiryMs;
|
|
1181
|
-
let expiringSoon = 0;
|
|
1182
|
-
const SOON_MS = 2592e5;
|
|
1183
|
-
const parseDate = (raw) => {
|
|
1184
|
-
if (typeof raw === "number" && raw > 0xe8d4a51000) return raw;
|
|
1185
|
-
if (typeof raw === "string" && raw !== "") {
|
|
1186
|
-
const parsed = Date.parse(raw);
|
|
1187
|
-
if (!Number.isNaN(parsed)) return parsed;
|
|
1188
|
-
}
|
|
1189
|
-
};
|
|
1190
|
-
const packages = [];
|
|
1191
|
-
for (const raw of rawAccounts) {
|
|
1192
|
-
if (typeof raw !== "object" || raw === null) continue;
|
|
1193
|
-
const account = raw;
|
|
1194
|
-
const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
1195
|
-
const monthly = numberField("CapacityType") === 4;
|
|
1196
|
-
const size = monthly ? numberField("CycleCapacitySize") : numberField("CapacitySize");
|
|
1197
|
-
const remain = monthly ? numberField("CycleCapacityRemain") : numberField("CapacityRemain");
|
|
1198
|
-
const cappedRemain = remain < 0 ? 0 : remain;
|
|
1199
|
-
const cycleEndMs = parseDate(account["CycleEndTime"]);
|
|
1200
|
-
const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
|
|
1201
|
-
const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
|
|
1202
|
-
if (!monthly && (cappedRemain <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
|
|
1203
|
-
total += cappedRemain;
|
|
1204
|
-
const expiryMs = expiresAtMs;
|
|
1205
|
-
if (expiryMs !== void 0) {
|
|
1206
|
-
if (nearestExpiryMs === void 0 || expiryMs < nearestExpiryMs) nearestExpiryMs = expiryMs;
|
|
1207
|
-
if (expiryMs - Date.now() <= SOON_MS) expiringSoon += cappedRemain;
|
|
1208
|
-
}
|
|
1209
|
-
packages.push({
|
|
1210
|
-
packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
|
|
1211
|
-
remain: cappedRemain,
|
|
1212
|
-
size,
|
|
1213
|
-
monthly,
|
|
1214
|
-
...refreshAtMs === void 0 ? {} : { refreshAtMs },
|
|
1215
|
-
...expiresAtMs === void 0 ? {} : { expiresAtMs }
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
return {
|
|
1219
|
-
total,
|
|
1220
|
-
packages,
|
|
1221
|
-
expiringSoon,
|
|
1222
|
-
...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
|
|
1223
|
-
};
|
|
1324
|
+
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
1325
|
+
set(models) {
|
|
1326
|
+
if (models.length === 0) throw new Error("workbuddy model catalog cannot be empty");
|
|
1327
|
+
this.models = models.map((model) => ({ ...model }));
|
|
1224
1328
|
}
|
|
1225
1329
|
};
|
|
1226
1330
|
//#endregion
|
|
1227
1331
|
//#region src/version.ts
|
|
1228
1332
|
/** The npm package version this build was produced from. */
|
|
1229
|
-
const WORKBUDDY_CONNECT_VERSION = "
|
|
1333
|
+
const WORKBUDDY_CONNECT_VERSION = "2.0.1";
|
|
1230
1334
|
//#endregion
|
|
1231
1335
|
//#region src/host-heartbeat.ts
|
|
1232
1336
|
/**
|
|
@@ -1332,4 +1436,4 @@ async function clearHostHeartbeat() {
|
|
|
1332
1436
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
1333
1437
|
}
|
|
1334
1438
|
//#endregion
|
|
1335
|
-
export {
|
|
1439
|
+
export { prepareChatBody as A, workbuddyAccountId as C, parseCreditMultiplier as D, classifyUpstreamError as E, parseReasoning as O, parseWorkBuddyAuth as S, WorkBuddyUpstreamClient as T, authFileName as _, readHostHeartbeat as a, defaultDesktopAuthPath as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, deriveCatalog as f, WorkBuddyCredentialStore as g, WORKBUDDY_AUTH_FILE_ENV as h, processStartTimeMs as i, regionOf as j, parseUpstreamModel as k, FALLBACK_WORKBUDDY_MODELS as l, WORKBUDDY_AUTH_FILENAME as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, fallbackModelsFor as p, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS_GLOBAL as u, defaultDesktopAuthCandidates as v, workbuddyOwnAuthPath as w, legacyWorkbuddyOwnAuthPath as x, defaultDesktopAuthDirs as y };
|