dsh-workbuddy-xdpool 0.2.0 → 0.3.0

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/lib/index.js CHANGED
@@ -1,1920 +1,2139 @@
1
- import { dirname, join, resolve } from "node:path";
2
- import z from "@deepseek-ai/schemastery";
3
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
- import { readFile, readdir } from "node:fs/promises";
5
- import { homedir } from "node:os";
6
- import { createProvider } from "@earendil-works/pi-ai";
7
- import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
- import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
- import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
- import { createServer } from "node:http";
11
- import { Readable } from "node:stream";
12
- //#region src/accounts.ts
13
- /**
14
- * Account pool: discovers every WorkBuddy credential snapshot the desktop app
15
- * has left on this machine and hands out one healthy account per request,
16
- * rotating away from any account the upstream has rate-limited.
17
- *
18
- * Discovery is read-only: the desktop app's files are never written. Each
19
- * account is keyed by its billing identity (`uin`, falling back to `uid`), so
20
- * re-logging the same account refreshes in place instead of creating a duplicate.
21
- *
22
- * @module dsh-workbuddy-xdpool/accounts
23
- */
24
- /** Live auth file name the WorkBuddy desktop app writes. */
25
- const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
26
- /** Snapshot files left behind by previous logins share this prefix. */
27
- const SNAPSHOT_PREFIX = "workbuddy-desktop.";
28
- /** Env override for the auth file or its directory. */
29
- const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
30
- function nonEmptyEnv(value) {
31
- return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
32
- }
33
- /**
34
- * Platform-default directories holding the desktop app's auth files.
35
- * Windows probes Local before Roaming; a redirected profile still resolves
36
- * through the env location.
37
- */
38
- function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), env = process.env) {
39
- if (platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth")];
40
- if (platform === "win32") {
41
- const local = nonEmptyEnv(env["LOCALAPPDATA"]) ?? join(home, "AppData", "Local");
42
- const roaming = nonEmptyEnv(env["APPDATA"]) ?? join(home, "AppData", "Roaming");
43
- return [join(local, "CodeBuddyExtension", "Data", "Public", "auth"), join(roaming, "CodeBuddyExtension", "Data", "Public", "auth")];
44
- }
45
- if (platform === "linux") {
46
- const config = nonEmptyEnv(env["XDG_CONFIG_HOME"]) ?? join(home, ".config");
47
- return [join(config, "CodeBuddyExtension", "Data", "Public", "auth")];
48
- }
49
- return [];
50
- }
51
- /** Normalize an expiry that may arrive in seconds or milliseconds. */
52
- function expiryToMs(value) {
53
- if (value <= 0) return 0;
54
- return value > 0xe8d4a51000 ? value : value * 1e3;
55
- }
56
- function optionalString(value) {
57
- return typeof value === "string" && value !== "" ? value : void 0;
58
- }
59
- /**
60
- * Parse a WorkBuddy auth document. Accepts the nested desktop shape
61
- * `{"auth":{...},"account":{...}}` and the flat panel shape; returns undefined
62
- * when there is no usable access token.
63
- */
64
- function parseWorkBuddyAuth(text, sourcePath) {
65
- let parsed;
66
- try {
67
- parsed = JSON.parse(text);
68
- } catch {
69
- return;
70
- }
71
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
72
- const document = parsed;
73
- let auth;
74
- let identity;
75
- if (typeof document["auth"] === "object" && document["auth"] !== null) {
76
- auth = document["auth"];
77
- identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
78
- } else {
79
- auth = document;
80
- identity = document;
81
- }
82
- const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
83
- if (accessToken === "") return void 0;
84
- const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
85
- if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
86
- return {
87
- accessToken,
88
- refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
89
- expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
90
- ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
91
- ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
92
- ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
93
- ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
94
- ...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
95
- domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
96
- sourcePath
97
- };
98
- }
99
- /**
100
- * Stable account id. `uin` is the billing identity the upstream keys on and
101
- * survives re-login; `uid` is the fallback.
102
- */
103
- function workbuddyAccountId(credential) {
104
- const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
105
- return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
106
- }
107
- /** Human label; distinguishes same-nickname accounts by uid prefix. */
108
- function accountLabel(credential) {
109
- const name = credential.nickname ?? "WorkBuddy";
110
- const discriminator = (credential.uid ?? credential.uin ?? "").slice(0, 8);
111
- return discriminator === "" ? name : `${name}#${discriminator}`;
112
- }
113
- /** List the auth files in one directory: the live file plus every snapshot. */
114
- async function authFilesIn(dir) {
115
- let entries;
116
- try {
117
- entries = await readdir(dir);
118
- } catch {
119
- return [];
120
- }
121
- const files = entries.filter((name) => {
122
- if (name === "workbuddy-desktop.info") return true;
123
- return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
124
- });
125
- files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
126
- return files.map((name) => join(dir, name));
127
- }
128
- async function readCredential(path) {
129
- try {
130
- return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
131
- } catch {
132
- return;
133
- }
134
- }
135
- /** Every directory the pool should scan, in probe order. */
136
- function candidateAuthDirs(env = process.env) {
137
- const dirs = [];
138
- const override = nonEmptyEnv(env[WORKBUDDY_AUTH_FILE_ENV]);
139
- if (override !== void 0) dirs.push(override.toLowerCase().endsWith(".info") ? resolve(override, "..") : override);
140
- dirs.push(...defaultDesktopAuthDirs(process.env["DSH_TEST_PLATFORM"]));
141
- return dirs;
142
- }
143
- /**
144
- * Read-only pool of every discovered WorkBuddy account, with rate-limit
145
- * cooldown and round-robin failover.
146
- */
147
- var WorkBuddyAccountPool = class {
148
- logger;
149
- authDirs;
150
- cooldownMs;
151
- client;
152
- refreshMarginMs;
153
- accounts = [];
154
- cursor = 0;
155
- lastScanAtMs = 0;
156
- preferredId;
157
- refreshInflight = /* @__PURE__ */ new Map();
158
- constructor(options = {}) {
159
- this.logger = options.logger;
160
- this.authDirs = options.authDirs ?? candidateAuthDirs();
161
- this.cooldownMs = options.cooldownMs ?? 6e4;
162
- this.client = options.client;
163
- this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
164
- }
165
- /**
166
- * Re-apply configuration that only affects discovery and cooldown policy,
167
- * without rebuilding the pool. A later `scan()` uses the new auth dirs and
168
- * cooldown window; existing accounts keep their in-memory state.
169
- */
170
- applyConfig(options) {
171
- if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
172
- if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
173
- }
174
- /** Rescan the auth directories and merge newly discovered accounts. */
175
- async scan() {
176
- const found = [];
177
- for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
178
- const credential = await readCredential(file);
179
- if (credential !== void 0) found.push(credential);
180
- }
181
- const byId = /* @__PURE__ */ new Map();
182
- for (const account of this.accounts) byId.set(account.id, account);
183
- for (const credential of found) {
184
- const id = workbuddyAccountId(credential);
185
- const existing = byId.get(id);
186
- if (existing === void 0) {
187
- byId.set(id, {
188
- id,
189
- label: accountLabel(credential),
190
- credential,
191
- cooldownUntilMs: 0,
192
- modelCooldowns: {},
193
- rateLimitHits: 0
194
- });
195
- continue;
196
- }
197
- if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
198
- ...existing,
199
- credential,
200
- label: accountLabel(credential)
201
- });
202
- }
203
- this.accounts = [...byId.values()];
204
- this.lastScanAtMs = Date.now();
205
- return this.accounts;
206
- }
207
- /** All accounts, cooldown state included. */
208
- list() {
209
- return this.accounts;
210
- }
211
- /**
212
- * Accounts currently eligible to serve a request.
213
- *
214
- * With a `modelId`, an account is eligible when it is not account-wide cooled
215
- * AND that model is not cooling on it — so a 429 on `hy4-preview` only keeps
216
- * that model out while `hy3` on the same account stays usable. Without a
217
- * model id the legacy account-wide check applies (callers that cannot name a
218
- * model, e.g. CLI diagnostics).
219
- */
220
- available(now, modelId) {
221
- return this.accounts.filter((account) => {
222
- if (account.cooldownUntilMs > now) return false;
223
- if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
224
- return true;
225
- });
226
- }
227
- /**
228
- * Pick the next usable account for an optional model. Scans on first use,
229
- * and rescans when every known account is cooling down — a fresh desktop
230
- * login is the usual way out of an exhausted pool. A preferred
231
- * (user-selected) account that is healthy is tried first; otherwise the
232
- * cursor round-robins so consecutive requests spread across accounts and a
233
- * still-cooling preferred account is skipped.
234
- */
235
- async acquire(modelId) {
236
- if (this.accounts.length === 0) await this.scan();
237
- let pool = this.available(Date.now(), modelId);
238
- if (pool.length === 0) {
239
- await this.scan();
240
- pool = this.available(Date.now(), modelId);
241
- }
242
- if (pool.length === 0) return void 0;
243
- let start = this.cursor % pool.length;
244
- if (this.preferredId !== void 0) {
245
- const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
246
- if (preferredIndex !== -1) start = preferredIndex;
247
- }
248
- for (let step = 0; step < pool.length; step += 1) {
249
- const account = pool[(start + step) % pool.length];
250
- if (account === void 0) continue;
251
- await this.ensureFresh(account);
252
- this.cursor = (start + step + 1) % pool.length;
253
- return account;
254
- }
255
- return pool[0];
256
- }
257
- /** Pin the account the plugin card should prefer; tokens stay out of settings. */
258
- prefer(accountId) {
259
- this.preferredId = accountId;
260
- }
261
- /** Best-effort refresh of one account after a session-dead upstream answer. */
262
- async refreshAccount(accountId) {
263
- const account = this.accounts.find((item) => item.id === accountId);
264
- if (account === void 0) return;
265
- await this.ensureFresh(account);
266
- }
267
- /**
268
- * Refresh the account's access token when it is within the margin (or already
269
- * expired), in-flight de-duped per account. A failed refresh keeps the
270
- * existing token when it has not yet expired, so an unreachable refresh
271
- * endpoint never takes down a working session.
272
- */
273
- async ensureFresh(account) {
274
- if (this.client === void 0) return;
275
- const credential = account.credential;
276
- if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
277
- const existing = this.refreshInflight.get(account.id);
278
- if (existing !== void 0) {
279
- await existing;
280
- return;
281
- }
282
- const run = (async () => {
283
- if (credential.refreshToken === "") {
284
- if (credential.expiresAtMs > Date.now() + 3e4) return;
285
- this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
286
- return;
287
- }
288
- try {
289
- const outcome = await this.client.refreshToken(credential);
290
- account.credential = {
291
- ...credential,
292
- accessToken: outcome.accessToken,
293
- ...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
294
- expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
295
- ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
296
- };
297
- this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
298
- } catch (error) {
299
- if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
300
- else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
301
- }
302
- })();
303
- this.refreshInflight.set(account.id, run);
304
- try {
305
- await run;
306
- } finally {
307
- this.refreshInflight.delete(account.id);
308
- }
309
- }
310
- /**
311
- * Mark an account (or one of its models) rate-limited.
312
- *
313
- * With `modelId`, only that model on the account is cooled — the account's
314
- * other models stay in rotation, matching the upstream's per-model rate
315
- * limit ("可切换其他模型继续使用"). Without a model id the whole account is
316
- * cooled, which callers should reserve for limits that truly span every model.
317
- */
318
- penalize(accountId, resetAtMs, modelId) {
319
- const account = this.accounts.find((item) => item.id === accountId);
320
- if (account === void 0) return;
321
- account.rateLimitHits += 1;
322
- const until = resetAtMs ?? Date.now() + this.cooldownMs;
323
- if (modelId !== void 0 && modelId !== "") {
324
- account.modelCooldowns[modelId] = Math.max(account.modelCooldowns[modelId] ?? 0, until);
325
- this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on model ${modelId}; cooling that model until ${new Date(until).toISOString()}`);
326
- return;
327
- }
328
- account.cooldownUntilMs = Math.max(account.cooldownUntilMs, until);
329
- this.logger?.warn(`dsh-workbuddy-xdpool: account ${account.label} rate-limited; cooling until ${new Date(until).toISOString()}`);
330
- }
331
- /** Clear all cooldowns (account-wide and per-model), e.g. from a reset command. */
332
- resetCooldowns() {
333
- for (const account of this.accounts) {
334
- account.cooldownUntilMs = 0;
335
- account.modelCooldowns = {};
336
- account.rateLimitHits = 0;
337
- }
338
- }
339
- /** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
340
- * the account as a whole stays usable when only one model is limited). */
341
- status() {
342
- const now = Date.now();
343
- return {
344
- count: this.accounts.length,
345
- cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
346
- lastScanAtMs: this.lastScanAtMs
347
- };
348
- }
349
- };
350
- //#endregion
351
- //#region src/catalog.ts
352
- /** Static fallback used before the first live catalog fetch. */
353
- const FALLBACK_WORKBUDDY_MODELS = [
354
- {
355
- id: "glm-5.3",
356
- name: "GLM-5.3",
357
- contextWindow: 2e5,
358
- maxOutputTokens: 128e3,
359
- supportsImages: true
360
- },
361
- {
362
- id: "glm-5.3-flash",
363
- name: "GLM-5.3-Flash",
364
- contextWindow: 2e5,
365
- maxOutputTokens: 128e3,
366
- supportsImages: true
367
- },
368
- {
369
- id: "glm-5.2",
370
- name: "GLM-5.2",
371
- contextWindow: 2e5,
372
- maxOutputTokens: 128e3,
373
- supportsImages: true
374
- },
375
- {
376
- id: "glm-5.1",
377
- name: "GLM-5.1",
378
- contextWindow: 2e5,
379
- maxOutputTokens: 128e3,
380
- supportsImages: false
381
- },
382
- {
383
- id: "deepseek-v4-pro",
384
- name: "DeepSeek-V4-Pro",
385
- contextWindow: 2e5,
386
- maxOutputTokens: 128e3,
387
- supportsImages: true
388
- },
389
- {
390
- id: "deepseek-v4-flash",
391
- name: "DeepSeek-V4-Flash",
392
- contextWindow: 2e5,
393
- maxOutputTokens: 128e3,
394
- supportsImages: true
395
- },
396
- {
397
- id: "kimi-k3",
398
- name: "Kimi-K3",
399
- contextWindow: 2e5,
400
- maxOutputTokens: 128e3,
401
- supportsImages: true
402
- },
403
- {
404
- id: "minimax-m3",
405
- name: "MiniMax-M3",
406
- contextWindow: 2e5,
407
- maxOutputTokens: 128e3,
408
- supportsImages: true
409
- },
410
- {
411
- id: "hy3",
412
- name: "Hy3",
413
- contextWindow: 2e5,
414
- maxOutputTokens: 128e3,
415
- supportsImages: true
416
- },
417
- {
418
- id: "hy4-preview",
419
- name: "Hy4-Preview",
420
- contextWindow: 1e6,
421
- maxOutputTokens: 128e3,
422
- supportsImages: true
423
- }
424
- ];
425
- /** Live catalog with a static fallback behind it. */
426
- var WorkBuddyCatalog = class {
427
- models = FALLBACK_WORKBUDDY_MODELS;
428
- listeners = /* @__PURE__ */ new Set();
429
- current() {
430
- return this.models;
431
- }
432
- /** Replace the catalog and notify the adapter to rebuild its model list. */
433
- update(models) {
434
- if (models.length === 0) return;
435
- this.models = models;
436
- for (const listener of this.listeners) listener();
437
- }
438
- /** Restore the static fallback, e.g. when the upstream stops answering. */
439
- reset() {
440
- this.models = FALLBACK_WORKBUDDY_MODELS;
441
- for (const listener of this.listeners) listener();
442
- }
443
- onChange(listener) {
444
- this.listeners.add(listener);
445
- return () => this.listeners.delete(listener);
446
- }
447
- find(id) {
448
- return this.models.find((model) => model.id === id);
449
- }
450
- /** Replace the catalog from the live upstream list; keeps the fallback if empty. */
451
- updateFromUpstream(models) {
452
- this.update(catalogFromUpstream(models));
453
- }
454
- };
455
- /** Convert one upstream catalog entry into the plugin's model-info shape. */
456
- function toModelInfo(model) {
457
- return {
458
- id: model.id,
459
- name: model.name,
460
- contextWindow: model.contextWindow,
461
- maxOutputTokens: model.maxTokens,
462
- supportsImages: model.multimodal ?? false,
463
- ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
464
- ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
465
- };
466
- }
467
- /** Map the live upstream list, falling back to the static list when empty. */
468
- function catalogFromUpstream(models) {
469
- if (models.length === 0) return FALLBACK_WORKBUDDY_MODELS;
470
- return models.map(toModelInfo);
471
- }
472
- //#endregion
473
- //#region src/adapter.ts
474
- /**
475
- * The `workbuddy-xdpool` pi-ai provider: one loopback-backed adapter registered
476
- * into the Harness LLM seam, assembled from public `dsh-llm-pi-ai` extension
477
- * points. Every model points at the shim; account rotation stays inside it.
478
- *
479
- * Assembly (createProvider + openAICompletionsApi + inert auth plane + the
480
- * shim's in-process secret as apiKey) follows corrinehu/dsh-workbuddy-connect
481
- * (MIT, Copyright (c) 2026 Corrine Hu) and dingminhua/dsh-connect-workbuddy
482
- * (MIT), both designed and validated against this host.
483
- *
484
- * @module dsh-workbuddy-xdpool/adapter
485
- */
486
- /** Provider route this bundle owns. */
487
- const WORKBUDDY_POOL_PROVIDER = "workbuddy-xdpool";
488
- /** Provider idle ceiling while one stream read is outstanding. */
489
- const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 3e5;
490
- /** Image-request budgets at the dsh-llm-pi-ai defaults. */
491
- const REQUEST_IMAGE_BUDGETS = {
492
- maxRequestImageBytes: 20971520,
493
- requestImagePixelBudget: 4194304,
494
- requestImageMaxBytes: 1048576
495
- };
496
- /**
497
- * Inert pi-ai auth plane. The route authenticates only through the shim shared
498
- * secret resolved per request, so pi-ai's own credential lifecycle must never
499
- * manufacture a credential for it.
500
- */
501
- const INERT_AUTH = {
502
- credentials: {
503
- async read() {},
504
- async list() {
505
- return [];
506
- },
507
- async modify() {
508
- throw new Error("dsh-workbuddy-xdpool: this route has no pi-ai credential lifecycle");
509
- },
510
- async delete() {}
511
- },
512
- authContext: {
513
- async env() {},
514
- async fileExists() {
515
- return false;
516
- }
517
- }
518
- };
519
- /** No per-token pricing is knowable for a subscription quota; report zero. */
520
- const NO_COST = {
521
- input: 0,
522
- output: 0,
523
- cacheRead: 0,
524
- cacheWrite: 0
525
- };
526
- /** pi-ai input modalities: images only when the catalog advertises them. */
527
- function modelInput(info) {
528
- return info.supportsImages ? ["text", "image"] : ["text"];
529
- }
530
- /** Map only levels the catalog advertises; undeclared DSH levels stay off. */
531
- function thinkingLevelMap(info) {
532
- const efforts = info.supportedEfforts;
533
- if (efforts === void 0 || efforts.length === 0) return void 0;
534
- const levels = [
535
- "minimal",
536
- "low",
537
- "medium",
538
- "high",
539
- "xhigh",
540
- "max"
541
- ];
542
- const map = {};
543
- for (const level of levels) map[level] = efforts.includes(level) ? level : null;
544
- map["off"] = null;
545
- return map;
546
- }
547
- /** Middle-dot separator: unambiguous between the model's own hyphens and the
548
- * rate/badge suffix. Matches the LaoDing family convention. */
549
- const DISPLAY_SEPARATOR = " · ";
550
- /** Map an upstream tag code to the localized promo label shown next to the
551
- * credit rate in the model picker. Both `free` and `limited-free` collapse to
552
- * the same short label so the dropdown row stays scannable. */
553
- const TAG_LABEL = {
554
- "free": "限时免费",
555
- "limited-free": "限时免费",
556
- "night-discount": "夜间折扣"
557
- };
558
- /** Resolve the display suffix (`xN.NN`, promo badges) for one catalog row.
559
- * Returns `undefined` when there's nothing to show — the name is left alone
560
- * so we don't tack a trailing separator on a plain model. */
561
- function displaySuffix(info) {
562
- const parts = [];
563
- if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
564
- for (const tag of info.tags ?? []) {
565
- const label = TAG_LABEL[tag];
566
- if (label !== void 0 && !parts.includes(label)) parts.push(label);
567
- }
568
- return parts.length === 0 ? void 0 : parts.join(DISPLAY_SEPARATOR);
569
- }
570
- /** Apply the catalog's rate + promo badges to one model's display name.
571
- * Display-only: the wire request is built from `model.id`, so renaming here
572
- * cannot affect routing, token, or upstream accounting. DSH 0.1.2's composer
573
- * (`ModelSelect`) renders `model.name` only, which is why the rate and
574
- * badges ride the name rather than a separate description column. */
575
- function withCatalogDisplay(name, info) {
576
- const suffix = displaySuffix(info);
577
- return suffix === void 0 ? name : `${name}${DISPLAY_SEPARATOR}${suffix}`;
578
- }
579
- /** Build one pi-ai model descriptor pointing at the loopback shim. */
580
- function toPiModel(info, baseUrl, providerId) {
581
- const map = thinkingLevelMap(info);
582
- return {
583
- id: info.id,
584
- name: withCatalogDisplay(info.name, info),
585
- api: "openai-completions",
586
- provider: providerId,
587
- baseUrl,
588
- input: modelInput(info),
589
- cost: NO_COST,
590
- contextWindow: info.contextWindow,
591
- maxTokens: info.maxOutputTokens,
592
- reasoning: map !== void 0,
593
- ...map === void 0 ? {} : { thinkingLevelMap: map },
594
- compat: { supportsReasoningEffort: map !== void 0 }
595
- };
596
- }
597
- /**
598
- * Assemble the adapter. `getModels` re-reads the live catalog, and every
599
- * model's `baseUrl` is re-resolved per read so the shim's ephemeral port
600
- * applies from the first snapshot after startup. Call only after `shim.ready`.
601
- */
602
- function createWorkBuddyAdapter(options) {
603
- const { shim, catalog } = options;
604
- const providerId = options.providerId ?? "workbuddy-xdpool";
605
- const displayName = options.displayName ?? "WorkBuddy XD Pool";
606
- const buildModels = () => {
607
- const baseUrl = `${shim.baseUrl()}/v1`;
608
- return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
609
- };
610
- const provider = {
611
- ...createProvider({
612
- id: providerId,
613
- name: displayName,
614
- auth: { apiKey: {
615
- name: "WorkBuddy XD Pool loopback secret",
616
- async resolve({ credential }) {
617
- const apiKey = credential?.key;
618
- return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
619
- auth: { apiKey },
620
- source: "WorkBuddy XD Pool"
621
- };
622
- }
623
- } },
624
- models: buildModels(),
625
- api: openAICompletionsApi()
626
- }),
627
- getModels: () => buildModels()
628
- };
629
- const profile = {
630
- provider: providerId,
631
- displayName,
632
- streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
633
- retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
634
- configuredMaxTokens: /* @__PURE__ */ new Map(),
635
- ...REQUEST_IMAGE_BUDGETS,
636
- piProvider: provider
637
- };
638
- let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
639
- return {
640
- providerId,
641
- displayName,
642
- adapter: new PiAiAdapter({
643
- profiles: () => profiles,
644
- auth: INERT_AUTH,
645
- resolveApiKey: async () => shim.token()
646
- }),
647
- buildModels,
648
- invalidate: () => {
649
- profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
650
- }
651
- };
652
- }
653
- //#endregion
654
- //#region src/upstream.ts
655
- /** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
656
- const CN_CHAT_BASE = "https://copilot.tencent.com";
657
- /** Billing/console base — `www.codebuddy.cn` is the billing origin. */
658
- const CN_BILLING_BASE = "https://www.codebuddy.cn";
659
- /** Global base for `workbuddy.ai` logins. */
660
- const GLOBAL_BASE = "https://www.workbuddy.ai";
661
- /** Client UA the desktop CLI uses. */
662
- const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
663
- const JSON_TIMEOUT_MS = 3e4;
664
- const ERROR_BODY_LIMIT = 4096;
665
- /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
666
- const HARD_CREDIT_MARKERS = [
667
- "insufficient credit",
668
- "no credit",
669
- "credit exhausted",
670
- "out of credit",
671
- "quota exceeded",
672
- "quota exhaust",
673
- "payment required",
674
- "credit not enough",
675
- "not enough credit",
676
- "积分不足",
677
- "额度不足",
678
- "余额不足",
679
- "积分用完",
680
- "额度用尽",
681
- "没有积分"
682
- ];
683
- /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
684
- const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
685
- /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
686
- function regionOf(domain) {
687
- const lowered = domain.trim().toLowerCase();
688
- if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
689
- return "cn";
690
- }
691
- function chatBase(credential) {
692
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
693
- }
694
- function billingBase(credential) {
695
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
696
- }
697
- function originReferer(credential) {
698
- return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
699
- }
700
- /** Headers every upstream request shares. */
701
- function commonHeaders(credential) {
702
- return {
703
- "Accept": "application/json, text/plain, */*",
704
- "X-Requested-With": "XMLHttpRequest",
705
- "Origin": originReferer(credential),
706
- "Referer": `${originReferer(credential)}/`,
707
- "User-Agent": CLIENT_UA
708
- };
709
- }
710
- /** Chat request headers, including the X-No-* conventions the official CLI uses. */
711
- function chatHeaders(credential) {
712
- return {
713
- ...commonHeaders(credential),
714
- "Content-Type": "application/json",
715
- "Authorization": `Bearer ${credential.accessToken}`,
716
- ...credential.uid === "" || credential.uid === void 0 ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
717
- ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
718
- ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
719
- "X-Product": "SaaS"
720
- };
721
- }
722
- /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
723
- function refreshHeaders(credential) {
724
- const headers = {
725
- ...commonHeaders(credential),
726
- "X-Refresh-Token": credential.refreshToken,
727
- "X-Auth-Refresh-Source": "workbuddy"
728
- };
729
- if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
730
- return headers;
731
- }
732
- /** Billing request headers. */
733
- function billingHeaders(credential) {
734
- const headers = {
735
- "Authorization": `Bearer ${credential.accessToken}`,
736
- "Accept": "application/json",
737
- "Content-Type": "application/json"
738
- };
739
- if (credential.uid !== "" && credential.uid !== void 0) headers["X-User-Id"] = credential.uid;
740
- if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
741
- headers["X-Enterprise-Id"] = credential.enterpriseId;
742
- headers["X-Tenant-Id"] = credential.enterpriseId;
743
- }
744
- if (credential.domain !== "") headers["X-Domain"] = credential.domain;
745
- return headers;
746
- }
747
- async function readEnvelope(response) {
748
- const text = await response.text();
749
- let parsed;
750
- try {
751
- parsed = JSON.parse(text);
752
- } catch {
753
- throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
754
- }
755
- if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
756
- const document = parsed;
757
- return {
758
- code: typeof document["code"] === "number" ? document["code"] : 0,
759
- msg: typeof document["msg"] === "string" ? document["msg"] : "",
760
- data: "data" in document ? document["data"] : void 0
761
- };
762
- }
763
- /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
764
- function envelopeError(status, envelope) {
765
- const kind = classifyUpstreamError(status, envelope.msg);
766
- return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
767
- }
768
- /**
769
- * Classify an upstream failure from its HTTP status and body excerpt.
770
- * Body markers win over status, because the upstream reuses 400/200 for
771
- * several distinct conditions.
772
- */
773
- function classifyUpstreamError(status, body) {
774
- if (status === 402) return "hard_credit";
775
- const lower = body.toLowerCase();
776
- for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
777
- for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
778
- if (status === 429) return "soft_rate";
779
- if (body.includes("soft_rate") || body.includes("\"code\":6004") || body.includes("频率限制")) return "soft_rate";
780
- if (status === 404) return "not_found";
781
- if (status >= 500) return "server";
782
- return "client";
783
- }
784
- /**
785
- * Parse the reset time the upstream reports for a rate limit, when present.
786
- * Recognises an epoch-millisecond field and the Chinese-localised sentence
787
- * form, so the pool can resume exactly when the window reopens.
788
- */
789
- function parseRateLimitReset(body) {
790
- const epochMs = /"(?:resetAt|reset_at|resetTime|reset_time)"\s*:\s*(\d{13})/.exec(body);
791
- if (epochMs !== null) return Number(epochMs[1]);
792
- const localized = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2})/.exec(body);
793
- if (localized !== null) {
794
- const parsed = Date.parse(localized[1].replace(" ", "T"));
795
- if (!Number.isNaN(parsed)) return parsed;
796
- }
797
- }
798
- /** Parse the upstream's `credits` string into a multiplier. */
799
- function parseCreditMultiplier(value) {
800
- if (typeof value !== "string") return void 0;
801
- const match = /x\s*([0-9]*\.?[0-9]+)/iu.exec(value);
802
- if (match === null) return void 0;
803
- const parsed = Number(match[1]);
804
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
805
- }
806
- /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
807
- function parseReasoning(value) {
808
- if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
809
- const raw = value;
810
- const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
811
- const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
812
- const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
813
- if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
814
- return {
815
- ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
816
- ...defaultEffort === void 0 ? {} : { defaultEffort },
817
- ...canDisableThinking === void 0 ? {} : { canDisableThinking }
818
- };
819
- }
820
- /** Parse one catalog entry; entries without usable token limits are dropped. */
821
- function parseUpstreamModel(value) {
822
- if (typeof value !== "object" || value === null) return void 0;
823
- const raw = value;
824
- const id = typeof raw["id"] === "string" ? raw["id"] : "";
825
- if (id === "" || raw["disabled"] === true) return void 0;
826
- const input = typeof raw["maxInputTokens"] === "number" ? raw["maxInputTokens"] : 0;
827
- const output = typeof raw["maxOutputTokens"] === "number" ? raw["maxOutputTokens"] : 0;
828
- if (input <= 0 || output <= 0) return void 0;
829
- const name = typeof raw["name"] === "string" && raw["name"] !== "" ? raw["name"] : id;
830
- const descriptionZh = typeof raw["descriptionZh"] === "string" && raw["descriptionZh"] !== "" ? raw["descriptionZh"] : void 0;
831
- const descriptionEn = typeof raw["descriptionEn"] === "string" && raw["descriptionEn"] !== "" ? raw["descriptionEn"] : void 0;
832
- const creditMultiplier = parseCreditMultiplier(raw["credits"]);
833
- const reasoning = parseReasoning(raw["reasoning"]);
834
- const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
835
- return {
836
- id,
837
- name,
838
- contextWindow: input,
839
- maxTokens: output,
840
- ...creditMultiplier === void 0 ? {} : { creditMultiplier },
841
- ...reasoning === void 0 ? {} : { reasoning },
842
- ...descriptionZh === void 0 ? {} : { descriptionZh },
843
- ...descriptionEn === void 0 ? {} : { descriptionEn },
844
- ...supportsToolCall === void 0 ? {} : { supportsToolCall }
845
- };
846
- }
847
- var WorkBuddyUpstreamClient = class {
848
- fetchImpl;
849
- clientVersion;
850
- constructor(options = {}) {
851
- this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
852
- this.clientVersion = options.clientVersion ?? "2.0.4";
853
- }
854
- /**
855
- * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
856
- * force `stream: true` (the upstream rejects non-streaming), convert the
857
- * DSH `developer` role into `system` (upstream rejects `developer` with
858
- * business code 11128), and flatten `tool_choice` into its string form.
859
- */
860
- prepareChatBody(raw) {
861
- let body;
862
- try {
863
- body = JSON.parse(raw);
864
- } catch {
865
- return raw;
866
- }
867
- if (typeof body !== "object" || body === null || Array.isArray(body)) return raw;
868
- const obj = body;
869
- obj["stream"] = true;
870
- delete obj["stream_options"];
871
- if (Array.isArray(obj["messages"])) for (const value of obj["messages"]) {
872
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
873
- const message = value;
874
- if (message["role"] === "developer") message["role"] = "system";
875
- }
876
- const choice = obj["tool_choice"];
877
- if (typeof choice === "string") {
878
- if (choice.trim().toLowerCase() === "none") {
879
- delete obj["tool_choice"];
880
- delete obj["tools"];
881
- delete obj["functions"];
882
- }
883
- } else if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
884
- const wrapped = choice;
885
- const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
886
- if (type === "none") {
887
- delete obj["tool_choice"];
888
- delete obj["tools"];
889
- delete obj["functions"];
890
- } else if (type === "auto" || type === "required") obj["tool_choice"] = type;
891
- else if (type === "function") {
892
- const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
893
- let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
894
- if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
895
- obj["tool_choice"] = name.trim() !== "" ? name.trim() : "auto";
896
- } else delete obj["tool_choice"];
897
- }
898
- return JSON.stringify(obj);
899
- }
900
- /** Forward one chat completion. Never throws for upstream failures. */
901
- async chatStream(credential, prepared, signal) {
902
- let response;
903
- try {
904
- response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
905
- method: "POST",
906
- headers: chatHeaders(credential),
907
- body: prepared,
908
- ...signal === void 0 ? {} : { signal }
909
- });
910
- } catch (error) {
911
- return {
912
- ok: false,
913
- kind: "server",
914
- status: 0,
915
- message: `transport error: ${String(error)}`
916
- };
917
- }
918
- if (response.ok) return {
919
- ok: true,
920
- response
921
- };
922
- const text = (await response.text().catch(() => "")).slice(0, ERROR_BODY_LIMIT);
923
- return {
924
- ok: false,
925
- kind: classifyUpstreamError(response.status, text),
926
- status: response.status,
927
- message: text
928
- };
929
- }
930
- /** POST the token-refresh endpoint; the caller merges the outcome. */
931
- async refreshToken(credential) {
932
- const response = await this.fetchImpl(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
933
- method: "POST",
934
- headers: refreshHeaders(credential),
935
- signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
936
- });
937
- const envelope = await readEnvelope(response);
938
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
939
- const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
940
- const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
941
- if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
942
- const outcome = { accessToken };
943
- if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
944
- if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
945
- if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
946
- return outcome;
947
- }
948
- /** GET the personal model catalog, keeping the `cli` agent's models only. */
949
- async fetchModels(credential, signal) {
950
- const response = await this.fetchImpl(`${chatBase(credential)}/console/enterprises/personal/models`, {
951
- headers: {
952
- "Authorization": `Bearer ${credential.accessToken}`,
953
- "Accept": "application/json",
954
- "Origin": originReferer(credential),
955
- "Referer": `${originReferer(credential)}/`,
956
- "User-Agent": CLIENT_UA
957
- },
958
- ...signal === void 0 ? {} : { signal }
959
- });
960
- const envelope = await readEnvelope(response);
961
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
962
- const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
963
- const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
964
- const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
965
- let cliIds;
966
- for (const agent of agents) if (typeof agent === "object" && agent !== null) {
967
- const wrapped = agent;
968
- if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
969
- cliIds = wrapped["models"].filter((id) => typeof id === "string");
970
- break;
971
- }
972
- }
973
- const byId = /* @__PURE__ */ new Map();
974
- for (const model of rawModels) {
975
- const parsed = parseUpstreamModel(model);
976
- if (parsed !== void 0) byId.set(parsed.id, parsed);
977
- }
978
- const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
979
- if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
980
- return models;
981
- }
982
- /** Read-only credits query, aggregated by package. Does not consume credits. */
983
- async fetchCredits(credential) {
984
- const now = /* @__PURE__ */ new Date();
985
- const fmt = (date) => {
986
- const p = (n) => n.toString().padStart(2, "0");
987
- return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
988
- };
989
- const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
990
- method: "POST",
991
- headers: billingHeaders(credential),
992
- body: JSON.stringify({
993
- PageNumber: 1,
994
- PageSize: 100,
995
- ProductCode: "p_tcaca",
996
- Status: [0, 3],
997
- PackageEndTimeRangeBegin: fmt(now),
998
- PackageEndTimeRangeEnd: fmt(new Date(now.getTime() + 3185136e6))
999
- }),
1000
- signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1001
- });
1002
- const envelope = await readEnvelope(response);
1003
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1004
- const wrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1005
- const data = typeof wrapper["Response"] === "object" && wrapper["Response"] !== null ? wrapper["Response"] : {};
1006
- const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
1007
- const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
1008
- let total = 0;
1009
- let nearestExpiryMs;
1010
- let expiringSoon = 0;
1011
- const SOON_MS = 2592e5;
1012
- const parseDate = (raw) => {
1013
- if (typeof raw === "number" && raw > 0xe8d4a51000) return raw;
1014
- if (typeof raw === "string" && raw !== "") {
1015
- const parsed = Date.parse(raw);
1016
- if (!Number.isNaN(parsed)) return parsed;
1017
- }
1018
- };
1019
- const packages = [];
1020
- for (const raw of rawAccounts) {
1021
- if (typeof raw !== "object" || raw === null) continue;
1022
- const account = raw;
1023
- const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1024
- const monthly = num("CapacityType") === 4;
1025
- const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1026
- const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1027
- const capped = remain < 0 ? 0 : remain;
1028
- const cycleEndMs = parseDate(account["CycleEndTime"]);
1029
- const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1030
- const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1031
- if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1032
- total += capped;
1033
- if (expiresAtMs !== void 0) {
1034
- if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
1035
- if (expiresAtMs - Date.now() <= SOON_MS) expiringSoon += capped;
1036
- }
1037
- packages.push({
1038
- packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
1039
- remain: capped,
1040
- size,
1041
- monthly,
1042
- ...refreshAtMs === void 0 ? {} : { refreshAtMs },
1043
- ...expiresAtMs === void 0 ? {} : { expiresAtMs }
1044
- });
1045
- }
1046
- return {
1047
- total,
1048
- packages,
1049
- expiringSoon,
1050
- ...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
1051
- };
1052
- }
1053
- /** Query today's check-in status without changing account state. */
1054
- async fetchCheckinStatus(credential) {
1055
- const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
1056
- method: "POST",
1057
- headers: billingHeaders(credential),
1058
- body: "{}",
1059
- signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1060
- });
1061
- const envelope = await readEnvelope(response);
1062
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1063
- const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1064
- const num = (key) => typeof data[key] === "number" ? data[key] : 0;
1065
- return {
1066
- active: data["active"] === true,
1067
- todayCheckedIn: data["today_checked_in"] === true,
1068
- streakDays: num("streak_days"),
1069
- dailyCredit: num("daily_credit"),
1070
- todayCredit: num("today_credit"),
1071
- isStreakDay: data["is_streak_day"] === true,
1072
- nextStreakDay: num("next_streak_day"),
1073
- streakBonusDays: num("streak_bonus_days"),
1074
- streakBonusCredit: num("streak_bonus_credit"),
1075
- ...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
1076
- };
1077
- }
1078
- /** Claim today's check-in reward. The browser route guards this mutation. */
1079
- async claimDailyCheckin(credential) {
1080
- const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
1081
- method: "POST",
1082
- headers: billingHeaders(credential),
1083
- body: "{}",
1084
- signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1085
- });
1086
- const envelope = await readEnvelope(response);
1087
- if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1088
- const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
1089
- const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
1090
- return {
1091
- credit: numberField("credit"),
1092
- streakDays: numberField("streak_days"),
1093
- isStreakDay: data["is_streak_day"] === true
1094
- };
1095
- }
1096
- /** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
1097
- async credits(credential) {
1098
- try {
1099
- return {
1100
- ok: true,
1101
- data: await this.fetchCredits(credential)
1102
- };
1103
- } catch (error) {
1104
- return {
1105
- ok: false,
1106
- message: String(error)
1107
- };
1108
- }
1109
- }
1110
- };
1111
- //#endregion
1112
- //#region src/shim.ts
1113
- /**
1114
- * Loopback OpenAI-compatible endpoint with multi-account failover.
1115
- *
1116
- * The pi-ai provider points here. Each chat request acquires an account from
1117
- * the pool; when the upstream answers with a rate limit, the shim cools that
1118
- * account down, takes the next one, and retries in the same request — so a
1119
- * `429 soft_rate` never reaches the user as a turn failure.
1120
- *
1121
- * Security model (Host/Origin loopback checks, constant-time bearer compare,
1122
- * random port, in-process secret, body cap, error→status mapping) follows
1123
- * corrinehu/dsh-workbuddy-connect (MIT, Copyright (c) 2026 Corrine Hu), which
1124
- * designed and validated it.
1125
- *
1126
- * @module dsh-workbuddy-xdpool/shim
1127
- */
1128
- const REQUEST_BODY_LIMIT = 67108864;
1129
- const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1130
- "127.0.0.1",
1131
- "localhost",
1132
- "[::1]"
1133
- ]);
1134
- /** HTTP status each upstream failure class surfaces as. */
1135
- const KIND_STATUS = {
1136
- hard_credit: 402,
1137
- soft_rate: 429,
1138
- session_dead: 401,
1139
- not_found: 502,
1140
- server: 502,
1141
- client: 400
1142
- };
1143
- function hostnameOfHost(host) {
1144
- let hostname = host.trim().toLowerCase();
1145
- if (hostname.startsWith("[")) {
1146
- const end = hostname.indexOf("]");
1147
- return end === -1 ? hostname : hostname.slice(0, end + 1);
1148
- }
1149
- const colon = hostname.lastIndexOf(":");
1150
- if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
1151
- return hostname;
1152
- }
1153
- /** Host must name loopback; drops DNS-rebinding attempts before routing. */
1154
- function hostIsLoopback(host) {
1155
- if (host === void 0 || host.trim() === "") return false;
1156
- return LOOPBACK_HOSTS.has(hostnameOfHost(host));
1157
- }
1158
- /** A present Origin must be loopback; non-browser clients send none and pass. */
1159
- function originIsLoopback(origin) {
1160
- if (origin === void 0 || origin.trim() === "") return true;
1161
- try {
1162
- const { hostname } = new URL(origin);
1163
- return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
1164
- } catch {
1165
- return false;
1166
- }
1167
- }
1168
- /** Chat POSTs must carry a JSON body type (blocks simple-request CSRF). */
1169
- function isJsonContentType(req) {
1170
- const type = req.headers["content-type"];
1171
- return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
1172
- }
1173
- function writeJson(res, status, body) {
1174
- const payload = JSON.stringify(body);
1175
- res.writeHead(status, {
1176
- "Content-Type": "application/json",
1177
- "Content-Length": Buffer.byteLength(payload)
1178
- });
1179
- res.end(payload);
1180
- }
1181
- function writeOpenAIError(res, status, kind, message) {
1182
- writeJson(res, status, { error: {
1183
- message,
1184
- type: kind,
1185
- code: kind
1186
- } });
1187
- }
1188
- /** True when an upstream failure body means the request overran the model's
1189
- * context window (OpenAI `context_length_exceeded`, WorkBuddy code 11115 /
1190
- * "input length too long"). Surfaced as a friendly hint, never auto-truncated. */
1191
- function isContextTooLong(body) {
1192
- if (body.includes("context_length_exceeded")) return true;
1193
- if (body.includes("input length too long")) return true;
1194
- if (body.includes("\"code\":11115")) return true;
1195
- if (/exceeds?\s+(the\s+)?(model\s+)?context\s+(window|limit)/iu.test(body)) return true;
1196
- return false;
1197
- }
1198
- function readBody(req) {
1199
- return new Promise((resolve, reject) => {
1200
- const chunks = [];
1201
- let size = 0;
1202
- req.on("data", (chunk) => {
1203
- size += chunk.length;
1204
- if (size > REQUEST_BODY_LIMIT) {
1205
- reject(/* @__PURE__ */ new Error("request body too large"));
1206
- req.destroy();
1207
- return;
1208
- }
1209
- chunks.push(chunk);
1210
- });
1211
- req.on("end", () => resolve(Buffer.concat(chunks)));
1212
- req.on("error", reject);
1213
- });
1214
- }
1215
- function createWorkBuddyShim(options) {
1216
- const { pool, client, catalog } = options;
1217
- const logger = options.logger;
1218
- const maxAttempts = options.maxAttempts ?? 8;
1219
- const SHARED_SECRET = randomBytes(32).toString("base64url");
1220
- function bearerOk(req) {
1221
- const header = req.headers.authorization;
1222
- if (typeof header !== "string") return false;
1223
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
1224
- if (match === null) return false;
1225
- const a = Buffer.from(match[1]);
1226
- const b = Buffer.from(SHARED_SECRET);
1227
- if (a.length !== b.length) return false;
1228
- return timingSafeEqual(a, b);
1229
- }
1230
- const server = createServer((req, res) => {
1231
- handle(req, res);
1232
- });
1233
- const ready = new Promise((resolve, reject) => {
1234
- server.once("listening", () => resolve());
1235
- server.once("error", reject);
1236
- });
1237
- server.listen(0, "127.0.0.1");
1238
- const baseUrl = () => {
1239
- const address = server.address();
1240
- if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
1241
- return `http://127.0.0.1:${address.port}`;
1242
- };
1243
- async function handle(req, res) {
1244
- try {
1245
- if (!hostIsLoopback(req.headers.host)) {
1246
- writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
1247
- return;
1248
- }
1249
- if (!originIsLoopback(req.headers.origin)) {
1250
- writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
1251
- return;
1252
- }
1253
- if (!bearerOk(req)) {
1254
- writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
1255
- return;
1256
- }
1257
- const url = req.url ?? "/";
1258
- if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
1259
- writeJson(res, 200, {
1260
- ok: true,
1261
- pool: pool.status()
1262
- });
1263
- return;
1264
- }
1265
- if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
1266
- writeJson(res, 200, {
1267
- object: "list",
1268
- data: catalog.current().map((model) => ({
1269
- id: model.id,
1270
- object: "model",
1271
- created: 0,
1272
- owned_by: "workbuddy"
1273
- }))
1274
- });
1275
- return;
1276
- }
1277
- if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
1278
- await chatCompletions(req, res);
1279
- return;
1280
- }
1281
- writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
1282
- } catch (error) {
1283
- if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
1284
- else res.end();
1285
- }
1286
- }
1287
- /**
1288
- * Serve one chat completion, rotating accounts on rate limits.
1289
- *
1290
- * A rate-limited account is cooled for exactly the window the upstream
1291
- * reports (when parseable) and the next account is tried immediately, so a
1292
- * pool with any healthy member never surfaces a 429 to the caller.
1293
- */
1294
- async function chatCompletions(req, res) {
1295
- if (!isJsonContentType(req)) {
1296
- writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
1297
- return;
1298
- }
1299
- const raw = (await readBody(req)).toString("utf8");
1300
- const prepared = client.prepareChatBody(raw);
1301
- const controller = new AbortController();
1302
- req.on("close", () => controller.abort());
1303
- let modelId;
1304
- try {
1305
- const parsed = JSON.parse(raw);
1306
- modelId = typeof parsed.model === "string" && parsed.model !== "" ? parsed.model : void 0;
1307
- } catch {
1308
- modelId = void 0;
1309
- }
1310
- const tried = [];
1311
- let last;
1312
- let exhaustedByRateLimit = false;
1313
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1314
- if (controller.signal.aborted) return;
1315
- const account = await pool.acquire(modelId);
1316
- if (account === void 0) {
1317
- if (exhaustedByRateLimit && last !== void 0) {
1318
- const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
1319
- writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `${subject} (tried ${tried.length}: ${tried.join(", ")}); resets at the upstream window — ${last.message.slice(0, 200)}`);
1320
- return;
1321
- }
1322
- writeOpenAIError(res, 401, "not_signed_in", "no WorkBuddy credential found; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)");
1323
- return;
1324
- }
1325
- tried.push(account.label);
1326
- const result = await client.chatStream(account.credential, prepared, controller.signal);
1327
- if (result.ok) {
1328
- logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
1329
- res.writeHead(200, {
1330
- "Content-Type": "text/event-stream",
1331
- "Cache-Control": "no-cache",
1332
- "Connection": "keep-alive",
1333
- "X-Accel-Buffering": "no"
1334
- });
1335
- let sawDone = false;
1336
- const body = Readable.fromWeb(result.response.body);
1337
- body.on("data", (chunk) => {
1338
- if (chunk.includes("[DONE]")) sawDone = true;
1339
- });
1340
- body.on("error", (error) => {
1341
- logger?.warn("dsh-workbuddy-xdpool: upstream stream failed mid-flight", error);
1342
- if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
1343
- });
1344
- body.pipe(res);
1345
- return;
1346
- }
1347
- last = {
1348
- kind: result.kind,
1349
- status: result.status,
1350
- message: result.message
1351
- };
1352
- if (result.kind === "session_dead") {
1353
- logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
1354
- await pool.refreshAccount(account.id);
1355
- continue;
1356
- }
1357
- if (result.kind !== "soft_rate") break;
1358
- exhaustedByRateLimit = true;
1359
- pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
1360
- logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on ${modelId ?? "(no model)"} (attempt ${attempt + 1}/${maxAttempts}); rotating`);
1361
- }
1362
- if (last === void 0) {
1363
- writeOpenAIError(res, 500, "internal", "chat request exhausted without a result");
1364
- return;
1365
- }
1366
- if (isContextTooLong(last.message)) {
1367
- writeOpenAIError(res, 400, "context_length_exceeded", `${modelId === void 0 ? "the conversation exceeds this model's context window" : `the conversation exceeds ${modelId}'s context window`}. Shorten the conversation, start a new chat, or pick a model with a larger window (e.g. hy4-preview).`);
1368
- return;
1369
- }
1370
- writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `workbuddy upstream ${last.kind} (http ${last.status}) after ${tried.length} account(s) [${tried.join(" → ")}]: ${last.message.slice(0, 400)}`);
1371
- }
1372
- return {
1373
- ready,
1374
- baseUrl,
1375
- token: () => SHARED_SECRET,
1376
- close: () => new Promise((resolve, reject) => {
1377
- server.close(() => resolve());
1378
- server.closeAllConnections();
1379
- server.once("error", reject);
1380
- })
1381
- };
1382
- }
1383
- //#endregion
1384
- //#region src/status.ts
1385
- /** Build the status document. Never throws. */
1386
- async function buildStatus(options) {
1387
- const { pool, catalog, client } = options;
1388
- const accounts = pool.list();
1389
- const now = Date.now();
1390
- const rows = [];
1391
- for (const account of accounts) {
1392
- const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
1393
- modelId,
1394
- until: new Date(until).toISOString()
1395
- }));
1396
- const row = {
1397
- id: account.id,
1398
- label: account.label,
1399
- ...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
1400
- domain: account.credential.domain,
1401
- ...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
1402
- cooling: account.cooldownUntilMs > now,
1403
- ...account.cooldownUntilMs > now ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1404
- ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
1405
- rateLimitHits: account.rateLimitHits,
1406
- sourcePath: account.credential.sourcePath
1407
- };
1408
- if (options.includeCredits === true && !row.cooling) try {
1409
- Object.assign(row, { credits: await client.fetchCredits(account.credential) });
1410
- } catch (error) {
1411
- Object.assign(row, { creditsError: String(error).slice(0, 200) });
1412
- }
1413
- rows.push(row);
1414
- }
1415
- const cooling = rows.filter((row) => row.cooling).length;
1416
- const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
1417
- return {
1418
- ok: accounts.length > 0 && cooling < accounts.length,
1419
- accounts: rows,
1420
- ...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
1421
- cooling,
1422
- models: catalog.current().map((model) => ({
1423
- id: model.id,
1424
- name: model.name,
1425
- ...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
1426
- ...model.tags === void 0 ? {} : { tags: model.tags }
1427
- })),
1428
- shim: options.shim ?? { running: false }
1429
- };
1430
- }
1431
- /** Format the status document for a terminal. */
1432
- function formatStatus(status) {
1433
- const lines = [];
1434
- lines.push(`WorkBuddy XD Pool: ${status.accounts.length} account(s), ${status.cooling} cooling`);
1435
- lines.push(`Shim: ${status.shim.running ? "running" : "stopped"}${status.shim.baseUrl === void 0 ? "" : ` at ${status.shim.baseUrl}`}`);
1436
- lines.push("");
1437
- if (status.accounts.length === 0) {
1438
- lines.push("No WorkBuddy credential found. Sign in on the WorkBuddy desktop app,");
1439
- lines.push("then run: dsh plugin --profile desktop exec dsh-workbuddy-xdpool import <key>");
1440
- return lines.join("\n");
1441
- }
1442
- for (const account of status.accounts) {
1443
- const flag = account.cooling ? "⏸ " : "▶ ";
1444
- const active = account.id === status.activeAccountId ? " (next up)" : "";
1445
- lines.push(`${flag}${account.label}${active}`);
1446
- lines.push(` uid/uin : ${account.id} [${account.domain || "cn"}]`);
1447
- if (account.expiresAt !== void 0) lines.push(` expires : ${account.expiresAt}`);
1448
- if (account.credits !== void 0) {
1449
- const { total } = account.credits;
1450
- const parts = [];
1451
- if (total !== void 0) parts.push(`total ${total}`);
1452
- lines.push(` credits : ${parts.join(" | ") || "n/a"}`);
1453
- }
1454
- if (account.creditsError !== void 0) lines.push(` credits : query failed — ${account.creditsError}`);
1455
- if (account.cooling && account.cooldownUntil !== void 0) lines.push(` cooldown : until ${account.cooldownUntil} (hits ${account.rateLimitHits})`);
1456
- if (account.modelCooldowns !== void 0 && account.modelCooldowns.length > 0) for (const mc of account.modelCooldowns) lines.push(` model-cool: ${mc.modelId} until ${mc.until}`);
1457
- lines.push(` source : ${account.sourcePath}`);
1458
- lines.push("");
1459
- }
1460
- return lines.join("\n").trimEnd();
1461
- }
1462
- /** Format the per-model credit multipliers. */
1463
- function formatRates(status) {
1464
- const lines = ["Model credit multipliers:"];
1465
- for (const model of status.models) {
1466
- const rate = model.multiplier === void 0 ? "x?" : `x${model.multiplier.toFixed(2)}`;
1467
- lines.push(` ${model.name.padEnd(20)} ${rate}`);
1468
- }
1469
- return lines.join("\n");
1470
- }
1471
- //#endregion
1472
- //#region src/status-paths.ts
1473
- /**
1474
- * Node-free constants and types shared by the Host and browser halves of the
1475
- * WorkBuddy XD Pool settings card.
1476
- *
1477
- * Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
1478
- * `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
1479
- * JSON document into a shape that stays token-free and matches what the
1480
- * browser card renders. Route paths are plugin-owned and mounted on the Host's
1481
- * same-origin web server (see `src/web-status.ts`).
1482
- *
1483
- * @module dsh-workbuddy-xdpool/status-paths
1484
- */
1485
- /** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
1486
- const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
1487
- /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
1488
- const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
1489
- /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
1490
- const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
1491
- /** Plugin-owned daily check-in action endpoint (claim today's reward). */
1492
- const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
1493
- //#endregion
1494
- //#region src/web-status.ts
1495
- /** Redact token-like content before it crosses to the browser. */
1496
- function safeMessage(error) {
1497
- return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
1498
- }
1499
- function json(res, status, body) {
1500
- const payload = JSON.stringify(body);
1501
- res.writeHead(status, {
1502
- "Content-Type": "application/json",
1503
- "Content-Length": Buffer.byteLength(payload)
1504
- });
1505
- res.end(payload);
1506
- }
1507
- /** Loopback browser origins only; other devices are refused. */
1508
- function loopbackOrigin(req) {
1509
- const origin = req.headers.origin;
1510
- if (origin === void 0) return true;
1511
- try {
1512
- const { hostname } = new URL(origin);
1513
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
1514
- } catch {
1515
- return false;
1516
- }
1517
- }
1518
- /** Smallest possible POST body reader, capped so a hung or oversized body
1519
- * cannot pin memory on the Host. Returns `{}` for an empty body. */
1520
- function readJsonBody(req) {
1521
- return new Promise((resolve, reject) => {
1522
- const chunks = [];
1523
- let size = 0;
1524
- const LIMIT = 65536;
1525
- req.on("data", (chunk) => {
1526
- size += chunk.length;
1527
- if (size > LIMIT) {
1528
- reject(/* @__PURE__ */ new Error("request body too large"));
1529
- req.destroy();
1530
- return;
1531
- }
1532
- chunks.push(chunk);
1533
- });
1534
- req.on("end", () => {
1535
- const text = Buffer.concat(chunks).toString("utf8").trim();
1536
- if (text === "") {
1537
- resolve({});
1538
- return;
1539
- }
1540
- try {
1541
- const parsed = JSON.parse(text);
1542
- resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
1543
- } catch {
1544
- reject(/* @__PURE__ */ new Error("invalid JSON body"));
1545
- }
1546
- });
1547
- req.on("error", reject);
1548
- });
1549
- }
1550
- function toWebAccount(account) {
1551
- const now = Date.now();
1552
- const cooling = account.cooldownUntilMs > now;
1553
- const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
1554
- modelId,
1555
- until: new Date(until).toISOString()
1556
- }));
1557
- return {
1558
- id: account.id,
1559
- label: account.label,
1560
- ...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
1561
- domain: account.credential.domain,
1562
- ...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
1563
- cooling,
1564
- ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1565
- ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
1566
- rateLimitHits: account.rateLimitHits
1567
- };
1568
- }
1569
- function toWebModel(model) {
1570
- return {
1571
- id: model.id,
1572
- name: model.name,
1573
- ...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
1574
- ...model.tags === void 0 ? {} : { tags: model.tags },
1575
- supportsImages: model.supportsImages,
1576
- contextWindow: model.contextWindow
1577
- };
1578
- }
1579
- /**
1580
- * Assemble the card's status document. Per-account credits and check-in state
1581
- * are queried live; a failing query degrades to `creditsError` / `checkinError`
1582
- * rather than failing the whole document. Never throws.
1583
- */
1584
- async function poolWebStatus(deps) {
1585
- const accounts = deps.pool.list();
1586
- const now = Date.now();
1587
- const rows = [];
1588
- for (const account of accounts) {
1589
- const row = toWebAccount(account);
1590
- if (!row.cooling) {
1591
- try {
1592
- const credits = await deps.client.fetchCredits(account.credential);
1593
- Object.assign(row, { credits: {
1594
- total: credits.total,
1595
- packages: credits.packages,
1596
- ...credits.expiringSoon === void 0 ? {} : { expiringSoon: credits.expiringSoon },
1597
- ...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
1598
- } });
1599
- } catch (error) {
1600
- Object.assign(row, { creditsError: safeMessage(error) });
1601
- }
1602
- try {
1603
- const checkin = await deps.client.fetchCheckinStatus(account.credential);
1604
- const web = {
1605
- active: checkin.active,
1606
- todayCheckedIn: checkin.todayCheckedIn,
1607
- streakDays: checkin.streakDays,
1608
- dailyCredit: checkin.dailyCredit,
1609
- todayCredit: checkin.todayCredit,
1610
- isStreakDay: checkin.isStreakDay,
1611
- nextStreakDay: checkin.nextStreakDay,
1612
- streakBonusCredit: checkin.streakBonusCredit
1613
- };
1614
- Object.assign(row, { checkin: web });
1615
- } catch (error) {
1616
- Object.assign(row, { checkinError: safeMessage(error) });
1617
- }
1618
- }
1619
- rows.push(row);
1620
- }
1621
- const cooling = rows.filter((row) => row.cooling).length;
1622
- const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
1623
- let shim;
1624
- if (deps.shim === void 0) shim = { running: false };
1625
- else try {
1626
- shim = deps.shim();
1627
- } catch {
1628
- shim = { running: false };
1629
- }
1630
- return {
1631
- ok: accounts.length > 0 && cooling < accounts.length,
1632
- accounts: rows,
1633
- ...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
1634
- cooling,
1635
- models: deps.catalog.current().map(toWebModel),
1636
- shim
1637
- };
1638
- }
1639
- /**
1640
- * Mount the read-only routes on a context where `webServer` is available. The
1641
- * caller uses `ctx.inject(['webServer'], ...)` so Desktop startup order cannot
1642
- * make this registration disappear.
1643
- */
1644
- function registerPoolStatusRoute(ctx, deps) {
1645
- ctx.effect(() => {
1646
- const disposeStatus = ctx.webServer.register({
1647
- kind: "exact",
1648
- path: POOL_STATUS_PATH,
1649
- handler: async (req, res) => {
1650
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
1651
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1652
- try {
1653
- json(res, 200, await poolWebStatus(deps));
1654
- } catch (error) {
1655
- json(res, 500, { error: safeMessage(error) });
1656
- }
1657
- }
1658
- });
1659
- const disposeRescan = ctx.webServer.register({
1660
- kind: "exact",
1661
- path: POOL_RESCAN_PATH,
1662
- handler: async (req, res) => {
1663
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1664
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1665
- try {
1666
- json(res, 200, { accounts: (await deps.pool.scan()).length });
1667
- } catch (error) {
1668
- json(res, 500, { error: safeMessage(error) });
1669
- }
1670
- }
1671
- });
1672
- const disposeReset = ctx.webServer.register({
1673
- kind: "exact",
1674
- path: POOL_RESET_COOLDOWN_PATH,
1675
- handler: async (req, res) => {
1676
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1677
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1678
- deps.pool.resetCooldowns();
1679
- json(res, 200, { ok: true });
1680
- }
1681
- });
1682
- const disposeCheckin = ctx.webServer.register({
1683
- kind: "exact",
1684
- path: POOL_CHECKIN_PATH,
1685
- handler: async (req, res) => {
1686
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1687
- if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1688
- try {
1689
- const body = await readJsonBody(req);
1690
- const accountId = typeof body["accountId"] === "string" ? body["accountId"] : "";
1691
- if (accountId === "") return json(res, 400, { error: "accountId is required" });
1692
- const account = deps.pool.list().find((item) => item.id === accountId);
1693
- if (account === void 0) return json(res, 404, { error: "unknown account" });
1694
- const before = await deps.client.fetchCheckinStatus(account.credential);
1695
- if (!before.active) return json(res, 409, { error: "check-in activity is not active" });
1696
- if (before.todayCheckedIn) return json(res, 200, {
1697
- ok: true,
1698
- alreadyCheckedIn: true,
1699
- claim: {
1700
- credit: 0,
1701
- streakDays: before.streakDays,
1702
- isStreakDay: before.isStreakDay
1703
- }
1704
- });
1705
- json(res, 200, {
1706
- ok: true,
1707
- claim: await deps.client.claimDailyCheckin(account.credential)
1708
- });
1709
- } catch (error) {
1710
- json(res, 500, { error: safeMessage(error) });
1711
- }
1712
- }
1713
- });
1714
- return () => {
1715
- disposeCheckin();
1716
- disposeReset();
1717
- disposeRescan();
1718
- disposeStatus();
1719
- };
1720
- }, "dsh-workbuddy-xdpool: Web status route");
1721
- }
1722
- //#endregion
1723
- //#region src/index.ts
1724
- /**
1725
- * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
1726
- * Harness LLM seam once the loopback shim holds its port, plus the HTTP status
1727
- * routes consumed by the CLI.
1728
- *
1729
- * @module dsh-workbuddy-xdpool/index
1730
- */
1731
- /** Stable Cordis plugin name. */
1732
- const name = "llm-workbuddy-xdpool";
1733
- /** The model registry required before the provider can register. */
1734
- const inject = ["llm"];
1735
- /**
1736
- * Settings namespace for the WorkBuddy XD Pool card. Registering a section here
1737
- * is what makes the provider appear on the Models settings page and causes the
1738
- * Host to mount the plugin's client card under Plugin configuration — exactly
1739
- * the mechanism the single-account connector uses.
1740
- */
1741
- const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
1742
- const Config = z.object({
1743
- authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
1744
- cooldownMs: z.number().step(1).min(1e3).description("Rate-limit cooldown per account, in milliseconds")
1745
- });
1746
- /** Live API, published for the CLI. */
1747
- let api;
1748
- /** The live API, or undefined when the plugin has not applied yet. */
1749
- function currentApi() {
1750
- return api;
1751
- }
1752
- /** Test seam: install an API instance without booting cordis. */
1753
- function setApi(next) {
1754
- api = next;
1755
- }
1756
- /** Assemble the runtime objects without registering anything. */
1757
- function createCore(logger) {
1758
- const client = new WorkBuddyUpstreamClient();
1759
- return {
1760
- pool: new WorkBuddyAccountPool({
1761
- ...logger === void 0 ? {} : { logger },
1762
- client
1763
- }),
1764
- catalog: new WorkBuddyCatalog(),
1765
- client
1766
- };
1767
- }
1768
- /**
1769
- * Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
1770
- * discover accounts. The provider registers only after `shim.ready` resolves,
1771
- * because its models read the shim origin at construction time.
1772
- */
1773
- function apply(ctx, config = {}) {
1774
- const core = createCore(ctx.logger);
1775
- let current = () => config;
1776
- const sectionHooks = {
1777
- setSource(source) {
1778
- current = source;
1779
- },
1780
- onChange() {
1781
- applyConfigFromSource();
1782
- }
1783
- };
1784
- const applyConfigFromSource = () => {
1785
- const { authFile, cooldownMs } = current();
1786
- core.pool.applyConfig({
1787
- ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
1788
- ...cooldownMs === void 0 ? {} : { cooldownMs }
1789
- });
1790
- };
1791
- ctx.inject(["settings"], (settingsCtx) => {
1792
- const service = settingsCtx.settings;
1793
- if (typeof service.installSection === "function") service.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
1794
- else ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service has no installSection; card will not mount");
1795
- });
1796
- const shim = createWorkBuddyShim({
1797
- pool: core.pool,
1798
- client: core.client,
1799
- catalog: core.catalog,
1800
- logger: ctx.logger
1801
- });
1802
- let stopped = false;
1803
- let builtAdapter;
1804
- ctx.effect(() => () => {
1805
- stopped = true;
1806
- shim.close();
1807
- });
1808
- ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
1809
- pool: core.pool,
1810
- catalog: core.catalog,
1811
- client: core.client,
1812
- shim: () => {
1813
- let baseUrl;
1814
- try {
1815
- baseUrl = shim.baseUrl();
1816
- } catch {
1817
- baseUrl = void 0;
1818
- }
1819
- return {
1820
- running: baseUrl !== void 0,
1821
- ...baseUrl === void 0 ? {} : { baseUrl }
1822
- };
1823
- }
1824
- }));
1825
- api = {
1826
- ...core,
1827
- shim,
1828
- get adapter() {
1829
- return builtAdapter;
1830
- },
1831
- async rescan() {
1832
- const accounts = await core.pool.scan();
1833
- ctx.logger.info?.(`dsh-workbuddy-xdpool: discovered ${accounts.length} account(s)`);
1834
- return accounts.length;
1835
- },
1836
- async status(includeCredits = false) {
1837
- let baseUrl;
1838
- try {
1839
- baseUrl = shim.baseUrl();
1840
- } catch {
1841
- baseUrl = void 0;
1842
- }
1843
- return buildStatus({
1844
- pool: core.pool,
1845
- catalog: core.catalog,
1846
- client: core.client,
1847
- shim: {
1848
- running: baseUrl !== void 0,
1849
- ...baseUrl === void 0 ? {} : { baseUrl }
1850
- },
1851
- includeCredits
1852
- });
1853
- },
1854
- resetCooldowns() {
1855
- core.pool.resetCooldowns();
1856
- }
1857
- };
1858
- shim.ready.then(async () => {
1859
- if (stopped) return;
1860
- let releaseAdapter;
1861
- let releaseDirectory;
1862
- try {
1863
- const workbuddy = createWorkBuddyAdapter({
1864
- shim,
1865
- catalog: core.catalog
1866
- });
1867
- builtAdapter = workbuddy;
1868
- try {
1869
- releaseAdapter = ctx.llm.registerAdapter([WORKBUDDY_POOL_PROVIDER], workbuddy.adapter);
1870
- releaseDirectory = ctx.llm.registerConfigurableProviders([{
1871
- provider: WORKBUDDY_POOL_PROVIDER,
1872
- displayName: "WorkBuddy XD Pool",
1873
- settingsNs: WORKBUDDY_POOL_SETTINGS_NS,
1874
- settingsPath: [],
1875
- declared: false
1876
- }]);
1877
- } finally {
1878
- if (releaseAdapter === void 0 || releaseDirectory === void 0) {
1879
- releaseAdapter?.();
1880
- releaseDirectory?.();
1881
- }
1882
- }
1883
- try {
1884
- ctx.effect(() => () => {
1885
- releaseAdapter?.();
1886
- releaseDirectory?.();
1887
- });
1888
- } catch {
1889
- releaseAdapter?.();
1890
- releaseDirectory?.();
1891
- }
1892
- ctx.logger.info?.(`dsh-workbuddy-xdpool: provider registered at ${shim.baseUrl()}`);
1893
- } catch (error) {
1894
- ctx.logger.error("dsh-workbuddy-xdpool: provider registration failed", error);
1895
- return;
1896
- }
1897
- if (stopped) return;
1898
- core.pool.scan().then((accounts) => {
1899
- ctx.logger.info?.(`dsh-workbuddy-xdpool: ${accounts.length} WorkBuddy account(s) in rotation`);
1900
- }, (error) => {
1901
- ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
1902
- });
1903
- (async () => {
1904
- try {
1905
- const credential = (await core.pool.scan())[0]?.credential;
1906
- if (credential === void 0) return;
1907
- const models = await core.client.fetchModels(credential);
1908
- core.catalog.updateFromUpstream(models);
1909
- builtAdapter?.invalidate();
1910
- ctx.logger.info?.(`dsh-workbuddy-xdpool: live catalog seeded with ${models.length} model(s)`);
1911
- } catch (error) {
1912
- ctx.logger.warn("dsh-workbuddy-xdpool: live model catalog unavailable; using static fallback", error);
1913
- }
1914
- })();
1915
- }, (error) => {
1916
- ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
1917
- });
1918
- }
1919
- //#endregion
1920
- export { Config, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };
1
+ import { dirname, join, resolve } from "node:path";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { readFile, readdir } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { createProvider } from "@earendil-works/pi-ai";
7
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
+ import { createServer } from "node:http";
11
+ import { Readable } from "node:stream";
12
+ //#region src/upstream.ts
13
+ /** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
14
+ const CN_CHAT_BASE = "https://copilot.tencent.com";
15
+ /** Billing/console base — `www.codebuddy.cn` is the billing origin. */
16
+ const CN_BILLING_BASE = "https://www.codebuddy.cn";
17
+ /** Global base for `workbuddy.ai` logins. */
18
+ const GLOBAL_BASE = "https://www.workbuddy.ai";
19
+ /** Client UA the desktop CLI uses. */
20
+ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
21
+ const JSON_TIMEOUT_MS = 3e4;
22
+ const ERROR_BODY_LIMIT = 4096;
23
+ /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
24
+ const HARD_CREDIT_MARKERS = [
25
+ "insufficient credit",
26
+ "no credit",
27
+ "credit exhausted",
28
+ "out of credit",
29
+ "quota exceeded",
30
+ "quota exhaust",
31
+ "payment required",
32
+ "credit not enough",
33
+ "not enough credit",
34
+ "积分不足",
35
+ "额度不足",
36
+ "余额不足",
37
+ "积分用完",
38
+ "额度用尽",
39
+ "没有积分"
40
+ ];
41
+ /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
42
+ const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
43
+ function regionOf(domain) {
44
+ const lowered = domain.trim().toLowerCase();
45
+ if (lowered.endsWith(".workbuddy.ai") || lowered.endsWith(".workbuddy.cc")) return "global";
46
+ if (lowered === "workbuddy.ai" || lowered === "workbuddy.cc") return "global";
47
+ return "cn";
48
+ }
49
+ function chatBase(credential) {
50
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
51
+ }
52
+ function billingBase(credential) {
53
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
54
+ }
55
+ function originReferer(credential) {
56
+ return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
57
+ }
58
+ /** Headers every upstream request shares. */
59
+ function commonHeaders(credential) {
60
+ return {
61
+ "Accept": "application/json, text/plain, */*",
62
+ "X-Requested-With": "XMLHttpRequest",
63
+ "Origin": originReferer(credential),
64
+ "Referer": `${originReferer(credential)}/`,
65
+ "User-Agent": CLIENT_UA
66
+ };
67
+ }
68
+ /** Chat request headers, including the X-No-* conventions the official CLI uses. */
69
+ function chatHeaders(credential) {
70
+ return {
71
+ ...commonHeaders(credential),
72
+ "Content-Type": "application/json",
73
+ "Authorization": `Bearer ${credential.accessToken}`,
74
+ ...credential.uid === "" || credential.uid === void 0 ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
75
+ ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
76
+ ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
77
+ "X-Product": "SaaS"
78
+ };
79
+ }
80
+ /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
81
+ function refreshHeaders(credential) {
82
+ const headers = {
83
+ ...commonHeaders(credential),
84
+ "X-Refresh-Token": credential.refreshToken,
85
+ "X-Auth-Refresh-Source": "workbuddy"
86
+ };
87
+ if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
88
+ return headers;
89
+ }
90
+ /** Billing request headers. */
91
+ function billingHeaders(credential) {
92
+ const headers = {
93
+ "Authorization": `Bearer ${credential.accessToken}`,
94
+ "Accept": "application/json",
95
+ "Content-Type": "application/json"
96
+ };
97
+ if (credential.uid !== "" && credential.uid !== void 0) headers["X-User-Id"] = credential.uid;
98
+ if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
99
+ headers["X-Enterprise-Id"] = credential.enterpriseId;
100
+ headers["X-Tenant-Id"] = credential.enterpriseId;
101
+ }
102
+ if (credential.domain !== "") headers["X-Domain"] = credential.domain;
103
+ return headers;
104
+ }
105
+ async function readEnvelope(response) {
106
+ const text = await response.text();
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(text);
110
+ } catch {
111
+ throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
112
+ }
113
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
114
+ const document = parsed;
115
+ return {
116
+ code: typeof document["code"] === "number" ? document["code"] : 0,
117
+ msg: typeof document["msg"] === "string" ? document["msg"] : "",
118
+ data: "data" in document ? document["data"] : void 0
119
+ };
120
+ }
121
+ /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
122
+ function envelopeError(status, envelope) {
123
+ const kind = classifyUpstreamError(status, envelope.msg);
124
+ return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
125
+ }
126
+ /**
127
+ * Classify an upstream failure from its HTTP status and body excerpt.
128
+ * Body markers win over status, because the upstream reuses 400/200 for
129
+ * several distinct conditions.
130
+ */
131
+ function classifyUpstreamError(status, body) {
132
+ if (status === 402) return "hard_credit";
133
+ const lower = body.toLowerCase();
134
+ for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
135
+ for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
136
+ if (status === 429) return "soft_rate";
137
+ if (body.includes("soft_rate") || body.includes("\"code\":6004") || body.includes("频率限制")) return "soft_rate";
138
+ if (status === 404) return "not_found";
139
+ if (status >= 500) return "server";
140
+ return "client";
141
+ }
142
+ /**
143
+ * Parse the reset time the upstream reports for a rate limit, when present.
144
+ * Recognises an epoch-millisecond field and the Chinese-localised sentence
145
+ * form, so the pool can resume exactly when the window reopens.
146
+ */
147
+ function parseRateLimitReset(body) {
148
+ const epochMs = /"(?:resetAt|reset_at|resetTime|reset_time)"\s*:\s*(\d{13})/.exec(body);
149
+ if (epochMs !== null) return Number(epochMs[1]);
150
+ const localized = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2})/.exec(body);
151
+ if (localized !== null) {
152
+ const parsed = Date.parse(localized[1].replace(" ", "T"));
153
+ if (!Number.isNaN(parsed)) return parsed;
154
+ }
155
+ }
156
+ /** Parse the upstream's `credits` string into a multiplier. */
157
+ function parseCreditMultiplier(value) {
158
+ if (typeof value !== "string") return void 0;
159
+ const match = /x\s*([0-9]*\.?[0-9]+)/iu.exec(value);
160
+ if (match === null) return void 0;
161
+ const parsed = Number(match[1]);
162
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
163
+ }
164
+ /** Parse the upstream's `reasoning` object; unknown shapes degrade to `{}`. */
165
+ function parseReasoning(value) {
166
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
167
+ const raw = value;
168
+ const supportedEfforts = Array.isArray(raw["supportedEfforts"]) ? raw["supportedEfforts"].filter((effort) => typeof effort === "string") : void 0;
169
+ const defaultEffort = typeof raw["defaultEffort"] === "string" ? raw["defaultEffort"] : void 0;
170
+ const canDisableThinking = typeof raw["canDisableThinking"] === "boolean" ? raw["canDisableThinking"] : void 0;
171
+ if (supportedEfforts === void 0 && defaultEffort === void 0 && canDisableThinking === void 0) return;
172
+ return {
173
+ ...supportedEfforts === void 0 || supportedEfforts.length === 0 ? {} : { supportedEfforts },
174
+ ...defaultEffort === void 0 ? {} : { defaultEffort },
175
+ ...canDisableThinking === void 0 ? {} : { canDisableThinking }
176
+ };
177
+ }
178
+ /** Parse one catalog entry; entries without usable token limits are dropped. */
179
+ function parseUpstreamModel(value) {
180
+ if (typeof value !== "object" || value === null) return void 0;
181
+ const raw = value;
182
+ const id = typeof raw["id"] === "string" ? raw["id"] : "";
183
+ if (id === "" || raw["disabled"] === true) return void 0;
184
+ const input = typeof raw["maxInputTokens"] === "number" ? raw["maxInputTokens"] : 0;
185
+ const output = typeof raw["maxOutputTokens"] === "number" ? raw["maxOutputTokens"] : 0;
186
+ if (input <= 0 || output <= 0) return void 0;
187
+ const name = typeof raw["name"] === "string" && raw["name"] !== "" ? raw["name"] : id;
188
+ const descriptionZh = typeof raw["descriptionZh"] === "string" && raw["descriptionZh"] !== "" ? raw["descriptionZh"] : void 0;
189
+ const descriptionEn = typeof raw["descriptionEn"] === "string" && raw["descriptionEn"] !== "" ? raw["descriptionEn"] : void 0;
190
+ const creditMultiplier = parseCreditMultiplier(raw["credits"]);
191
+ const reasoning = parseReasoning(raw["reasoning"]);
192
+ const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
193
+ return {
194
+ id,
195
+ name,
196
+ contextWindow: input,
197
+ maxTokens: output,
198
+ ...creditMultiplier === void 0 ? {} : { creditMultiplier },
199
+ ...reasoning === void 0 ? {} : { reasoning },
200
+ ...descriptionZh === void 0 ? {} : { descriptionZh },
201
+ ...descriptionEn === void 0 ? {} : { descriptionEn },
202
+ ...supportsToolCall === void 0 ? {} : { supportsToolCall }
203
+ };
204
+ }
205
+ var WorkBuddyUpstreamClient = class {
206
+ fetchImpl;
207
+ clientVersion;
208
+ constructor(options = {}) {
209
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
210
+ this.clientVersion = options.clientVersion ?? "2.0.4";
211
+ }
212
+ /**
213
+ * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
214
+ * force `stream: true` (the upstream rejects non-streaming), convert the
215
+ * DSH `developer` role into `system` (upstream rejects `developer` with
216
+ * business code 11128), and flatten `tool_choice` into its string form.
217
+ */
218
+ prepareChatBody(raw) {
219
+ let body;
220
+ try {
221
+ body = JSON.parse(raw);
222
+ } catch {
223
+ return raw;
224
+ }
225
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return raw;
226
+ const obj = body;
227
+ obj["stream"] = true;
228
+ delete obj["stream_options"];
229
+ if (Array.isArray(obj["messages"])) for (const value of obj["messages"]) {
230
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
231
+ const message = value;
232
+ if (message["role"] === "developer") message["role"] = "system";
233
+ }
234
+ const choice = obj["tool_choice"];
235
+ if (typeof choice === "string") {
236
+ if (choice.trim().toLowerCase() === "none") {
237
+ delete obj["tool_choice"];
238
+ delete obj["tools"];
239
+ delete obj["functions"];
240
+ }
241
+ } else if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
242
+ const wrapped = choice;
243
+ const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
244
+ if (type === "none") {
245
+ delete obj["tool_choice"];
246
+ delete obj["tools"];
247
+ delete obj["functions"];
248
+ } else if (type === "auto" || type === "required") obj["tool_choice"] = type;
249
+ else if (type === "function") {
250
+ const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
251
+ let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
252
+ if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
253
+ obj["tool_choice"] = name.trim() !== "" ? name.trim() : "auto";
254
+ } else delete obj["tool_choice"];
255
+ }
256
+ return JSON.stringify(obj);
257
+ }
258
+ /** Forward one chat completion. Never throws for upstream failures. */
259
+ async chatStream(credential, prepared, signal) {
260
+ let response;
261
+ try {
262
+ response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
263
+ method: "POST",
264
+ headers: chatHeaders(credential),
265
+ body: prepared,
266
+ ...signal === void 0 ? {} : { signal }
267
+ });
268
+ } catch (error) {
269
+ return {
270
+ ok: false,
271
+ kind: "server",
272
+ status: 0,
273
+ message: `transport error: ${String(error)}`
274
+ };
275
+ }
276
+ if (response.ok) return {
277
+ ok: true,
278
+ response
279
+ };
280
+ const text = (await response.text().catch(() => "")).slice(0, ERROR_BODY_LIMIT);
281
+ return {
282
+ ok: false,
283
+ kind: classifyUpstreamError(response.status, text),
284
+ status: response.status,
285
+ message: text
286
+ };
287
+ }
288
+ /** POST the token-refresh endpoint; the caller merges the outcome. */
289
+ async refreshToken(credential) {
290
+ const response = await this.fetchImpl(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
291
+ method: "POST",
292
+ headers: refreshHeaders(credential),
293
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
294
+ });
295
+ const envelope = await readEnvelope(response);
296
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
297
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
298
+ const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
299
+ if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
300
+ const outcome = { accessToken };
301
+ if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
302
+ if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
303
+ if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
304
+ return outcome;
305
+ }
306
+ /** GET the personal model catalog, keeping the `cli` agent's models only. */
307
+ async fetchModels(credential, signal) {
308
+ const response = await this.fetchImpl(`${chatBase(credential)}/console/enterprises/personal/models`, {
309
+ headers: {
310
+ "Authorization": `Bearer ${credential.accessToken}`,
311
+ "Accept": "application/json",
312
+ "Origin": originReferer(credential),
313
+ "Referer": `${originReferer(credential)}/`,
314
+ "User-Agent": CLIENT_UA
315
+ },
316
+ ...signal === void 0 ? {} : { signal }
317
+ });
318
+ const envelope = await readEnvelope(response);
319
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
320
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
321
+ const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
322
+ const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
323
+ let cliIds;
324
+ for (const agent of agents) if (typeof agent === "object" && agent !== null) {
325
+ const wrapped = agent;
326
+ if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
327
+ cliIds = wrapped["models"].filter((id) => typeof id === "string");
328
+ break;
329
+ }
330
+ }
331
+ const byId = /* @__PURE__ */ new Map();
332
+ for (const model of rawModels) {
333
+ const parsed = parseUpstreamModel(model);
334
+ if (parsed !== void 0) byId.set(parsed.id, parsed);
335
+ }
336
+ const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
337
+ if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
338
+ return models;
339
+ }
340
+ /** Read-only credits query, aggregated by package. Does not consume credits. */
341
+ async fetchCredits(credential) {
342
+ const now = /* @__PURE__ */ new Date();
343
+ const fmt = (date) => {
344
+ const p = (n) => n.toString().padStart(2, "0");
345
+ return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
346
+ };
347
+ const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
348
+ method: "POST",
349
+ headers: billingHeaders(credential),
350
+ body: JSON.stringify({
351
+ PageNumber: 1,
352
+ PageSize: 100,
353
+ ProductCode: "p_tcaca",
354
+ Status: [0, 3],
355
+ PackageEndTimeRangeBegin: fmt(now),
356
+ PackageEndTimeRangeEnd: fmt(new Date(now.getTime() + 3185136e6))
357
+ }),
358
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
359
+ });
360
+ const envelope = await readEnvelope(response);
361
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
362
+ const wrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
363
+ const data = typeof wrapper["Response"] === "object" && wrapper["Response"] !== null ? wrapper["Response"] : {};
364
+ const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
365
+ const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
366
+ let total = 0;
367
+ let nearestExpiryMs;
368
+ let expiringSoon = 0;
369
+ const SOON_MS = 2592e5;
370
+ const parseDate = (raw) => {
371
+ if (typeof raw === "number" && raw > 0xe8d4a51000) return raw;
372
+ if (typeof raw === "string" && raw !== "") {
373
+ const parsed = Date.parse(raw);
374
+ if (!Number.isNaN(parsed)) return parsed;
375
+ }
376
+ };
377
+ const packages = [];
378
+ for (const raw of rawAccounts) {
379
+ if (typeof raw !== "object" || raw === null) continue;
380
+ const account = raw;
381
+ const num = (key) => typeof account[key] === "number" ? account[key] : 0;
382
+ const monthly = num("CapacityType") === 4;
383
+ const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
384
+ const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
385
+ const capped = remain < 0 ? 0 : remain;
386
+ const cycleEndMs = parseDate(account["CycleEndTime"]);
387
+ const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
388
+ const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
389
+ if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
390
+ total += capped;
391
+ if (expiresAtMs !== void 0) {
392
+ if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
393
+ if (expiresAtMs - Date.now() <= SOON_MS) expiringSoon += capped;
394
+ }
395
+ packages.push({
396
+ packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
397
+ remain: capped,
398
+ size,
399
+ monthly,
400
+ ...refreshAtMs === void 0 ? {} : { refreshAtMs },
401
+ ...expiresAtMs === void 0 ? {} : { expiresAtMs }
402
+ });
403
+ }
404
+ return {
405
+ total,
406
+ packages,
407
+ expiringSoon,
408
+ ...nearestExpiryMs === void 0 ? {} : { nearestExpiryMs }
409
+ };
410
+ }
411
+ /** Query today's check-in status without changing account state. */
412
+ async fetchCheckinStatus(credential) {
413
+ const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
414
+ method: "POST",
415
+ headers: billingHeaders(credential),
416
+ body: "{}",
417
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
418
+ });
419
+ const envelope = await readEnvelope(response);
420
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
421
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
422
+ const num = (key) => typeof data[key] === "number" ? data[key] : 0;
423
+ return {
424
+ active: data["active"] === true,
425
+ todayCheckedIn: data["today_checked_in"] === true,
426
+ streakDays: num("streak_days"),
427
+ dailyCredit: num("daily_credit"),
428
+ todayCredit: num("today_credit"),
429
+ isStreakDay: data["is_streak_day"] === true,
430
+ nextStreakDay: num("next_streak_day"),
431
+ streakBonusDays: num("streak_bonus_days"),
432
+ streakBonusCredit: num("streak_bonus_credit"),
433
+ ...typeof data["claim_button_text"] === "string" && data["claim_button_text"] !== "" ? { claimButtonText: data["claim_button_text"] } : {}
434
+ };
435
+ }
436
+ /** Claim today's check-in reward. The browser route guards this mutation. */
437
+ async claimDailyCheckin(credential) {
438
+ const response = await this.fetchImpl(`${billingBase(credential)}/v2/billing/meter/daily-checkin`, {
439
+ method: "POST",
440
+ headers: billingHeaders(credential),
441
+ body: "{}",
442
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
443
+ });
444
+ const envelope = await readEnvelope(response);
445
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
446
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
447
+ const numberField = (key) => typeof data[key] === "number" ? data[key] : 0;
448
+ return {
449
+ credit: numberField("credit"),
450
+ streakDays: numberField("streak_days"),
451
+ isStreakDay: data["is_streak_day"] === true
452
+ };
453
+ }
454
+ /** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
455
+ async credits(credential) {
456
+ try {
457
+ return {
458
+ ok: true,
459
+ data: await this.fetchCredits(credential)
460
+ };
461
+ } catch (error) {
462
+ return {
463
+ ok: false,
464
+ message: String(error)
465
+ };
466
+ }
467
+ }
468
+ };
469
+ //#endregion
470
+ //#region src/accounts.ts
471
+ /**
472
+ * Account pool: discovers every WorkBuddy credential snapshot the desktop app
473
+ * has left on this machine and hands out one healthy account per request,
474
+ * rotating away from any account the upstream has rate-limited.
475
+ *
476
+ * Discovery is read-only: the desktop app's files are never written. Each
477
+ * account is keyed by its billing identity (`uin`, falling back to `uid`), so
478
+ * re-logging the same account refreshes in place instead of creating a duplicate.
479
+ *
480
+ * @module dsh-workbuddy-xdpool/accounts
481
+ */
482
+ /** Live auth file name the WorkBuddy desktop app writes. */
483
+ const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
484
+ /** Snapshot files left behind by previous logins share this prefix. */
485
+ const SNAPSHOT_PREFIX = "workbuddy-desktop.";
486
+ /** Env override for the auth file or its directory. */
487
+ const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
488
+ function nonEmptyEnv(value) {
489
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
490
+ }
491
+ /**
492
+ * Platform-default directories holding the desktop app's auth files.
493
+ * Windows probes Local before Roaming; a redirected profile still resolves
494
+ * through the env location.
495
+ */
496
+ function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), env = process.env) {
497
+ if (platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth")];
498
+ if (platform === "win32") {
499
+ const local = nonEmptyEnv(env["LOCALAPPDATA"]) ?? join(home, "AppData", "Local");
500
+ const roaming = nonEmptyEnv(env["APPDATA"]) ?? join(home, "AppData", "Roaming");
501
+ return [join(local, "CodeBuddyExtension", "Data", "Public", "auth"), join(roaming, "CodeBuddyExtension", "Data", "Public", "auth")];
502
+ }
503
+ if (platform === "linux") {
504
+ const config = nonEmptyEnv(env["XDG_CONFIG_HOME"]) ?? join(home, ".config");
505
+ return [join(config, "CodeBuddyExtension", "Data", "Public", "auth")];
506
+ }
507
+ return [];
508
+ }
509
+ /** Normalize an expiry that may arrive in seconds or milliseconds. */
510
+ function expiryToMs(value) {
511
+ if (value <= 0) return 0;
512
+ return value > 0xe8d4a51000 ? value : value * 1e3;
513
+ }
514
+ function optionalString(value) {
515
+ return typeof value === "string" && value !== "" ? value : void 0;
516
+ }
517
+ /**
518
+ * Parse a WorkBuddy auth document. Accepts the nested desktop shape
519
+ * `{"auth":{...},"account":{...}}` and the flat panel shape; returns undefined
520
+ * when there is no usable access token.
521
+ */
522
+ function parseWorkBuddyAuth(text, sourcePath) {
523
+ let parsed;
524
+ try {
525
+ parsed = JSON.parse(text);
526
+ } catch {
527
+ return;
528
+ }
529
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
530
+ const document = parsed;
531
+ let auth;
532
+ let identity;
533
+ if (typeof document["auth"] === "object" && document["auth"] !== null) {
534
+ auth = document["auth"];
535
+ identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
536
+ } else {
537
+ auth = document;
538
+ identity = document;
539
+ }
540
+ const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
541
+ if (accessToken === "") return void 0;
542
+ const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
543
+ if (refreshExpiresAtMs !== void 0 && refreshExpiresAtMs > 0 && refreshExpiresAtMs < Date.now()) return;
544
+ return {
545
+ accessToken,
546
+ refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
547
+ expiresAtMs: typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0,
548
+ ...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
549
+ ...optionalString(identity["nickname"]) === void 0 ? {} : { nickname: optionalString(identity["nickname"]) },
550
+ ...optionalString(identity["uin"]) === void 0 ? {} : { uin: optionalString(identity["uin"]) },
551
+ ...optionalString(identity["uid"]) === void 0 ? {} : { uid: optionalString(identity["uid"]) },
552
+ ...optionalString(identity["enterpriseId"]) === void 0 ? {} : { enterpriseId: optionalString(identity["enterpriseId"]) },
553
+ domain: typeof auth["domain"] === "string" ? auth["domain"] : "",
554
+ sourcePath
555
+ };
556
+ }
557
+ /**
558
+ * Stable account id. `uin` is the billing identity the upstream keys on and
559
+ * survives re-login; `uid` is the fallback.
560
+ */
561
+ function workbuddyAccountId(credential) {
562
+ const stable = credential.uin ?? credential.uid ?? credential.nickname ?? "unknown";
563
+ return createHash("sha256").update(`workbuddy\0${stable}`).digest("hex").slice(0, 16);
564
+ }
565
+ /** Human label; distinguishes same-nickname accounts by uid prefix. */
566
+ function accountLabel(credential) {
567
+ const name = credential.nickname ?? "WorkBuddy";
568
+ const discriminator = (credential.uid ?? credential.uin ?? "").slice(0, 8);
569
+ return discriminator === "" ? name : `${name}#${discriminator}`;
570
+ }
571
+ /** List the auth files in one directory: the live file plus every snapshot. */
572
+ async function authFilesIn(dir) {
573
+ let entries;
574
+ try {
575
+ entries = await readdir(dir);
576
+ } catch {
577
+ return [];
578
+ }
579
+ const files = entries.filter((name) => {
580
+ if (name === "workbuddy-desktop.info") return true;
581
+ return name.startsWith(SNAPSHOT_PREFIX) && name.endsWith(".info");
582
+ });
583
+ files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
584
+ return files.map((name) => join(dir, name));
585
+ }
586
+ async function readCredential(path) {
587
+ try {
588
+ return parseWorkBuddyAuth(await readFile(path, "utf8"), path);
589
+ } catch {
590
+ return;
591
+ }
592
+ }
593
+ /** Every directory the pool should scan, in probe order. */
594
+ function candidateAuthDirs(env = process.env) {
595
+ const dirs = [];
596
+ const override = nonEmptyEnv(env[WORKBUDDY_AUTH_FILE_ENV]);
597
+ if (override !== void 0) dirs.push(override.toLowerCase().endsWith(".info") ? resolve(override, "..") : override);
598
+ dirs.push(...defaultDesktopAuthDirs(process.env["DSH_TEST_PLATFORM"]));
599
+ return dirs;
600
+ }
601
+ /**
602
+ * Read-only pool of every discovered WorkBuddy account, with rate-limit
603
+ * cooldown and round-robin failover.
604
+ */
605
+ var WorkBuddyAccountPool = class {
606
+ logger;
607
+ authDirs;
608
+ cooldownMs;
609
+ client;
610
+ refreshMarginMs;
611
+ accounts = [];
612
+ cursor = 0;
613
+ lastScanAtMs = 0;
614
+ preferredId;
615
+ refreshInflight = /* @__PURE__ */ new Map();
616
+ constructor(options = {}) {
617
+ this.logger = options.logger;
618
+ this.authDirs = options.authDirs ?? candidateAuthDirs();
619
+ this.cooldownMs = options.cooldownMs ?? 6e4;
620
+ this.client = options.client;
621
+ this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
622
+ }
623
+ /**
624
+ * Re-apply configuration that only affects discovery and cooldown policy,
625
+ * without rebuilding the pool. A later `scan()` uses the new auth dirs and
626
+ * cooldown window; existing accounts keep their in-memory state.
627
+ */
628
+ applyConfig(options) {
629
+ if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
630
+ if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
631
+ }
632
+ /** Rescan the auth directories and merge newly discovered accounts. */
633
+ async scan() {
634
+ const found = [];
635
+ for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
636
+ const credential = await readCredential(file);
637
+ if (credential !== void 0) found.push(credential);
638
+ }
639
+ const byId = /* @__PURE__ */ new Map();
640
+ for (const account of this.accounts) byId.set(account.id, account);
641
+ for (const credential of found) {
642
+ const id = workbuddyAccountId(credential);
643
+ const existing = byId.get(id);
644
+ if (existing === void 0) {
645
+ byId.set(id, {
646
+ id,
647
+ label: accountLabel(credential),
648
+ credential,
649
+ cooldownUntilMs: 0,
650
+ modelCooldowns: {},
651
+ rateLimitHits: 0
652
+ });
653
+ continue;
654
+ }
655
+ if ((credential.expiresAtMs ?? 0) > (existing.credential.expiresAtMs ?? 0)) byId.set(id, {
656
+ ...existing,
657
+ credential,
658
+ label: accountLabel(credential)
659
+ });
660
+ }
661
+ this.accounts = [...byId.values()];
662
+ this.lastScanAtMs = Date.now();
663
+ return this.accounts;
664
+ }
665
+ /** All accounts, cooldown state included. */
666
+ list(region) {
667
+ if (region === void 0) return this.accounts;
668
+ return this.accounts.filter((account) => regionOf(account.credential.domain) === region);
669
+ }
670
+ /**
671
+ * Accounts currently eligible to serve a request.
672
+ *
673
+ * With a `modelId`, an account is eligible when it is not account-wide cooled
674
+ * AND that model is not cooling on it — so a 429 on `hy4-preview` only keeps
675
+ * that model out while `hy3` on the same account stays usable. Without a
676
+ * model id the legacy account-wide check applies (callers that cannot name a
677
+ * model, e.g. CLI diagnostics).
678
+ */
679
+ available(now, modelId, region) {
680
+ return this.accounts.filter((account) => {
681
+ if (account.cooldownUntilMs > now) return false;
682
+ if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
683
+ if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
684
+ return true;
685
+ });
686
+ }
687
+ /**
688
+ * Pick the next usable account for an optional model. Scans on first use,
689
+ * and rescans when every known account is cooling down — a fresh desktop
690
+ * login is the usual way out of an exhausted pool. A preferred
691
+ * (user-selected) account that is healthy is tried first; otherwise the
692
+ * cursor round-robins so consecutive requests spread across accounts and a
693
+ * still-cooling preferred account is skipped.
694
+ */
695
+ async acquire(modelId, region) {
696
+ if (this.accounts.length === 0) await this.scan();
697
+ let pool = this.available(Date.now(), modelId, region);
698
+ if (pool.length === 0) {
699
+ await this.scan();
700
+ pool = this.available(Date.now(), modelId, region);
701
+ }
702
+ if (pool.length === 0) return void 0;
703
+ let start = this.cursor % pool.length;
704
+ if (this.preferredId !== void 0) {
705
+ const preferredIndex = pool.findIndex((account) => account.id === this.preferredId);
706
+ if (preferredIndex !== -1) start = preferredIndex;
707
+ }
708
+ for (let step = 0; step < pool.length; step += 1) {
709
+ const account = pool[(start + step) % pool.length];
710
+ if (account === void 0) continue;
711
+ await this.ensureFresh(account);
712
+ this.cursor = (start + step + 1) % pool.length;
713
+ return account;
714
+ }
715
+ return pool[0];
716
+ }
717
+ /** Pin the account the plugin card should prefer; tokens stay out of settings. */
718
+ prefer(accountId) {
719
+ this.preferredId = accountId;
720
+ }
721
+ /** Best-effort refresh of one account after a session-dead upstream answer. */
722
+ async refreshAccount(accountId) {
723
+ const account = this.accounts.find((item) => item.id === accountId);
724
+ if (account === void 0) return;
725
+ await this.ensureFresh(account);
726
+ }
727
+ /**
728
+ * Refresh the account's access token when it is within the margin (or already
729
+ * expired), in-flight de-duped per account. A failed refresh keeps the
730
+ * existing token when it has not yet expired, so an unreachable refresh
731
+ * endpoint never takes down a working session.
732
+ */
733
+ async ensureFresh(account) {
734
+ if (this.client === void 0) return;
735
+ const credential = account.credential;
736
+ if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
737
+ const existing = this.refreshInflight.get(account.id);
738
+ if (existing !== void 0) {
739
+ await existing;
740
+ return;
741
+ }
742
+ const run = (async () => {
743
+ if (credential.refreshToken === "") {
744
+ if (credential.expiresAtMs > Date.now() + 3e4) return;
745
+ this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
746
+ return;
747
+ }
748
+ try {
749
+ const outcome = await this.client.refreshToken(credential);
750
+ account.credential = {
751
+ ...credential,
752
+ accessToken: outcome.accessToken,
753
+ ...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
754
+ expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
755
+ ...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
756
+ };
757
+ this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
758
+ } catch (error) {
759
+ if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
760
+ else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
761
+ }
762
+ })();
763
+ this.refreshInflight.set(account.id, run);
764
+ try {
765
+ await run;
766
+ } finally {
767
+ this.refreshInflight.delete(account.id);
768
+ }
769
+ }
770
+ /**
771
+ * Mark an account (or one of its models) rate-limited.
772
+ *
773
+ * With `modelId`, only that model on the account is cooled — the account's
774
+ * other models stay in rotation, matching the upstream's per-model rate
775
+ * limit ("可切换其他模型继续使用"). Without a model id the whole account is
776
+ * cooled, which callers should reserve for limits that truly span every model.
777
+ */
778
+ penalize(accountId, resetAtMs, modelId) {
779
+ const account = this.accounts.find((item) => item.id === accountId);
780
+ if (account === void 0) return;
781
+ account.rateLimitHits += 1;
782
+ const until = resetAtMs ?? Date.now() + this.cooldownMs;
783
+ if (modelId !== void 0 && modelId !== "") {
784
+ account.modelCooldowns[modelId] = Math.max(account.modelCooldowns[modelId] ?? 0, until);
785
+ this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on model ${modelId}; cooling that model until ${new Date(until).toISOString()}`);
786
+ return;
787
+ }
788
+ account.cooldownUntilMs = Math.max(account.cooldownUntilMs, until);
789
+ this.logger?.warn(`dsh-workbuddy-xdpool: account ${account.label} rate-limited; cooling until ${new Date(until).toISOString()}`);
790
+ }
791
+ /** Clear all cooldowns (account-wide and per-model), e.g. from a reset command. */
792
+ resetCooldowns() {
793
+ for (const account of this.accounts) {
794
+ account.cooldownUntilMs = 0;
795
+ account.modelCooldowns = {};
796
+ account.rateLimitHits = 0;
797
+ }
798
+ }
799
+ /** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
800
+ * the account as a whole stays usable when only one model is limited). */
801
+ status() {
802
+ const now = Date.now();
803
+ return {
804
+ count: this.accounts.length,
805
+ cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
806
+ lastScanAtMs: this.lastScanAtMs
807
+ };
808
+ }
809
+ };
810
+ //#endregion
811
+ //#region src/catalog.ts
812
+ /** Static fallback used before the first live catalog fetch. */
813
+ const FALLBACK_WORKBUDDY_MODELS = [
814
+ {
815
+ id: "glm-5.3",
816
+ name: "GLM-5.3",
817
+ contextWindow: 2e5,
818
+ maxOutputTokens: 128e3,
819
+ supportsImages: true
820
+ },
821
+ {
822
+ id: "glm-5.3-flash",
823
+ name: "GLM-5.3-Flash",
824
+ contextWindow: 2e5,
825
+ maxOutputTokens: 128e3,
826
+ supportsImages: true
827
+ },
828
+ {
829
+ id: "glm-5.2",
830
+ name: "GLM-5.2",
831
+ contextWindow: 2e5,
832
+ maxOutputTokens: 128e3,
833
+ supportsImages: true
834
+ },
835
+ {
836
+ id: "glm-5.1",
837
+ name: "GLM-5.1",
838
+ contextWindow: 2e5,
839
+ maxOutputTokens: 128e3,
840
+ supportsImages: false
841
+ },
842
+ {
843
+ id: "deepseek-v4-pro",
844
+ name: "DeepSeek-V4-Pro",
845
+ contextWindow: 2e5,
846
+ maxOutputTokens: 128e3,
847
+ supportsImages: true
848
+ },
849
+ {
850
+ id: "deepseek-v4-flash",
851
+ name: "DeepSeek-V4-Flash",
852
+ contextWindow: 2e5,
853
+ maxOutputTokens: 128e3,
854
+ supportsImages: true
855
+ },
856
+ {
857
+ id: "kimi-k3",
858
+ name: "Kimi-K3",
859
+ contextWindow: 2e5,
860
+ maxOutputTokens: 128e3,
861
+ supportsImages: true
862
+ },
863
+ {
864
+ id: "minimax-m3",
865
+ name: "MiniMax-M3",
866
+ contextWindow: 2e5,
867
+ maxOutputTokens: 128e3,
868
+ supportsImages: true
869
+ },
870
+ {
871
+ id: "hy3",
872
+ name: "Hy3",
873
+ contextWindow: 2e5,
874
+ maxOutputTokens: 128e3,
875
+ supportsImages: true
876
+ },
877
+ {
878
+ id: "hy4-preview",
879
+ name: "Hy4-Preview",
880
+ contextWindow: 1e6,
881
+ maxOutputTokens: 128e3,
882
+ supportsImages: true
883
+ }
884
+ ];
885
+ /** Live catalog with a static fallback behind it. */
886
+ var WorkBuddyCatalog = class {
887
+ models = FALLBACK_WORKBUDDY_MODELS;
888
+ listeners = /* @__PURE__ */ new Set();
889
+ /** User's model selection. Empty object = follow the catalog unfiltered. */
890
+ selection = {};
891
+ current() {
892
+ return this.models;
893
+ }
894
+ /**
895
+ * The models DSH should actually offer, after applying the user's selection:
896
+ * disabled models are dropped, an explicit image list overrides the upstream
897
+ * capability flag, and a per-model budget caps the advertised window.
898
+ *
899
+ * An absent `enabledModelIds` means "everything" — a fresh install with no
900
+ * saved selection must not present an empty picker.
901
+ */
902
+ visible() {
903
+ const enabled = this.selection.enabledModelIds;
904
+ const allow = enabled === void 0 ? void 0 : new Set(enabled);
905
+ const images = this.selection.imageModelIds;
906
+ const imageSet = images === void 0 ? void 0 : new Set(images);
907
+ const budgets = this.selection.contextBudgets;
908
+ return this.models.filter((model) => allow === void 0 || allow.has(model.id)).map((model) => {
909
+ const next = { ...model };
910
+ if (imageSet !== void 0) next.supportsImages = imageSet.has(model.id);
911
+ const budget = budgets?.[model.id];
912
+ if (budget !== void 0 && budget > 0 && budget < next.contextWindow) next.contextWindow = budget;
913
+ return next;
914
+ });
915
+ }
916
+ /** Replace the catalog and notify the adapter to rebuild its model list. */
917
+ update(models) {
918
+ if (models.length === 0) return;
919
+ this.models = models;
920
+ this.notify();
921
+ }
922
+ /** Restore the static fallback, e.g. when the upstream stops answering. */
923
+ reset() {
924
+ this.models = FALLBACK_WORKBUDDY_MODELS;
925
+ this.notify();
926
+ }
927
+ /** Replace the user's selection; the adapter rebuilds from `visible()`. */
928
+ applySelection(selection) {
929
+ this.selection = selection;
930
+ this.notify();
931
+ }
932
+ /** The selection currently in force, for the card's save round-trip. */
933
+ currentSelection() {
934
+ return this.selection;
935
+ }
936
+ onChange(listener) {
937
+ this.listeners.add(listener);
938
+ return () => this.listeners.delete(listener);
939
+ }
940
+ find(id) {
941
+ return this.models.find((model) => model.id === id);
942
+ }
943
+ /** Replace the catalog from the live upstream list; keeps the fallback if empty. */
944
+ updateFromUpstream(models) {
945
+ this.update(catalogFromUpstream(models));
946
+ }
947
+ notify() {
948
+ for (const listener of this.listeners) listener();
949
+ }
950
+ };
951
+ /** Convert one upstream catalog entry into the plugin's model-info shape. */
952
+ function toModelInfo(model) {
953
+ return {
954
+ id: model.id,
955
+ name: model.name,
956
+ contextWindow: model.contextWindow,
957
+ maxOutputTokens: model.maxTokens,
958
+ supportsImages: model.multimodal ?? false,
959
+ ...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
960
+ ...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
961
+ };
962
+ }
963
+ /** Map the live upstream list, falling back to the static list when empty. */
964
+ function catalogFromUpstream(models) {
965
+ if (models.length === 0) return FALLBACK_WORKBUDDY_MODELS;
966
+ return models.map(toModelInfo);
967
+ }
968
+ //#endregion
969
+ //#region src/adapter.ts
970
+ /**
971
+ * The `workbuddy-xdpool` pi-ai provider: one loopback-backed adapter registered
972
+ * into the Harness LLM seam, assembled from public `dsh-llm-pi-ai` extension
973
+ * points. Every model points at the shim; account rotation stays inside it.
974
+ *
975
+ * Assembly (createProvider + openAICompletionsApi + inert auth plane + the
976
+ * shim's in-process secret as apiKey) follows corrinehu/dsh-workbuddy-connect
977
+ * (MIT, Copyright (c) 2026 Corrine Hu) and dingminhua/dsh-connect-workbuddy
978
+ * (MIT), both designed and validated against this host.
979
+ *
980
+ * @module dsh-workbuddy-xdpool/adapter
981
+ */
982
+ /** Provider route this bundle owns. */
983
+ /** Provider route this bundle owns for the domestic (CN) gateway. */
984
+ const WORKBUDDY_POOL_PROVIDER = "workbuddy-xdpool";
985
+ /** The provider id each region registers as. */
986
+ const POOL_PROVIDER_BY_REGION = {
987
+ cn: WORKBUDDY_POOL_PROVIDER,
988
+ global: "workbuddy-xdpool-global"
989
+ };
990
+ /** Display name each region registers under, so the picker can group them. */
991
+ const POOL_NAME_BY_REGION = {
992
+ cn: "WorkBuddy XD Pool(国内版)",
993
+ global: "WorkBuddy XD Pool(国际版)"
994
+ };
995
+ /** Provider idle ceiling while one stream read is outstanding. */
996
+ const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 3e5;
997
+ /** Image-request budgets at the dsh-llm-pi-ai defaults. */
998
+ const REQUEST_IMAGE_BUDGETS = {
999
+ maxRequestImageBytes: 20971520,
1000
+ requestImagePixelBudget: 4194304,
1001
+ requestImageMaxBytes: 1048576
1002
+ };
1003
+ /**
1004
+ * Inert pi-ai auth plane. The route authenticates only through the shim shared
1005
+ * secret resolved per request, so pi-ai's own credential lifecycle must never
1006
+ * manufacture a credential for it.
1007
+ */
1008
+ const INERT_AUTH = {
1009
+ credentials: {
1010
+ async read() {},
1011
+ async list() {
1012
+ return [];
1013
+ },
1014
+ async modify() {
1015
+ throw new Error("dsh-workbuddy-xdpool: this route has no pi-ai credential lifecycle");
1016
+ },
1017
+ async delete() {}
1018
+ },
1019
+ authContext: {
1020
+ async env() {},
1021
+ async fileExists() {
1022
+ return false;
1023
+ }
1024
+ }
1025
+ };
1026
+ /** No per-token pricing is knowable for a subscription quota; report zero. */
1027
+ const NO_COST = {
1028
+ input: 0,
1029
+ output: 0,
1030
+ cacheRead: 0,
1031
+ cacheWrite: 0
1032
+ };
1033
+ /** pi-ai input modalities: images only when the catalog advertises them. */
1034
+ function modelInput(info) {
1035
+ return info.supportsImages ? ["text", "image"] : ["text"];
1036
+ }
1037
+ /** Map only levels the catalog advertises; undeclared DSH levels stay off. */
1038
+ function thinkingLevelMap(info) {
1039
+ const efforts = info.supportedEfforts;
1040
+ if (efforts === void 0 || efforts.length === 0) return void 0;
1041
+ const levels = [
1042
+ "minimal",
1043
+ "low",
1044
+ "medium",
1045
+ "high",
1046
+ "xhigh",
1047
+ "max"
1048
+ ];
1049
+ const map = {};
1050
+ for (const level of levels) map[level] = efforts.includes(level) ? level : null;
1051
+ map["off"] = null;
1052
+ return map;
1053
+ }
1054
+ /** Middle-dot separator: unambiguous between the model's own hyphens and the
1055
+ * rate/badge suffix. Matches the LaoDing family convention. */
1056
+ const DISPLAY_SEPARATOR = " · ";
1057
+ /** Map an upstream tag code to the localized promo label shown next to the
1058
+ * credit rate in the model picker. Both `free` and `limited-free` collapse to
1059
+ * the same short label so the dropdown row stays scannable. */
1060
+ const TAG_LABEL = {
1061
+ "free": "限时免费",
1062
+ "limited-free": "限时免费",
1063
+ "night-discount": "夜间折扣"
1064
+ };
1065
+ /** Resolve the display suffix (`xN.NN`, promo badges) for one catalog row.
1066
+ * Returns `undefined` when there's nothing to show — the name is left alone
1067
+ * so we don't tack a trailing separator on a plain model. */
1068
+ function displaySuffix(info) {
1069
+ const parts = [];
1070
+ if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
1071
+ for (const tag of info.tags ?? []) {
1072
+ const label = TAG_LABEL[tag];
1073
+ if (label !== void 0 && !parts.includes(label)) parts.push(label);
1074
+ }
1075
+ return parts.length === 0 ? void 0 : parts.join(DISPLAY_SEPARATOR);
1076
+ }
1077
+ /** Apply the catalog's rate + promo badges to one model's display name.
1078
+ * Display-only: the wire request is built from `model.id`, so renaming here
1079
+ * cannot affect routing, token, or upstream accounting. DSH 0.1.2's composer
1080
+ * (`ModelSelect`) renders `model.name` only, which is why the rate and
1081
+ * badges ride the name rather than a separate description column. */
1082
+ function withCatalogDisplay(name, info) {
1083
+ const suffix = displaySuffix(info);
1084
+ return suffix === void 0 ? name : `${name}${DISPLAY_SEPARATOR}${suffix}`;
1085
+ }
1086
+ /** Build one pi-ai model descriptor pointing at the loopback shim. */
1087
+ function toPiModel(info, baseUrl, providerId) {
1088
+ const map = thinkingLevelMap(info);
1089
+ return {
1090
+ id: info.id,
1091
+ name: withCatalogDisplay(info.name, info),
1092
+ api: "openai-completions",
1093
+ provider: providerId,
1094
+ baseUrl,
1095
+ input: modelInput(info),
1096
+ cost: NO_COST,
1097
+ contextWindow: info.contextWindow,
1098
+ maxTokens: info.maxOutputTokens,
1099
+ reasoning: map !== void 0,
1100
+ ...map === void 0 ? {} : { thinkingLevelMap: map },
1101
+ compat: { supportsReasoningEffort: map !== void 0 }
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Assemble the adapter. `getModels` re-reads the live catalog, and every
1106
+ * model's `baseUrl` is re-resolved per read so the shim's ephemeral port
1107
+ * applies from the first snapshot after startup. Call only after `shim.ready`.
1108
+ */
1109
+ function createWorkBuddyAdapter(options) {
1110
+ const { shim, catalog } = options;
1111
+ const providerId = options.providerId ?? "workbuddy-xdpool";
1112
+ const displayName = options.displayName ?? "WorkBuddy XD Pool";
1113
+ const buildModels = () => {
1114
+ const baseUrl = `${shim.baseUrl()}/v1`;
1115
+ return catalog.visible().map((info) => toPiModel(info, baseUrl, providerId));
1116
+ };
1117
+ const provider = {
1118
+ ...createProvider({
1119
+ id: providerId,
1120
+ name: displayName,
1121
+ auth: { apiKey: {
1122
+ name: "WorkBuddy XD Pool loopback secret",
1123
+ async resolve({ credential }) {
1124
+ const apiKey = credential?.key;
1125
+ return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
1126
+ auth: { apiKey },
1127
+ source: "WorkBuddy XD Pool"
1128
+ };
1129
+ }
1130
+ } },
1131
+ models: buildModels(),
1132
+ api: openAICompletionsApi()
1133
+ }),
1134
+ getModels: () => buildModels()
1135
+ };
1136
+ const profile = {
1137
+ provider: providerId,
1138
+ displayName,
1139
+ streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
1140
+ retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
1141
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
1142
+ modelErrors: /* @__PURE__ */ new Map(),
1143
+ ...REQUEST_IMAGE_BUDGETS,
1144
+ piProvider: provider
1145
+ };
1146
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1147
+ return {
1148
+ providerId,
1149
+ displayName,
1150
+ adapter: new PiAiAdapter({
1151
+ profiles: () => profiles,
1152
+ auth: INERT_AUTH,
1153
+ resolveApiKey: async () => shim.token()
1154
+ }),
1155
+ buildModels,
1156
+ invalidate: () => {
1157
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
1158
+ }
1159
+ };
1160
+ }
1161
+ //#endregion
1162
+ //#region src/shim.ts
1163
+ /**
1164
+ * Loopback OpenAI-compatible endpoint with multi-account failover.
1165
+ *
1166
+ * The pi-ai provider points here. Each chat request acquires an account from
1167
+ * the pool; when the upstream answers with a rate limit, the shim cools that
1168
+ * account down, takes the next one, and retries in the same request — so a
1169
+ * `429 soft_rate` never reaches the user as a turn failure.
1170
+ *
1171
+ * Security model (Host/Origin loopback checks, constant-time bearer compare,
1172
+ * random port, in-process secret, body cap, error→status mapping) follows
1173
+ * corrinehu/dsh-workbuddy-connect (MIT, Copyright (c) 2026 Corrine Hu), which
1174
+ * designed and validated it.
1175
+ *
1176
+ * @module dsh-workbuddy-xdpool/shim
1177
+ */
1178
+ const REQUEST_BODY_LIMIT = 67108864;
1179
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1180
+ "127.0.0.1",
1181
+ "localhost",
1182
+ "[::1]"
1183
+ ]);
1184
+ /** HTTP status each upstream failure class surfaces as. */
1185
+ const KIND_STATUS = {
1186
+ hard_credit: 402,
1187
+ soft_rate: 429,
1188
+ session_dead: 401,
1189
+ not_found: 502,
1190
+ server: 502,
1191
+ client: 400
1192
+ };
1193
+ function hostnameOfHost(host) {
1194
+ let hostname = host.trim().toLowerCase();
1195
+ if (hostname.startsWith("[")) {
1196
+ const end = hostname.indexOf("]");
1197
+ return end === -1 ? hostname : hostname.slice(0, end + 1);
1198
+ }
1199
+ const colon = hostname.lastIndexOf(":");
1200
+ if (colon !== -1 && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
1201
+ return hostname;
1202
+ }
1203
+ /** Host must name loopback; drops DNS-rebinding attempts before routing. */
1204
+ function hostIsLoopback(host) {
1205
+ if (host === void 0 || host.trim() === "") return false;
1206
+ return LOOPBACK_HOSTS.has(hostnameOfHost(host));
1207
+ }
1208
+ /** A present Origin must be loopback; non-browser clients send none and pass. */
1209
+ function originIsLoopback(origin) {
1210
+ if (origin === void 0 || origin.trim() === "") return true;
1211
+ try {
1212
+ const { hostname } = new URL(origin);
1213
+ return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
1214
+ } catch {
1215
+ return false;
1216
+ }
1217
+ }
1218
+ /** Chat POSTs must carry a JSON body type (blocks simple-request CSRF). */
1219
+ function isJsonContentType(req) {
1220
+ const type = req.headers["content-type"];
1221
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
1222
+ }
1223
+ function writeJson(res, status, body) {
1224
+ const payload = JSON.stringify(body);
1225
+ res.writeHead(status, {
1226
+ "Content-Type": "application/json",
1227
+ "Content-Length": Buffer.byteLength(payload)
1228
+ });
1229
+ res.end(payload);
1230
+ }
1231
+ function writeOpenAIError(res, status, kind, message) {
1232
+ writeJson(res, status, { error: {
1233
+ message,
1234
+ type: kind,
1235
+ code: kind
1236
+ } });
1237
+ }
1238
+ /** True when an upstream failure body means the request overran the model's
1239
+ * context window (OpenAI `context_length_exceeded`, WorkBuddy code 11115 /
1240
+ * "input length too long"). Surfaced as a friendly hint, never auto-truncated. */
1241
+ function isContextTooLong(body) {
1242
+ if (body.includes("context_length_exceeded")) return true;
1243
+ if (body.includes("input length too long")) return true;
1244
+ if (body.includes("\"code\":11115")) return true;
1245
+ if (/exceeds?\s+(the\s+)?(model\s+)?context\s+(window|limit)/iu.test(body)) return true;
1246
+ return false;
1247
+ }
1248
+ function readBody(req) {
1249
+ return new Promise((resolve, reject) => {
1250
+ const chunks = [];
1251
+ let size = 0;
1252
+ req.on("data", (chunk) => {
1253
+ size += chunk.length;
1254
+ if (size > REQUEST_BODY_LIMIT) {
1255
+ reject(/* @__PURE__ */ new Error("request body too large"));
1256
+ req.destroy();
1257
+ return;
1258
+ }
1259
+ chunks.push(chunk);
1260
+ });
1261
+ req.on("end", () => resolve(Buffer.concat(chunks)));
1262
+ req.on("error", reject);
1263
+ });
1264
+ }
1265
+ function createWorkBuddyShim(options) {
1266
+ const { pool, client, catalog } = options;
1267
+ const region = options.region;
1268
+ const logger = options.logger;
1269
+ const maxAttempts = options.maxAttempts ?? 8;
1270
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
1271
+ function bearerOk(req) {
1272
+ const header = req.headers.authorization;
1273
+ if (typeof header !== "string") return false;
1274
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
1275
+ if (match === null) return false;
1276
+ const a = Buffer.from(match[1]);
1277
+ const b = Buffer.from(SHARED_SECRET);
1278
+ if (a.length !== b.length) return false;
1279
+ return timingSafeEqual(a, b);
1280
+ }
1281
+ const server = createServer((req, res) => {
1282
+ handle(req, res);
1283
+ });
1284
+ const ready = new Promise((resolve, reject) => {
1285
+ server.once("listening", () => resolve());
1286
+ server.once("error", reject);
1287
+ });
1288
+ server.listen(0, "127.0.0.1");
1289
+ const baseUrl = () => {
1290
+ const address = server.address();
1291
+ if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
1292
+ return `http://127.0.0.1:${address.port}`;
1293
+ };
1294
+ async function handle(req, res) {
1295
+ try {
1296
+ if (!hostIsLoopback(req.headers.host)) {
1297
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
1298
+ return;
1299
+ }
1300
+ if (!originIsLoopback(req.headers.origin)) {
1301
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
1302
+ return;
1303
+ }
1304
+ if (!bearerOk(req)) {
1305
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
1306
+ return;
1307
+ }
1308
+ const url = req.url ?? "/";
1309
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
1310
+ writeJson(res, 200, {
1311
+ ok: true,
1312
+ pool: pool.status()
1313
+ });
1314
+ return;
1315
+ }
1316
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
1317
+ writeJson(res, 200, {
1318
+ object: "list",
1319
+ data: catalog.current().map((model) => ({
1320
+ id: model.id,
1321
+ object: "model",
1322
+ created: 0,
1323
+ owned_by: "workbuddy"
1324
+ }))
1325
+ });
1326
+ return;
1327
+ }
1328
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
1329
+ await chatCompletions(req, res);
1330
+ return;
1331
+ }
1332
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
1333
+ } catch (error) {
1334
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
1335
+ else res.end();
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Serve one chat completion, rotating accounts on rate limits.
1340
+ *
1341
+ * A rate-limited account is cooled for exactly the window the upstream
1342
+ * reports (when parseable) and the next account is tried immediately, so a
1343
+ * pool with any healthy member never surfaces a 429 to the caller.
1344
+ */
1345
+ async function chatCompletions(req, res) {
1346
+ if (!isJsonContentType(req)) {
1347
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
1348
+ return;
1349
+ }
1350
+ const raw = (await readBody(req)).toString("utf8");
1351
+ const prepared = client.prepareChatBody(raw);
1352
+ const controller = new AbortController();
1353
+ req.on("close", () => controller.abort());
1354
+ let modelId;
1355
+ try {
1356
+ const parsed = JSON.parse(raw);
1357
+ modelId = typeof parsed.model === "string" && parsed.model !== "" ? parsed.model : void 0;
1358
+ } catch {
1359
+ modelId = void 0;
1360
+ }
1361
+ const tried = [];
1362
+ let last;
1363
+ let exhaustedByRateLimit = false;
1364
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1365
+ if (controller.signal.aborted) return;
1366
+ const account = await pool.acquire(modelId, region);
1367
+ if (account === void 0) {
1368
+ if (exhaustedByRateLimit && last !== void 0) {
1369
+ const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
1370
+ writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `${subject} (tried ${tried.length}: ${tried.join(", ")}); resets at the upstream window — ${last.message.slice(0, 200)}`);
1371
+ return;
1372
+ }
1373
+ writeOpenAIError(res, 401, "not_signed_in", "no WorkBuddy credential found; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)");
1374
+ return;
1375
+ }
1376
+ tried.push(account.label);
1377
+ const result = await client.chatStream(account.credential, prepared, controller.signal);
1378
+ if (result.ok) {
1379
+ logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
1380
+ res.writeHead(200, {
1381
+ "Content-Type": "text/event-stream",
1382
+ "Cache-Control": "no-cache",
1383
+ "Connection": "keep-alive",
1384
+ "X-Accel-Buffering": "no"
1385
+ });
1386
+ let sawDone = false;
1387
+ const body = Readable.fromWeb(result.response.body);
1388
+ body.on("data", (chunk) => {
1389
+ if (chunk.includes("[DONE]")) sawDone = true;
1390
+ });
1391
+ body.on("error", (error) => {
1392
+ logger?.warn("dsh-workbuddy-xdpool: upstream stream failed mid-flight", error);
1393
+ if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
1394
+ });
1395
+ body.pipe(res);
1396
+ return;
1397
+ }
1398
+ last = {
1399
+ kind: result.kind,
1400
+ status: result.status,
1401
+ message: result.message
1402
+ };
1403
+ if (result.kind === "session_dead") {
1404
+ logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
1405
+ await pool.refreshAccount(account.id);
1406
+ continue;
1407
+ }
1408
+ if (result.kind !== "soft_rate") break;
1409
+ exhaustedByRateLimit = true;
1410
+ pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
1411
+ logger?.warn(`dsh-workbuddy-xdpool: ${account.label} rate-limited on ${modelId ?? "(no model)"} (attempt ${attempt + 1}/${maxAttempts}); rotating`);
1412
+ }
1413
+ if (last === void 0) {
1414
+ writeOpenAIError(res, 500, "internal", "chat request exhausted without a result");
1415
+ return;
1416
+ }
1417
+ if (isContextTooLong(last.message)) {
1418
+ writeOpenAIError(res, 400, "context_length_exceeded", `${modelId === void 0 ? "the conversation exceeds this model's context window" : `the conversation exceeds ${modelId}'s context window`}. Shorten the conversation, start a new chat, or pick a model with a larger window (e.g. hy4-preview).`);
1419
+ return;
1420
+ }
1421
+ writeOpenAIError(res, KIND_STATUS[last.kind], last.kind, `workbuddy upstream ${last.kind} (http ${last.status}) after ${tried.length} account(s) [${tried.join(" → ")}]: ${last.message.slice(0, 400)}`);
1422
+ }
1423
+ return {
1424
+ ready,
1425
+ baseUrl,
1426
+ token: () => SHARED_SECRET,
1427
+ close: () => new Promise((resolve, reject) => {
1428
+ server.close(() => resolve());
1429
+ server.closeAllConnections();
1430
+ server.once("error", reject);
1431
+ })
1432
+ };
1433
+ }
1434
+ //#endregion
1435
+ //#region src/status.ts
1436
+ /** Build the status document. Never throws. */
1437
+ async function buildStatus(options) {
1438
+ const { pool, catalog, client } = options;
1439
+ const accounts = pool.list();
1440
+ const now = Date.now();
1441
+ const rows = [];
1442
+ for (const account of accounts) {
1443
+ const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
1444
+ modelId,
1445
+ until: new Date(until).toISOString()
1446
+ }));
1447
+ const row = {
1448
+ id: account.id,
1449
+ label: account.label,
1450
+ ...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
1451
+ domain: account.credential.domain,
1452
+ ...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
1453
+ cooling: account.cooldownUntilMs > now,
1454
+ ...account.cooldownUntilMs > now ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1455
+ ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
1456
+ rateLimitHits: account.rateLimitHits,
1457
+ sourcePath: account.credential.sourcePath
1458
+ };
1459
+ if (options.includeCredits === true && !row.cooling) try {
1460
+ Object.assign(row, { credits: await client.fetchCredits(account.credential) });
1461
+ } catch (error) {
1462
+ Object.assign(row, { creditsError: String(error).slice(0, 200) });
1463
+ }
1464
+ rows.push(row);
1465
+ }
1466
+ const cooling = rows.filter((row) => row.cooling).length;
1467
+ const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
1468
+ return {
1469
+ ok: accounts.length > 0 && cooling < accounts.length,
1470
+ accounts: rows,
1471
+ ...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
1472
+ cooling,
1473
+ models: catalog.current().map((model) => ({
1474
+ id: model.id,
1475
+ name: model.name,
1476
+ ...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
1477
+ ...model.tags === void 0 ? {} : { tags: model.tags }
1478
+ })),
1479
+ shim: options.shim ?? { running: false }
1480
+ };
1481
+ }
1482
+ /** Format the status document for a terminal. */
1483
+ function formatStatus(status) {
1484
+ const lines = [];
1485
+ lines.push(`WorkBuddy XD Pool: ${status.accounts.length} account(s), ${status.cooling} cooling`);
1486
+ lines.push(`Shim: ${status.shim.running ? "running" : "stopped"}${status.shim.baseUrl === void 0 ? "" : ` at ${status.shim.baseUrl}`}`);
1487
+ lines.push("");
1488
+ if (status.accounts.length === 0) {
1489
+ lines.push("No WorkBuddy credential found. Sign in on the WorkBuddy desktop app,");
1490
+ lines.push("then run: dsh plugin --profile desktop exec dsh-workbuddy-xdpool import <key>");
1491
+ return lines.join("\n");
1492
+ }
1493
+ for (const account of status.accounts) {
1494
+ const flag = account.cooling ? "⏸ " : "▶ ";
1495
+ const active = account.id === status.activeAccountId ? " (next up)" : "";
1496
+ lines.push(`${flag}${account.label}${active}`);
1497
+ lines.push(` uid/uin : ${account.id} [${account.domain || "cn"}]`);
1498
+ if (account.expiresAt !== void 0) lines.push(` expires : ${account.expiresAt}`);
1499
+ if (account.credits !== void 0) {
1500
+ const { total } = account.credits;
1501
+ const parts = [];
1502
+ if (total !== void 0) parts.push(`total ${total}`);
1503
+ lines.push(` credits : ${parts.join(" | ") || "n/a"}`);
1504
+ }
1505
+ if (account.creditsError !== void 0) lines.push(` credits : query failed — ${account.creditsError}`);
1506
+ if (account.cooling && account.cooldownUntil !== void 0) lines.push(` cooldown : until ${account.cooldownUntil} (hits ${account.rateLimitHits})`);
1507
+ if (account.modelCooldowns !== void 0 && account.modelCooldowns.length > 0) for (const mc of account.modelCooldowns) lines.push(` model-cool: ${mc.modelId} until ${mc.until}`);
1508
+ lines.push(` source : ${account.sourcePath}`);
1509
+ lines.push("");
1510
+ }
1511
+ return lines.join("\n").trimEnd();
1512
+ }
1513
+ /** Format the per-model credit multipliers. */
1514
+ function formatRates(status) {
1515
+ const lines = ["Model credit multipliers:"];
1516
+ for (const model of status.models) {
1517
+ const rate = model.multiplier === void 0 ? "x?" : `x${model.multiplier.toFixed(2)}`;
1518
+ lines.push(` ${model.name.padEnd(20)} ${rate}`);
1519
+ }
1520
+ return lines.join("\n");
1521
+ }
1522
+ //#endregion
1523
+ //#region src/status-paths.ts
1524
+ /**
1525
+ * Node-free constants and types shared by the Host and browser halves of the
1526
+ * WorkBuddy XD Pool settings card.
1527
+ *
1528
+ * Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
1529
+ * `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
1530
+ * JSON document into a shape that stays token-free and matches what the
1531
+ * browser card renders. Route paths are plugin-owned and mounted on the Host's
1532
+ * same-origin web server (see `src/web-status.ts`).
1533
+ *
1534
+ * @module dsh-workbuddy-xdpool/status-paths
1535
+ */
1536
+ /** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
1537
+ const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
1538
+ /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
1539
+ const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
1540
+ /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
1541
+ const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
1542
+ /** Plugin-owned daily check-in action endpoint (claim today's reward). */
1543
+ const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
1544
+ /** Plugin-owned model-selection save endpoint (writes the settings section). */
1545
+ const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
1546
+ //#endregion
1547
+ //#region src/web-status.ts
1548
+ /** Redact token-like content before it crosses to the browser. */
1549
+ function safeMessage(error) {
1550
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
1551
+ }
1552
+ function json(res, status, body) {
1553
+ const payload = JSON.stringify(body);
1554
+ res.writeHead(status, {
1555
+ "Content-Type": "application/json",
1556
+ "Content-Length": Buffer.byteLength(payload)
1557
+ });
1558
+ res.end(payload);
1559
+ }
1560
+ /** Loopback browser origins only; other devices are refused. */
1561
+ function loopbackOrigin(req) {
1562
+ const origin = req.headers.origin;
1563
+ if (origin === void 0) return true;
1564
+ try {
1565
+ const { hostname } = new URL(origin);
1566
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1571
+ /** Smallest possible POST body reader, capped so a hung or oversized body
1572
+ * cannot pin memory on the Host. Returns `{}` for an empty body. */
1573
+ function readJsonBody(req) {
1574
+ return new Promise((resolve, reject) => {
1575
+ const chunks = [];
1576
+ let size = 0;
1577
+ const LIMIT = 65536;
1578
+ req.on("data", (chunk) => {
1579
+ size += chunk.length;
1580
+ if (size > LIMIT) {
1581
+ reject(/* @__PURE__ */ new Error("request body too large"));
1582
+ req.destroy();
1583
+ return;
1584
+ }
1585
+ chunks.push(chunk);
1586
+ });
1587
+ req.on("end", () => {
1588
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1589
+ if (text === "") {
1590
+ resolve({});
1591
+ return;
1592
+ }
1593
+ try {
1594
+ const parsed = JSON.parse(text);
1595
+ resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
1596
+ } catch {
1597
+ reject(/* @__PURE__ */ new Error("invalid JSON body"));
1598
+ }
1599
+ });
1600
+ req.on("error", reject);
1601
+ });
1602
+ }
1603
+ /**
1604
+ * Validate an untrusted selection payload.
1605
+ *
1606
+ * Returns undefined for anything malformed so the route answers 400 instead of
1607
+ * writing a partial selection into the settings file. An empty array is
1608
+ * meaningful (`enabledModelIds: []` disables every model, and the card blocks
1609
+ * saving that state) so it is preserved rather than treated as absent.
1610
+ */
1611
+ function parseSelection(body) {
1612
+ const out = {};
1613
+ for (const key of ["enabledModelIds", "imageModelIds"]) {
1614
+ const value = body[key];
1615
+ if (value === void 0) continue;
1616
+ if (!Array.isArray(value)) return void 0;
1617
+ const ids = [];
1618
+ for (const entry of value) {
1619
+ if (typeof entry !== "string" || entry === "") return void 0;
1620
+ ids.push(entry);
1621
+ }
1622
+ out[key] = ids;
1623
+ }
1624
+ const budgets = body["contextBudgets"];
1625
+ if (budgets !== void 0) {
1626
+ if (typeof budgets !== "object" || budgets === null || Array.isArray(budgets)) return void 0;
1627
+ const map = {};
1628
+ for (const [id, raw] of Object.entries(budgets)) {
1629
+ if (id === "") return void 0;
1630
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1e3) return void 0;
1631
+ map[id] = raw;
1632
+ }
1633
+ out.contextBudgets = map;
1634
+ }
1635
+ return out;
1636
+ }
1637
+ function toWebAccount(account) {
1638
+ const now = Date.now();
1639
+ const cooling = account.cooldownUntilMs > now;
1640
+ const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
1641
+ modelId,
1642
+ until: new Date(until).toISOString()
1643
+ }));
1644
+ return {
1645
+ id: account.id,
1646
+ label: account.label,
1647
+ ...account.credential.nickname === void 0 ? {} : { nickname: account.credential.nickname },
1648
+ domain: account.credential.domain,
1649
+ ...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
1650
+ cooling,
1651
+ ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
1652
+ ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
1653
+ rateLimitHits: account.rateLimitHits
1654
+ };
1655
+ }
1656
+ function toWebModel(model, selection) {
1657
+ const enabled = selection.enabledModelIds;
1658
+ const budget = selection.contextBudgets?.[model.id];
1659
+ const capped = budget !== void 0 && budget > 0 && budget < model.nativeContextWindow;
1660
+ return {
1661
+ id: model.id,
1662
+ name: model.name,
1663
+ ...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
1664
+ ...model.tags === void 0 ? {} : { tags: model.tags },
1665
+ ...model.supportedEfforts === void 0 || model.supportedEfforts.length === 0 ? {} : { supportedEfforts: model.supportedEfforts },
1666
+ supportsImages: model.supportsImages,
1667
+ contextWindow: capped ? budget : model.nativeContextWindow,
1668
+ nativeContextWindow: model.nativeContextWindow,
1669
+ maxOutputTokens: model.maxOutputTokens,
1670
+ enabled: enabled === void 0 || enabled.includes(model.id)
1671
+ };
1672
+ }
1673
+ /**
1674
+ * Assemble the card's status document. Per-account credits and check-in state
1675
+ * are queried live; a failing query degrades to `creditsError` / `checkinError`
1676
+ * rather than failing the whole document. Never throws.
1677
+ */
1678
+ async function poolWebStatus(deps, region = "cn") {
1679
+ const accounts = deps.pool.list(region);
1680
+ const regions = ["cn", "global"];
1681
+ const selection = deps.catalog.currentSelection();
1682
+ const rows = [];
1683
+ const now = Date.now();
1684
+ for (const account of accounts) {
1685
+ const row = toWebAccount(account);
1686
+ if (!row.cooling) {
1687
+ try {
1688
+ const credits = await deps.client.fetchCredits(account.credential);
1689
+ Object.assign(row, { credits: {
1690
+ total: credits.total,
1691
+ packages: credits.packages,
1692
+ ...credits.expiringSoon === void 0 ? {} : { expiringSoon: credits.expiringSoon },
1693
+ ...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
1694
+ } });
1695
+ } catch (error) {
1696
+ Object.assign(row, { creditsError: safeMessage(error) });
1697
+ }
1698
+ try {
1699
+ const checkin = await deps.client.fetchCheckinStatus(account.credential);
1700
+ const web = {
1701
+ active: checkin.active,
1702
+ todayCheckedIn: checkin.todayCheckedIn,
1703
+ streakDays: checkin.streakDays,
1704
+ dailyCredit: checkin.dailyCredit,
1705
+ todayCredit: checkin.todayCredit,
1706
+ isStreakDay: checkin.isStreakDay,
1707
+ nextStreakDay: checkin.nextStreakDay,
1708
+ streakBonusCredit: checkin.streakBonusCredit
1709
+ };
1710
+ Object.assign(row, { checkin: web });
1711
+ } catch (error) {
1712
+ Object.assign(row, { checkinError: safeMessage(error) });
1713
+ }
1714
+ }
1715
+ rows.push(row);
1716
+ }
1717
+ const cooling = rows.filter((row) => row.cooling).length;
1718
+ const firstUsable = accounts.find((account) => account.cooldownUntilMs <= now);
1719
+ let shim;
1720
+ if (deps.shim === void 0) shim = { running: false };
1721
+ else try {
1722
+ shim = deps.shim();
1723
+ } catch {
1724
+ shim = { running: false };
1725
+ }
1726
+ return {
1727
+ ok: accounts.length > 0 && cooling < accounts.length,
1728
+ accounts: rows,
1729
+ ...firstUsable === void 0 ? {} : { activeAccountId: firstUsable.id },
1730
+ cooling,
1731
+ models: deps.catalog.current().map((model) => toWebModel({
1732
+ id: model.id,
1733
+ name: model.name,
1734
+ contextWindow: model.contextWindow,
1735
+ maxOutputTokens: model.maxOutputTokens,
1736
+ nativeContextWindow: model.contextWindow,
1737
+ ...model.multiplier === void 0 ? {} : { multiplier: model.multiplier },
1738
+ ...model.tags === void 0 ? {} : { tags: model.tags },
1739
+ ...model.supportedEfforts === void 0 ? {} : { supportedEfforts: model.supportedEfforts },
1740
+ supportsImages: model.supportsImages
1741
+ }, selection)),
1742
+ selection,
1743
+ region,
1744
+ regions,
1745
+ shim
1746
+ };
1747
+ }
1748
+ /**
1749
+ * Mount the read-only routes on a context where `webServer` is available. The
1750
+ * caller uses `ctx.inject(['webServer'], ...)` so Desktop startup order cannot
1751
+ * make this registration disappear.
1752
+ */
1753
+ function registerPoolStatusRoute(ctx, deps) {
1754
+ ctx.effect(() => {
1755
+ const disposeStatus = ctx.webServer.register({
1756
+ kind: "exact",
1757
+ path: POOL_STATUS_PATH,
1758
+ handler: async (req, res) => {
1759
+ if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
1760
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1761
+ try {
1762
+ json(res, 200, await poolWebStatus(deps, new URL(req.url ?? "/", "http://localhost").searchParams.get("region") === "global" ? "global" : "cn"));
1763
+ } catch (error) {
1764
+ json(res, 500, { error: safeMessage(error) });
1765
+ }
1766
+ }
1767
+ });
1768
+ const disposeRescan = ctx.webServer.register({
1769
+ kind: "exact",
1770
+ path: POOL_RESCAN_PATH,
1771
+ handler: async (req, res) => {
1772
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1773
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1774
+ try {
1775
+ json(res, 200, { accounts: (await deps.pool.scan()).length });
1776
+ } catch (error) {
1777
+ json(res, 500, { error: safeMessage(error) });
1778
+ }
1779
+ }
1780
+ });
1781
+ const disposeReset = ctx.webServer.register({
1782
+ kind: "exact",
1783
+ path: POOL_RESET_COOLDOWN_PATH,
1784
+ handler: async (req, res) => {
1785
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1786
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1787
+ deps.pool.resetCooldowns();
1788
+ json(res, 200, { ok: true });
1789
+ }
1790
+ });
1791
+ const disposeCheckin = ctx.webServer.register({
1792
+ kind: "exact",
1793
+ path: POOL_CHECKIN_PATH,
1794
+ handler: async (req, res) => {
1795
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1796
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1797
+ try {
1798
+ const body = await readJsonBody(req);
1799
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"] : "";
1800
+ if (accountId === "") return json(res, 400, { error: "accountId is required" });
1801
+ const account = deps.pool.list().find((item) => item.id === accountId);
1802
+ if (account === void 0) return json(res, 404, { error: "unknown account" });
1803
+ const before = await deps.client.fetchCheckinStatus(account.credential);
1804
+ if (!before.active) return json(res, 409, { error: "check-in activity is not active" });
1805
+ if (before.todayCheckedIn) return json(res, 200, {
1806
+ ok: true,
1807
+ alreadyCheckedIn: true,
1808
+ claim: {
1809
+ credit: 0,
1810
+ streakDays: before.streakDays,
1811
+ isStreakDay: before.isStreakDay
1812
+ }
1813
+ });
1814
+ json(res, 200, {
1815
+ ok: true,
1816
+ claim: await deps.client.claimDailyCheckin(account.credential)
1817
+ });
1818
+ } catch (error) {
1819
+ json(res, 500, { error: safeMessage(error) });
1820
+ }
1821
+ }
1822
+ });
1823
+ const disposeModelsSave = ctx.webServer.register({
1824
+ kind: "exact",
1825
+ path: POOL_MODELS_SAVE_PATH,
1826
+ handler: async (req, res) => {
1827
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1828
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1829
+ if (deps.saveSelection === void 0) return json(res, 503, { error: "settings service unavailable; model selection cannot be saved" });
1830
+ try {
1831
+ const selection = parseSelection(await readJsonBody(req));
1832
+ if (selection === void 0) return json(res, 400, { error: "invalid selection payload" });
1833
+ await deps.saveSelection(selection);
1834
+ json(res, 200, {
1835
+ ok: true,
1836
+ selection
1837
+ });
1838
+ } catch (error) {
1839
+ json(res, 500, { error: safeMessage(error) });
1840
+ }
1841
+ }
1842
+ });
1843
+ return () => {
1844
+ disposeCheckin();
1845
+ disposeModelsSave();
1846
+ disposeReset();
1847
+ disposeRescan();
1848
+ disposeStatus();
1849
+ };
1850
+ }, "dsh-workbuddy-xdpool: Web status route");
1851
+ }
1852
+ //#endregion
1853
+ //#region src/index.ts
1854
+ /**
1855
+ * Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
1856
+ * Harness LLM seam once the loopback shim holds its port, plus the HTTP status
1857
+ * routes consumed by the CLI.
1858
+ *
1859
+ * @module dsh-workbuddy-xdpool/index
1860
+ */
1861
+ /** Stable Cordis plugin name. */
1862
+ const name = "llm-workbuddy-xdpool";
1863
+ /** The model registry required before the provider can register. */
1864
+ const inject = ["llm", "settings"];
1865
+ /**
1866
+ * Settings namespace for the WorkBuddy XD Pool card. Registering a section here
1867
+ * is what makes the provider appear on the Models settings page and causes the
1868
+ * Host to mount the plugin's client card under Plugin configuration — exactly
1869
+ * the mechanism the single-account connector uses.
1870
+ */
1871
+ const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
1872
+ /** Upper bound the card offers as the "default" context window, in tokens. */
1873
+ const DEFAULT_CONTEXT_BUDGET = 2e5;
1874
+ /**
1875
+ * Plugin configuration schema.
1876
+ *
1877
+ * Mirrors the shape the settings section stores. Every field carries a default
1878
+ * so a config that never touched the card still folds cleanly: a field whose
1879
+ * schema declares no default is read as absent by the settings fold. That is
1880
+ * also why `contextBudgets` is a real dictionary (`z.dict`) - an open object
1881
+ * schema reads as "an object with no fields" and the fold then throws while
1882
+ * the provider row is rendered.
1883
+ */
1884
+ const Config = z.object({
1885
+ authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1886
+ cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1887
+ enabledModelIds: z.array(z.string()).default([]).description("Model ids enabled in the picker (empty = all)"),
1888
+ imageModelIds: z.array(z.string()).default([]).description("Model ids accepting image input (empty = follow upstream)"),
1889
+ contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Per-model context-window override, keyed by model id")
1890
+ });
1891
+ /** Live API, published for the CLI. */
1892
+ let api;
1893
+ /** The live API, or undefined when the plugin has not applied yet. */
1894
+ function currentApi() {
1895
+ return api;
1896
+ }
1897
+ /** Test seam: install an API instance without booting cordis. */
1898
+ function setApi(next) {
1899
+ api = next;
1900
+ }
1901
+ /** Assemble the runtime objects without registering anything. */
1902
+ function createCore(logger) {
1903
+ const client = new WorkBuddyUpstreamClient();
1904
+ return {
1905
+ pool: new WorkBuddyAccountPool({
1906
+ ...logger === void 0 ? {} : { logger },
1907
+ client
1908
+ }),
1909
+ catalog: new WorkBuddyCatalog(),
1910
+ client
1911
+ };
1912
+ }
1913
+ /**
1914
+ * Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
1915
+ * discover accounts. The provider registers only after `shim.ready` resolves,
1916
+ * because its models read the shim origin at construction time.
1917
+ */
1918
+ function apply(ctx, config = {}) {
1919
+ const core = createCore(ctx.logger);
1920
+ /**
1921
+ * Invalidate the provider snapshot so the picker re-reads the catalog.
1922
+ *
1923
+ * Seeded with a no-op and reassigned once the adapters exist. The settings
1924
+ * section calls its `onChange` hook SYNCHRONOUSLY from `installSection`,
1925
+ * before registration has run, so a plain `let builtAdapter` declared later
1926
+ * would be read from its temporal dead zone ("Cannot access `builtAdapter`
1927
+ * before initialization") and abort the whole apply — which in turn leaves the
1928
+ * providers undeclared and the settings page unable to render them.
1929
+ */
1930
+ /** The domestic adapter, published for the CLI after registration. */
1931
+ let builtAdapter;
1932
+ let invalidateCatalog = () => {};
1933
+ let current = () => config;
1934
+ const sectionHooks = {
1935
+ setSource(source) {
1936
+ current = source;
1937
+ },
1938
+ onChange() {
1939
+ applyConfigFromSource();
1940
+ }
1941
+ };
1942
+ const applyConfigFromSource = () => {
1943
+ const { authFile, cooldownMs, enabledModelIds, imageModelIds, contextBudgets } = current();
1944
+ core.pool.applyConfig({
1945
+ ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
1946
+ ...cooldownMs === void 0 ? {} : { cooldownMs }
1947
+ });
1948
+ core.catalog.applySelection({
1949
+ ...enabledModelIds === void 0 ? {} : { enabledModelIds },
1950
+ ...imageModelIds === void 0 ? {} : { imageModelIds },
1951
+ ...contextBudgets === void 0 ? {} : { contextBudgets }
1952
+ });
1953
+ invalidateCatalog();
1954
+ };
1955
+ const settingsService = ctx.settings;
1956
+ if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
1957
+ else ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service has no installSection; card will not mount");
1958
+ /**
1959
+ * Write one key of the plugin's own settings section. Only ever called with
1960
+ * the three model-selection keys, so the settings file cannot be steered from
1961
+ * the browser; the catalog re-reads through `onChange` either way.
1962
+ */
1963
+ const setSetting = (key, value) => {
1964
+ if (value === void 0) return;
1965
+ const write = settingsService?.set;
1966
+ if (write === void 0) return;
1967
+ try {
1968
+ write.call(settingsService, key, value);
1969
+ } catch (error) {
1970
+ ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ${key}`, error);
1971
+ }
1972
+ };
1973
+ const shims = {
1974
+ cn: createWorkBuddyShim({
1975
+ pool: core.pool,
1976
+ client: core.client,
1977
+ catalog: core.catalog,
1978
+ logger: ctx.logger,
1979
+ region: "cn"
1980
+ }),
1981
+ global: createWorkBuddyShim({
1982
+ pool: core.pool,
1983
+ client: core.client,
1984
+ catalog: core.catalog,
1985
+ logger: ctx.logger,
1986
+ region: "global"
1987
+ })
1988
+ };
1989
+ const shim = shims.cn;
1990
+ /** Resolve a region's loopback origin once it has bound a port. */
1991
+ const shimInfo = (which) => {
1992
+ try {
1993
+ return {
1994
+ running: true,
1995
+ baseUrl: shims[which].baseUrl()
1996
+ };
1997
+ } catch {
1998
+ return { running: false };
1999
+ }
2000
+ };
2001
+ let stopped = false;
2002
+ ctx.effect(() => () => {
2003
+ stopped = true;
2004
+ shims.cn.close();
2005
+ shims.global.close();
2006
+ });
2007
+ ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
2008
+ pool: core.pool,
2009
+ catalog: core.catalog,
2010
+ client: core.client,
2011
+ shim: () => shimInfo("cn"),
2012
+ saveSelection: (selection) => {
2013
+ setSetting("enabledModelIds", selection.enabledModelIds);
2014
+ setSetting("imageModelIds", selection.imageModelIds);
2015
+ setSetting("contextBudgets", selection.contextBudgets);
2016
+ }
2017
+ }));
2018
+ api = {
2019
+ ...core,
2020
+ shim,
2021
+ get adapter() {
2022
+ return builtAdapter;
2023
+ },
2024
+ async rescan() {
2025
+ const accounts = await core.pool.scan();
2026
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: discovered ${accounts.length} account(s)`);
2027
+ return accounts.length;
2028
+ },
2029
+ async status(includeCredits = false) {
2030
+ return buildStatus({
2031
+ pool: core.pool,
2032
+ catalog: core.catalog,
2033
+ client: core.client,
2034
+ shim: shimInfo("cn"),
2035
+ includeCredits
2036
+ });
2037
+ },
2038
+ resetCooldowns() {
2039
+ core.pool.resetCooldowns();
2040
+ }
2041
+ };
2042
+ Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
2043
+ if (stopped) return;
2044
+ try {
2045
+ const adaptersByRegion = {
2046
+ cn: createWorkBuddyAdapter({
2047
+ shim: shims.cn,
2048
+ catalog: core.catalog,
2049
+ providerId: POOL_PROVIDER_BY_REGION.cn,
2050
+ displayName: POOL_NAME_BY_REGION.cn
2051
+ }),
2052
+ global: createWorkBuddyAdapter({
2053
+ shim: shims.global,
2054
+ catalog: core.catalog,
2055
+ providerId: POOL_PROVIDER_BY_REGION.global,
2056
+ displayName: POOL_NAME_BY_REGION.global
2057
+ })
2058
+ };
2059
+ let releaseAdapterCn;
2060
+ let releaseAdapterGlobal;
2061
+ let releaseDirectory;
2062
+ try {
2063
+ releaseAdapterCn = ctx.llm.registerAdapter([POOL_PROVIDER_BY_REGION.cn], adaptersByRegion.cn.adapter);
2064
+ releaseAdapterGlobal = ctx.llm.registerAdapter([POOL_PROVIDER_BY_REGION.global], adaptersByRegion.global.adapter);
2065
+ releaseDirectory = ctx.llm.registerConfigurableProviders([{
2066
+ provider: POOL_PROVIDER_BY_REGION.cn,
2067
+ displayName: POOL_NAME_BY_REGION.cn,
2068
+ settingsNs: WORKBUDDY_POOL_SETTINGS_NS,
2069
+ settingsPath: [],
2070
+ declared: false
2071
+ }, {
2072
+ provider: POOL_PROVIDER_BY_REGION.global,
2073
+ displayName: POOL_NAME_BY_REGION.global,
2074
+ settingsNs: WORKBUDDY_POOL_SETTINGS_NS,
2075
+ settingsPath: [],
2076
+ declared: false
2077
+ }]);
2078
+ } finally {
2079
+ if (releaseAdapterCn === void 0 || releaseAdapterGlobal === void 0 || releaseDirectory === void 0) {
2080
+ releaseAdapterCn?.();
2081
+ releaseAdapterGlobal?.();
2082
+ releaseDirectory?.();
2083
+ }
2084
+ }
2085
+ builtAdapter = adaptersByRegion.cn;
2086
+ invalidateCatalog = () => {
2087
+ adaptersByRegion.cn.invalidate();
2088
+ adaptersByRegion.global.invalidate();
2089
+ };
2090
+ /** Release everything the two providers registered, once. */
2091
+ const releaseProviders = () => {
2092
+ releaseAdapterCn?.();
2093
+ releaseAdapterGlobal?.();
2094
+ releaseDirectory?.();
2095
+ };
2096
+ try {
2097
+ ctx.effect(() => releaseProviders);
2098
+ } catch {
2099
+ releaseProviders();
2100
+ }
2101
+ ctx.llm.registerModelDiscovery(WORKBUDDY_POOL_SETTINGS_NS, async (request) => {
2102
+ if (request.provider !== "workbuddy-xdpool" && request.provider !== "workbuddy-xdpool-global") return [];
2103
+ return core.catalog.visible().map((model) => ({
2104
+ id: model.id,
2105
+ name: model.name,
2106
+ contextWindow: model.contextWindow,
2107
+ maxTokens: model.maxOutputTokens,
2108
+ inputModalities: model.supportsImages ? ["text", "image"] : ["text"]
2109
+ }));
2110
+ });
2111
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: providers registered at cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}`);
2112
+ } catch (error) {
2113
+ ctx.logger.error("dsh-workbuddy-xdpool: provider registration failed", error);
2114
+ return;
2115
+ }
2116
+ if (stopped) return;
2117
+ core.pool.scan().then((accounts) => {
2118
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: ${accounts.length} WorkBuddy account(s) in rotation`);
2119
+ }, (error) => {
2120
+ ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
2121
+ });
2122
+ (async () => {
2123
+ try {
2124
+ const credential = (await core.pool.scan())[0]?.credential;
2125
+ if (credential === void 0) return;
2126
+ const models = await core.client.fetchModels(credential);
2127
+ core.catalog.updateFromUpstream(models);
2128
+ invalidateCatalog();
2129
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: live catalog seeded with ${models.length} model(s)`);
2130
+ } catch (error) {
2131
+ ctx.logger.warn("dsh-workbuddy-xdpool: live model catalog unavailable; using static fallback", error);
2132
+ }
2133
+ })();
2134
+ }, (error) => {
2135
+ ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
2136
+ });
2137
+ }
2138
+ //#endregion
2139
+ export { Config, DEFAULT_CONTEXT_BUDGET, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };