dsh-workbuddy-connect 0.3.2 → 0.5.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/README.en.md +40 -10
- package/README.md +42 -12
- package/lib/bin.js +71 -23
- package/lib/client.js +1033 -45
- package/lib/index.d.ts +619 -13
- package/lib/index.js +1196 -126
- package/lib/variants-GgLTJgZs.js +2321 -0
- package/package.json +11 -5
- package/lib/host-heartbeat-BSsKUoB8.js +0 -1198
|
@@ -0,0 +1,2321 @@
|
|
|
1
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, release } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
5
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
//#region src/app-version.ts
|
|
9
|
+
/**
|
|
10
|
+
* The international desktop app's version, used as the `/v3/config` UA.
|
|
11
|
+
*
|
|
12
|
+
* The App-shaped catalog is served only to a User-Agent carrying the product
|
|
13
|
+
* name (see `docs/workbuddy-ai-international-research-2026-09-11.md` §2.7).
|
|
14
|
+
* That document's conclusion recommended the space form `WorkBuddy AI/<v>`;
|
|
15
|
+
* re-measured on 2026-09-11 the *space* form is rejected (HTTP 400, code
|
|
16
|
+
* 12403) while the terse `WorkBuddyAI/<v>` form — with or without the space
|
|
17
|
+
* removed — returns the 21-model App document. The UA is therefore built from
|
|
18
|
+
* the form verified in code, not from the earlier prose.
|
|
19
|
+
*
|
|
20
|
+
* The version is only ever a UA component: a missing App, an unreadable
|
|
21
|
+
* plist, or a bad cached value degrades to the last saved value and finally to
|
|
22
|
+
* a compiled-in constant, and never blocks credential use or the provider.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-workbuddy-connect/app-version
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Last-resort UA version.
|
|
28
|
+
*
|
|
29
|
+
* The gateway ignores the version number when splitting the UA (research §2.7.2
|
|
30
|
+
* item 2: `CLI/1.0.0`, `CLI/99.0.0` and the real version all return the same
|
|
31
|
+
* document), so this constant is a shape requirement rather than a currency
|
|
32
|
+
* claim. It is *not* used to infer anything about model capabilities.
|
|
33
|
+
*/
|
|
34
|
+
const FALLBACK_APP_VERSION = "5.5.2";
|
|
35
|
+
/** Basename of the saved version under `$DSH_HOME`. */
|
|
36
|
+
const WORKBUDDY_APP_VERSION_FILENAME = ".workbuddy-ai-version.json";
|
|
37
|
+
/**
|
|
38
|
+
* Whether a string is safe to interpolate into an HTTP header.
|
|
39
|
+
*
|
|
40
|
+
* Strict on purpose: the value reaches a header, so anything that could split
|
|
41
|
+
* the request (CR, LF, spaces beyond the separator) or inject a second UA
|
|
42
|
+
* token must never pass. The App's own version is always `N.N.N` or `N.N.N.N`.
|
|
43
|
+
*/
|
|
44
|
+
function validAppVersion(value) {
|
|
45
|
+
return typeof value === "string" && /^\d{1,6}(?:\.\d{1,6}){1,3}$/u.test(value);
|
|
46
|
+
}
|
|
47
|
+
/** macOS App-bundle roots: system-wide first, then the user's own install. */
|
|
48
|
+
function macAppRoots() {
|
|
49
|
+
return ["/Applications", join(homedir(), "Applications")];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read `CFBundleShortVersionString` out of an `Info.plist`.
|
|
53
|
+
*
|
|
54
|
+
* Parsed as XML rather than grepped, because the plist contains several
|
|
55
|
+
* `<string>` values and a regex would be one unrelated key away from
|
|
56
|
+
* returning the wrong one. A binary plist has no `<dict>` in its bytes and is
|
|
57
|
+
* reported as unreadable (the saved value then applies) rather than guessed at.
|
|
58
|
+
*/
|
|
59
|
+
async function readBundleVersion(plistPath) {
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = await readFile(plistPath, "utf8");
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const version = /<key>\s*CFBundleShortVersionString\s*<\/key>\s*<string>([^<]*)<\/string>/u.exec(text)?.[1]?.trim();
|
|
67
|
+
return validAppVersion(version) ? version : void 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The installed international App's version, or `undefined` when it is not
|
|
71
|
+
* installed (or not readable).
|
|
72
|
+
*
|
|
73
|
+
* Windows and Linux have no verified bundle-metadata location yet, so this
|
|
74
|
+
* returns `undefined` there and the saved/fallback value is used instead of
|
|
75
|
+
* guessing a path — the same discipline the credential discovery follows.
|
|
76
|
+
*/
|
|
77
|
+
async function installedAppVersion() {
|
|
78
|
+
if (process.platform !== "darwin") return void 0;
|
|
79
|
+
for (const root of macAppRoots()) {
|
|
80
|
+
const bundle = join(root, "WorkBuddy AI.app");
|
|
81
|
+
const version = await readBundleVersion(join(bundle, "Contents", "Info.plist"));
|
|
82
|
+
if (version !== void 0) return {
|
|
83
|
+
version,
|
|
84
|
+
bundle
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Saved-version file path under the Harness home. */
|
|
89
|
+
function appVersionPath() {
|
|
90
|
+
return join(resolveDshHome(), WORKBUDDY_APP_VERSION_FILENAME);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the UA version: installed App first, then the last saved value, then
|
|
94
|
+
* the compiled-in fallback.
|
|
95
|
+
*
|
|
96
|
+
* A value read from the App is written back immediately, so an uninstalled App
|
|
97
|
+
* or an unreadable plist later still has the last real version to fall back
|
|
98
|
+
* on. The write is best-effort: failing to cache a version must never fail the
|
|
99
|
+
* catalog request that asked for it.
|
|
100
|
+
*/
|
|
101
|
+
async function resolveAppVersion(options = {}) {
|
|
102
|
+
const path = options.path ?? appVersionPath();
|
|
103
|
+
const installed = await (options.installed ?? installedAppVersion)();
|
|
104
|
+
if (installed !== void 0 && validAppVersion(installed.version)) {
|
|
105
|
+
try {
|
|
106
|
+
await writeFileAtomic(path, `${JSON.stringify({
|
|
107
|
+
version: installed.version,
|
|
108
|
+
bundle: installed.bundle,
|
|
109
|
+
observedAt: Date.now()
|
|
110
|
+
}, null, 2)}\n`, {
|
|
111
|
+
mode: 384,
|
|
112
|
+
dirMode: 448
|
|
113
|
+
});
|
|
114
|
+
} catch {}
|
|
115
|
+
return {
|
|
116
|
+
version: installed.version,
|
|
117
|
+
source: "installed",
|
|
118
|
+
bundle: installed.bundle
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const saved = JSON.parse(await readFile(path, "utf8"));
|
|
123
|
+
if (typeof saved === "object" && saved !== null) {
|
|
124
|
+
const version = saved["version"];
|
|
125
|
+
if (validAppVersion(version)) return {
|
|
126
|
+
version,
|
|
127
|
+
source: "saved"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
return {
|
|
132
|
+
version: FALLBACK_APP_VERSION,
|
|
133
|
+
source: "fallback"
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the App-shaped User-Agent for catalog requests.
|
|
138
|
+
*
|
|
139
|
+
* `WorkBuddyAI/<version>` with no space is the form measured to reach the App
|
|
140
|
+
* document; the space form is rejected with 400/12403. Throws on an invalid
|
|
141
|
+
* version rather than sending a malformed header.
|
|
142
|
+
*/
|
|
143
|
+
function appUserAgent(version) {
|
|
144
|
+
if (!validAppVersion(version)) throw new Error(`invalid WorkBuddy AI version for User-Agent: ${JSON.stringify(version)}`);
|
|
145
|
+
return `WorkBuddyAI/${version}`;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/probe.ts
|
|
149
|
+
/**
|
|
150
|
+
* The reasoning-effort probe: decide whether a model's `reasoning_effort`
|
|
151
|
+
* parameter is actually validated, and if so which canonical values it accepts.
|
|
152
|
+
*
|
|
153
|
+
* Implements `docs/reasoning-effort-probe-plan.md` §4. The order matters and is
|
|
154
|
+
* not an optimization:
|
|
155
|
+
*
|
|
156
|
+
* 1. **Baseline** (no `reasoning_effort`) proves the model, credential, and
|
|
157
|
+
* request shape work at all, so a later rejection can be attributed.
|
|
158
|
+
* 2. **Sentinel** (a fresh random, impossible-to-collide value) answers the one
|
|
159
|
+
* question a per-level sweep cannot: does the upstream validate the field?
|
|
160
|
+
* A model that accepts the sentinel answers 200 to *everything*, so its
|
|
161
|
+
* per-level results would be uniformly false positives.
|
|
162
|
+
* 3. **Levels**, only after the sentinel was refused.
|
|
163
|
+
*
|
|
164
|
+
* The result is an observation, never a capability claim. Even a fully
|
|
165
|
+
* successful sweep means "the upstream accepted these spellings", not "these
|
|
166
|
+
* spellings change how the model thinks".
|
|
167
|
+
*
|
|
168
|
+
* @module dsh-workbuddy-connect/probe
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* The canonical values a probe tests, in a fixed order.
|
|
172
|
+
*
|
|
173
|
+
* `minimal` is absent: it appears in no upstream vocabulary. `off` is absent
|
|
174
|
+
* by policy — disabling thinking is a separate capability the upstream must
|
|
175
|
+
* declare through `canDisableThinking`, never something probing may infer.
|
|
176
|
+
*/
|
|
177
|
+
const PROBE_EFFORT_CANDIDATES = [
|
|
178
|
+
"low",
|
|
179
|
+
"medium",
|
|
180
|
+
"high",
|
|
181
|
+
"xhigh",
|
|
182
|
+
"max"
|
|
183
|
+
];
|
|
184
|
+
/** Prompt body used by every probe request; carries nothing user-specific. */
|
|
185
|
+
const PROBE_PROMPT = "ping";
|
|
186
|
+
/** Default sentinel: unmistakably non-canonical, different on every call. */
|
|
187
|
+
function randomSentinel() {
|
|
188
|
+
return `probe_sentinel_${randomBytes(12).toString("hex")}`;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The upstream's "this effort value is not supported" code, measured
|
|
192
|
+
* 2026-09-11 (plan §4.2). It is *not* treated as a permanent protocol promise:
|
|
193
|
+
* anything unrecognized degrades to `unknown` rather than to a capability
|
|
194
|
+
* conclusion.
|
|
195
|
+
*/
|
|
196
|
+
const INVALID_EFFORT_CODE = "invalid_reasoning_effort";
|
|
197
|
+
/** Whether an attempt is an attributable rejection of the effort value. */
|
|
198
|
+
function isEffortRejection(attempt) {
|
|
199
|
+
return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
|
|
200
|
+
}
|
|
201
|
+
/** Whether an attempt shows the upstream accepted the request and streamed. */
|
|
202
|
+
function isAcceptance(attempt) {
|
|
203
|
+
return attempt.status === 200 && attempt.streamed;
|
|
204
|
+
}
|
|
205
|
+
/** Why an attempt ended in `unknown`, phrased for a log line. */
|
|
206
|
+
function unknownReason(stage, attempt) {
|
|
207
|
+
const code = attempt.errorCode === void 0 ? "" : ` (${attempt.errorCode})`;
|
|
208
|
+
const detail = attempt.detail === void 0 ? "" : `: ${attempt.detail}`;
|
|
209
|
+
return `${stage} status ${attempt.status}${code}${detail}`;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Probe one model.
|
|
213
|
+
*
|
|
214
|
+
* `options.candidates` exists so tests can shorten the sweep; production always
|
|
215
|
+
* uses {@link PROBE_EFFORT_CANDIDATES}.
|
|
216
|
+
*/
|
|
217
|
+
async function probeModel(options) {
|
|
218
|
+
const sentinel = options.sentinel ?? randomSentinel;
|
|
219
|
+
const candidates = options.candidates ?? PROBE_EFFORT_CANDIDATES;
|
|
220
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
221
|
+
let requests = 0;
|
|
222
|
+
const attempt = async (effort) => {
|
|
223
|
+
requests += 1;
|
|
224
|
+
const controller = new AbortController();
|
|
225
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
226
|
+
try {
|
|
227
|
+
return await options.send(effort, controller.signal);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return {
|
|
230
|
+
status: 0,
|
|
231
|
+
streamed: false,
|
|
232
|
+
detail: `transport error: ${String(error)}`
|
|
233
|
+
};
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const baseline = await attempt(void 0);
|
|
239
|
+
if (!isAcceptance(baseline)) return {
|
|
240
|
+
validation: "unknown",
|
|
241
|
+
efforts: [],
|
|
242
|
+
requests,
|
|
243
|
+
reason: unknownReason("baseline", baseline)
|
|
244
|
+
};
|
|
245
|
+
const sentinelAttempt = await attempt(sentinel());
|
|
246
|
+
if (isAcceptance(sentinelAttempt)) return {
|
|
247
|
+
validation: "non-validating",
|
|
248
|
+
efforts: [],
|
|
249
|
+
requests
|
|
250
|
+
};
|
|
251
|
+
if (!isEffortRejection(sentinelAttempt)) return {
|
|
252
|
+
validation: "unknown",
|
|
253
|
+
efforts: [],
|
|
254
|
+
requests,
|
|
255
|
+
reason: unknownReason("sentinel", sentinelAttempt)
|
|
256
|
+
};
|
|
257
|
+
const accepted = [];
|
|
258
|
+
for (const effort of candidates) {
|
|
259
|
+
const levelAttempt = await attempt(effort);
|
|
260
|
+
if (isAcceptance(levelAttempt)) {
|
|
261
|
+
accepted.push(effort);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (isEffortRejection(levelAttempt)) continue;
|
|
265
|
+
return {
|
|
266
|
+
validation: "unknown",
|
|
267
|
+
efforts: [],
|
|
268
|
+
requests,
|
|
269
|
+
reason: unknownReason(`level ${effort}`, levelAttempt)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
validation: "validating",
|
|
274
|
+
efforts: accepted,
|
|
275
|
+
requests
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/upstream.ts
|
|
280
|
+
/**
|
|
281
|
+
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
282
|
+
* token refresh, model catalog, and credit balance. The wire behavior is
|
|
283
|
+
* ported from Sliverkiss/workbuddy2api (MIT), whose Go implementation is
|
|
284
|
+
* battle-tested against the real endpoint.
|
|
285
|
+
*
|
|
286
|
+
* @module dsh-workbuddy-connect/upstream
|
|
287
|
+
*/
|
|
288
|
+
const CN_CHAT_BASE = "https://copilot.tencent.com";
|
|
289
|
+
const CN_BILLING_BASE = "https://www.codebuddy.cn";
|
|
290
|
+
const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
291
|
+
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
292
|
+
const JSON_TIMEOUT_MS = 3e4;
|
|
293
|
+
const ERROR_BODY_LIMIT = 4096;
|
|
294
|
+
/** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
|
|
295
|
+
const HARD_CREDIT_MARKERS = [
|
|
296
|
+
"insufficient credit",
|
|
297
|
+
"no credit",
|
|
298
|
+
"credit exhausted",
|
|
299
|
+
"credits exhausted",
|
|
300
|
+
"out of credit",
|
|
301
|
+
"quota exceeded",
|
|
302
|
+
"quota exhaust",
|
|
303
|
+
"payment required",
|
|
304
|
+
"credit not enough",
|
|
305
|
+
"not enough credit",
|
|
306
|
+
"积分不足",
|
|
307
|
+
"额度不足",
|
|
308
|
+
"余额不足",
|
|
309
|
+
"积分用完",
|
|
310
|
+
"额度用尽",
|
|
311
|
+
"没有积分"
|
|
312
|
+
];
|
|
313
|
+
/** The concrete effort spellings WorkBuddy exposes on the wire. */
|
|
314
|
+
const EFFORT_VALUES = [
|
|
315
|
+
"low",
|
|
316
|
+
"medium",
|
|
317
|
+
"high",
|
|
318
|
+
"xhigh",
|
|
319
|
+
"max"
|
|
320
|
+
];
|
|
321
|
+
/** Promotional badge keys the upstream tags carry, minus their color suffix. */
|
|
322
|
+
const BADGE_PREFIX = "badge:";
|
|
323
|
+
/** Parse the upstream `reasoning` object into {@link WorkBuddyModelReasoning}. */
|
|
324
|
+
function resolveUpstreamReasoning(wrapped) {
|
|
325
|
+
const supports = wrapped["supportsReasoning"] === true;
|
|
326
|
+
const onlyReasoning = wrapped["onlyReasoning"] === true;
|
|
327
|
+
const rawReasoning = wrapped["reasoning"];
|
|
328
|
+
let supportedEfforts;
|
|
329
|
+
let defaultEffort;
|
|
330
|
+
let canDisableThinking = true;
|
|
331
|
+
if (typeof rawReasoning === "object" && rawReasoning !== null && !Array.isArray(rawReasoning)) {
|
|
332
|
+
const reasoning = rawReasoning;
|
|
333
|
+
const rawEfforts = reasoning["supportedEfforts"];
|
|
334
|
+
if (Array.isArray(rawEfforts)) {
|
|
335
|
+
const efforts = rawEfforts.filter((value) => typeof value === "string" && EFFORT_VALUES.includes(value));
|
|
336
|
+
if (efforts.length > 0) supportedEfforts = efforts;
|
|
337
|
+
}
|
|
338
|
+
if (typeof reasoning["defaultEffort"] === "string" && EFFORT_VALUES.includes(reasoning["defaultEffort"])) defaultEffort = reasoning["defaultEffort"];
|
|
339
|
+
else if (typeof reasoning["effort"] === "string" && EFFORT_VALUES.includes(reasoning["effort"])) defaultEffort = reasoning["effort"];
|
|
340
|
+
canDisableThinking = reasoning["canDisableThinking"] === true;
|
|
341
|
+
}
|
|
342
|
+
return { reasoning: {
|
|
343
|
+
supports,
|
|
344
|
+
onlyReasoning,
|
|
345
|
+
...supportedEfforts === void 0 ? {} : { supportedEfforts },
|
|
346
|
+
...defaultEffort === void 0 ? {} : { defaultEffort },
|
|
347
|
+
canDisableThinking
|
|
348
|
+
} };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Reduce an upstream credits string to its language-neutral display form.
|
|
352
|
+
*
|
|
353
|
+
* The host LLM seam carries this text to the browser, and the host has no
|
|
354
|
+
* locale service — whatever string is produced here is shown verbatim in every
|
|
355
|
+
* UI language. The upstream is inconsistent in a way that matters: some catalog
|
|
356
|
+
* rows report a bare multiplier (`x0.79`) and others append a unit word
|
|
357
|
+
* (`x0.79 credits`), and the unit word would pin the display to English.
|
|
358
|
+
* Dropping a trailing `credits` (case-insensitive, singular or plural) yields
|
|
359
|
+
* the one spelling that reads identically in every language.
|
|
360
|
+
*
|
|
361
|
+
* @param credits - raw upstream credits string, e.g. `"x0.79 credits"`.
|
|
362
|
+
* @returns the bare multiplier, or undefined when nothing displayable remains.
|
|
363
|
+
*/
|
|
364
|
+
function normalizeCredits(credits) {
|
|
365
|
+
if (credits === void 0) return void 0;
|
|
366
|
+
const trimmed = credits.trim();
|
|
367
|
+
if (trimmed === "") return void 0;
|
|
368
|
+
if (/^credits?$/iu.test(trimmed)) return void 0;
|
|
369
|
+
const bare = trimmed.replace(/\s+credits?$/iu, "").trim();
|
|
370
|
+
return bare === "" ? void 0 : bare;
|
|
371
|
+
}
|
|
372
|
+
/** Parse the upstream `tags` / `credits` fields into billing metadata. */
|
|
373
|
+
function resolveUpstreamBilling(wrapped) {
|
|
374
|
+
const rawCredits = wrapped["credits"];
|
|
375
|
+
const credits = typeof rawCredits === "string" && rawCredits.trim() !== "" ? rawCredits.trim() : void 0;
|
|
376
|
+
const badges = [];
|
|
377
|
+
const rawTags = wrapped["tags"];
|
|
378
|
+
if (Array.isArray(rawTags)) for (const tag of rawTags) {
|
|
379
|
+
if (typeof tag !== "string") continue;
|
|
380
|
+
if (!tag.toLowerCase().startsWith(BADGE_PREFIX)) continue;
|
|
381
|
+
const label = tag.slice(6).split(":")[0] ?? tag.slice(6);
|
|
382
|
+
if (label !== "") badges.push(label);
|
|
383
|
+
}
|
|
384
|
+
const free = credits !== void 0 && /^x?0\.0+$/u.test(credits);
|
|
385
|
+
return { billing: {
|
|
386
|
+
...credits === void 0 ? {} : { credits },
|
|
387
|
+
...badges.length === 0 ? {} : { badges },
|
|
388
|
+
free
|
|
389
|
+
} };
|
|
390
|
+
}
|
|
391
|
+
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
392
|
+
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
393
|
+
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
394
|
+
function classifyUpstreamError(status, body) {
|
|
395
|
+
if (status === 402) return "hard_credit";
|
|
396
|
+
const lower = body.toLowerCase();
|
|
397
|
+
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
398
|
+
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
399
|
+
if (status === 429) return "soft_rate";
|
|
400
|
+
if (status === 404) return "not_found";
|
|
401
|
+
if (status >= 500) return "server";
|
|
402
|
+
if (status >= 400) return "client";
|
|
403
|
+
return "client";
|
|
404
|
+
}
|
|
405
|
+
/** Region for a login domain; an empty domain means CN (matching upstream tooling). */
|
|
406
|
+
function regionOf(domain) {
|
|
407
|
+
const lowered = domain.trim().toLowerCase();
|
|
408
|
+
if (lowered === "workbuddy.ai" || lowered.endsWith(".workbuddy.ai")) return "global";
|
|
409
|
+
return "cn";
|
|
410
|
+
}
|
|
411
|
+
function chatBase(credential) {
|
|
412
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_CHAT_BASE;
|
|
413
|
+
}
|
|
414
|
+
function billingBase(credential) {
|
|
415
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
416
|
+
}
|
|
417
|
+
function originReferer(credential) {
|
|
418
|
+
return regionOf(credential.domain) === "global" ? GLOBAL_BASE : CN_BILLING_BASE;
|
|
419
|
+
}
|
|
420
|
+
/** Headers every upstream request shares. */
|
|
421
|
+
function commonHeaders(credential) {
|
|
422
|
+
return {
|
|
423
|
+
"Accept": "application/json, text/plain, */*",
|
|
424
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
425
|
+
"Origin": originReferer(credential),
|
|
426
|
+
"Referer": `${originReferer(credential)}/`,
|
|
427
|
+
"User-Agent": CLIENT_UA
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
431
|
+
function chatHeaders(credential) {
|
|
432
|
+
return {
|
|
433
|
+
...commonHeaders(credential),
|
|
434
|
+
"Content-Type": "application/json",
|
|
435
|
+
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
436
|
+
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
437
|
+
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
438
|
+
"X-Product": "SaaS"
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
442
|
+
function refreshHeaders(credential) {
|
|
443
|
+
const headers = {
|
|
444
|
+
...commonHeaders(credential),
|
|
445
|
+
"X-Refresh-Token": credential.refreshToken,
|
|
446
|
+
"X-Auth-Refresh-Source": "workbuddy"
|
|
447
|
+
};
|
|
448
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
449
|
+
return headers;
|
|
450
|
+
}
|
|
451
|
+
/** Billing request headers. */
|
|
452
|
+
function billingHeaders(credential) {
|
|
453
|
+
const headers = {
|
|
454
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
455
|
+
"Accept": "application/json",
|
|
456
|
+
"Content-Type": "application/json"
|
|
457
|
+
};
|
|
458
|
+
if (credential.uid !== "") headers["X-User-Id"] = credential.uid;
|
|
459
|
+
if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") {
|
|
460
|
+
headers["X-Enterprise-Id"] = credential.enterpriseId;
|
|
461
|
+
headers["X-Tenant-Id"] = credential.enterpriseId;
|
|
462
|
+
}
|
|
463
|
+
if (credential.domain !== "") headers["X-Domain"] = credential.domain;
|
|
464
|
+
return headers;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
468
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
469
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
470
|
+
* and rewrite `developer` messages as `system`.
|
|
471
|
+
*
|
|
472
|
+
* The `developer` rewrite is load-bearing: pi-ai emits the system prompt as
|
|
473
|
+
* `role: "developer"` (the OpenAI convention it adopted), but the WorkBuddy
|
|
474
|
+
* upstream rejects that role with HTTP 400 code 11128 ("Illegal API
|
|
475
|
+
* invocation from an unapproved channel"). Rewriting to `system` is the
|
|
476
|
+
* compatible spelling the upstream accepts.
|
|
477
|
+
*/
|
|
478
|
+
function prepareChatBody(source) {
|
|
479
|
+
let body;
|
|
480
|
+
try {
|
|
481
|
+
body = JSON.parse(source);
|
|
482
|
+
} catch {
|
|
483
|
+
return source;
|
|
484
|
+
}
|
|
485
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return source;
|
|
486
|
+
const obj = body;
|
|
487
|
+
obj["stream"] = true;
|
|
488
|
+
normalizeDeveloperRole(obj);
|
|
489
|
+
normalizeToolChoice(obj);
|
|
490
|
+
return JSON.stringify(obj);
|
|
491
|
+
}
|
|
492
|
+
/** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
|
|
493
|
+
function normalizeDeveloperRole(obj) {
|
|
494
|
+
const messages = obj["messages"];
|
|
495
|
+
if (!Array.isArray(messages)) return;
|
|
496
|
+
for (const message of messages) {
|
|
497
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) continue;
|
|
498
|
+
const wrapped = message;
|
|
499
|
+
if (wrapped["role"] === "developer") wrapped["role"] = "system";
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
|
|
503
|
+
function normalizeToolChoice(obj) {
|
|
504
|
+
const suppress = () => {
|
|
505
|
+
delete obj["tools"];
|
|
506
|
+
delete obj["functions"];
|
|
507
|
+
};
|
|
508
|
+
if (!("tool_choice" in obj)) return;
|
|
509
|
+
const choice = obj["tool_choice"];
|
|
510
|
+
if (typeof choice === "string") {
|
|
511
|
+
if (choice.trim().toLowerCase() === "none") {
|
|
512
|
+
delete obj["tool_choice"];
|
|
513
|
+
suppress();
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (typeof choice === "object" && choice !== null && !Array.isArray(choice)) {
|
|
518
|
+
const wrapped = choice;
|
|
519
|
+
const type = typeof wrapped["type"] === "string" ? wrapped["type"].trim().toLowerCase() : "";
|
|
520
|
+
if (type === "none") {
|
|
521
|
+
delete obj["tool_choice"];
|
|
522
|
+
suppress();
|
|
523
|
+
} else if (type === "auto" || type === "required") obj["tool_choice"] = type;
|
|
524
|
+
else if (type === "function") {
|
|
525
|
+
const fn = typeof wrapped["function"] === "object" && wrapped["function"] !== null ? wrapped["function"] : void 0;
|
|
526
|
+
let name = typeof fn?.["name"] === "string" ? fn["name"] : "";
|
|
527
|
+
if (name === "" && typeof wrapped["name"] === "string") name = wrapped["name"];
|
|
528
|
+
name = name.trim();
|
|
529
|
+
obj["tool_choice"] = name !== "" ? name : "auto";
|
|
530
|
+
} else delete obj["tool_choice"];
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
delete obj["tool_choice"];
|
|
534
|
+
}
|
|
535
|
+
async function readEnvelope(response) {
|
|
536
|
+
const text = await response.text();
|
|
537
|
+
let parsed;
|
|
538
|
+
try {
|
|
539
|
+
parsed = JSON.parse(text);
|
|
540
|
+
} catch {
|
|
541
|
+
throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
|
|
542
|
+
}
|
|
543
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
|
|
544
|
+
const document = parsed;
|
|
545
|
+
return {
|
|
546
|
+
code: typeof document["code"] === "number" ? document["code"] : 0,
|
|
547
|
+
msg: typeof document["msg"] === "string" ? document["msg"] : "",
|
|
548
|
+
data: "data" in document ? document["data"] : void 0,
|
|
549
|
+
document
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
|
|
553
|
+
function envelopeError(status, envelope) {
|
|
554
|
+
const kind = classifyUpstreamError(status, envelope.msg);
|
|
555
|
+
return /* @__PURE__ */ new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Upstream HTTP client. One instance serves the whole plugin; requests take
|
|
559
|
+
* the credential explicitly so token refreshes apply on the next call.
|
|
560
|
+
*
|
|
561
|
+
* One instance is *per variant*: the international provider needs its own
|
|
562
|
+
* catalog source, UA version, and probe differences, and keeping them on the
|
|
563
|
+
* instance avoids passing a variant through every call signature.
|
|
564
|
+
*/
|
|
565
|
+
var WorkBuddyUpstreamClient = class {
|
|
566
|
+
/**
|
|
567
|
+
* Resolves the App-shaped UA version for international catalog requests.
|
|
568
|
+
* Injectable so tests never read the real filesystem.
|
|
569
|
+
*/
|
|
570
|
+
resolveAppVersion;
|
|
571
|
+
/** Provenance of the most recent successful catalog fetch, for the card. */
|
|
572
|
+
lastCatalog;
|
|
573
|
+
constructor(options = {}) {
|
|
574
|
+
this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
|
|
575
|
+
}
|
|
576
|
+
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
577
|
+
async chatStream(credential, bodyJson, signal) {
|
|
578
|
+
let response;
|
|
579
|
+
try {
|
|
580
|
+
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: {
|
|
583
|
+
...chatHeaders(credential),
|
|
584
|
+
"Authorization": `Bearer ${credential.accessToken}`
|
|
585
|
+
},
|
|
586
|
+
body: regionOf(credential.domain) === "global" ? prepareInternationalChatBody(bodyJson) : bodyJson,
|
|
587
|
+
...signal === void 0 ? {} : { signal }
|
|
588
|
+
});
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
status: 0,
|
|
593
|
+
kind: "server",
|
|
594
|
+
message: `transport error: ${String(error)}`
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (response.ok) return {
|
|
598
|
+
ok: true,
|
|
599
|
+
response
|
|
600
|
+
};
|
|
601
|
+
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
602
|
+
return {
|
|
603
|
+
ok: false,
|
|
604
|
+
status: response.status,
|
|
605
|
+
kind: classifyUpstreamError(response.status, text),
|
|
606
|
+
message: text
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
610
|
+
async refreshToken(credential) {
|
|
611
|
+
const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: refreshHeaders(credential),
|
|
614
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
615
|
+
});
|
|
616
|
+
const envelope = await readEnvelope(response);
|
|
617
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
618
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
619
|
+
const accessToken = typeof data["accessToken"] === "string" ? data["accessToken"] : "";
|
|
620
|
+
if (accessToken === "") throw new Error("workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app");
|
|
621
|
+
const outcome = { accessToken };
|
|
622
|
+
if (typeof data["refreshToken"] === "string" && data["refreshToken"] !== "") outcome.refreshToken = data["refreshToken"];
|
|
623
|
+
if (typeof data["expiresIn"] === "number" && data["expiresIn"] > 0) outcome.expiresInSec = data["expiresIn"];
|
|
624
|
+
if (typeof data["domain"] === "string" && data["domain"] !== "") outcome.domain = data["domain"];
|
|
625
|
+
return outcome;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* GET the personal model catalog.
|
|
629
|
+
*
|
|
630
|
+
* Two upstream documents feed this, one per variant:
|
|
631
|
+
*
|
|
632
|
+
* - CN (`workbuddy`): `/console/enterprises/personal/models`, the document
|
|
633
|
+
* the official CLI itself consumes. Unchanged behaviour.
|
|
634
|
+
* - International (`workbuddy-ai`): `/v3/config`, the product document the
|
|
635
|
+
* App's main process fetches. The gateway splits it by User-Agent, so this
|
|
636
|
+
* request carries the App-shaped UA while every other request keeps the
|
|
637
|
+
* CLI UA it has always sent.
|
|
638
|
+
*
|
|
639
|
+
* Both are unwrapped and classified the same way — `readEnvelope` plus
|
|
640
|
+
* `envelopeError` — so an expired session or exhausted credit is reported as
|
|
641
|
+
* such rather than as a generic catalog failure.
|
|
642
|
+
*/
|
|
643
|
+
async fetchModels(credential, signal) {
|
|
644
|
+
const international = regionOf(credential.domain) === "global";
|
|
645
|
+
const appVersion = international ? await this.resolveAppVersion() : void 0;
|
|
646
|
+
const response = await fetch(`${chatBase(credential)}${international ? "/v3/config" : "/console/enterprises/personal/models"}`, {
|
|
647
|
+
headers: {
|
|
648
|
+
Authorization: `Bearer ${credential.accessToken}`,
|
|
649
|
+
Accept: "application/json",
|
|
650
|
+
Origin: originReferer(credential),
|
|
651
|
+
Referer: `${originReferer(credential)}/`,
|
|
652
|
+
...international ? {
|
|
653
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
654
|
+
"X-Product": "SaaS"
|
|
655
|
+
} : {},
|
|
656
|
+
"User-Agent": appVersion === void 0 ? CLIENT_UA : appUserAgent(appVersion.version)
|
|
657
|
+
},
|
|
658
|
+
signal: signal === void 0 ? AbortSignal.timeout(JSON_TIMEOUT_MS) : AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)])
|
|
659
|
+
});
|
|
660
|
+
const envelope = await readEnvelope(response);
|
|
661
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
662
|
+
const models = parseModelCatalog(isObject(envelope.data) ? envelope.data : "models" in envelope.document || "agents" in envelope.document ? envelope.document : {}, international);
|
|
663
|
+
this.lastCatalog = {
|
|
664
|
+
fetchedAtMs: Date.now(),
|
|
665
|
+
source: international ? "workbuddy-ai:app" : "workbuddy:cli",
|
|
666
|
+
...appVersion === void 0 ? {} : { appVersion }
|
|
667
|
+
};
|
|
668
|
+
return models;
|
|
669
|
+
}
|
|
670
|
+
/** POST the billing endpoint for the aggregated remaining credit. */
|
|
671
|
+
async fetchCredits(credential) {
|
|
672
|
+
const now = /* @__PURE__ */ new Date();
|
|
673
|
+
const format = (date) => [
|
|
674
|
+
date.getFullYear().toString().padStart(4, "0"),
|
|
675
|
+
(date.getMonth() + 1).toString().padStart(2, "0"),
|
|
676
|
+
date.getDate().toString().padStart(2, "0")
|
|
677
|
+
].join("-") + " " + [
|
|
678
|
+
date.getHours().toString().padStart(2, "0"),
|
|
679
|
+
date.getMinutes().toString().padStart(2, "0"),
|
|
680
|
+
date.getSeconds().toString().padStart(2, "0")
|
|
681
|
+
].join(":");
|
|
682
|
+
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: billingHeaders(credential),
|
|
685
|
+
body: JSON.stringify({
|
|
686
|
+
PageNumber: 1,
|
|
687
|
+
PageSize: 100,
|
|
688
|
+
ProductCode: "p_tcaca",
|
|
689
|
+
Status: [0, 3],
|
|
690
|
+
PackageEndTimeRangeBegin: format(now),
|
|
691
|
+
PackageEndTimeRangeEnd: format(new Date(now.getTime() + 3185136e6))
|
|
692
|
+
}),
|
|
693
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
694
|
+
});
|
|
695
|
+
const envelope = await readEnvelope(response);
|
|
696
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
697
|
+
const responseWrapper = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
698
|
+
const data = typeof responseWrapper["Response"] === "object" && responseWrapper["Response"] !== null ? responseWrapper["Response"] : {};
|
|
699
|
+
const inner = typeof data["Data"] === "object" && data["Data"] !== null ? data["Data"] : {};
|
|
700
|
+
const rawAccounts = Array.isArray(inner["Accounts"]) ? inner["Accounts"] : [];
|
|
701
|
+
const accounts = [];
|
|
702
|
+
let total = 0;
|
|
703
|
+
for (const raw of rawAccounts) {
|
|
704
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
705
|
+
const account = raw;
|
|
706
|
+
const numberField = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
707
|
+
const size = numberField("CycleCapacitySize");
|
|
708
|
+
const cycleRemain = numberField("CycleCapacityRemain");
|
|
709
|
+
const cycleUsed = numberField("CycleCapacityUsed");
|
|
710
|
+
const capacityRemain = numberField("CapacityRemain");
|
|
711
|
+
let remain;
|
|
712
|
+
if (size > 0) remain = cycleRemain;
|
|
713
|
+
else if (cycleRemain > 0 || cycleUsed > 0) remain = cycleRemain;
|
|
714
|
+
else remain = capacityRemain;
|
|
715
|
+
if (remain < 0) remain = 0;
|
|
716
|
+
total += remain;
|
|
717
|
+
accounts.push({
|
|
718
|
+
packageName: typeof account["PackageName"] === "string" ? account["PackageName"] : "(unnamed)",
|
|
719
|
+
remain,
|
|
720
|
+
size: size > 0 ? size : numberField("CapacitySize")
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
total,
|
|
725
|
+
accounts
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* One probe request: a real streaming chat call carrying the effort under
|
|
730
|
+
* test.
|
|
731
|
+
*
|
|
732
|
+
* Shares `chatHeaders` with the normal chat path on purpose — the plan
|
|
733
|
+
* forbids probing through anything but the plugin's own credential handling,
|
|
734
|
+
* so a result describes what a real message would experience.
|
|
735
|
+
*
|
|
736
|
+
* The caller aborts as soon as a parseable event arrives; the body is never
|
|
737
|
+
* assembled into an answer. `reasoning_effort` is omitted entirely (rather
|
|
738
|
+
* than sent empty) when `effort` is undefined, so the baseline case is a
|
|
739
|
+
* genuinely bare request.
|
|
740
|
+
*
|
|
741
|
+
* Two international differences, both measured on 2026-09-11:
|
|
742
|
+
*
|
|
743
|
+
* - The gateway requires a leading `system` message (400/11128 otherwise), so
|
|
744
|
+
* one is prepended for the global region only.
|
|
745
|
+
* - `max_tokens: 1` is below some models' floor (the GPT-5.6 family rejects it
|
|
746
|
+
* with 400/11133 `integer_below_min_value`), so the international probe asks
|
|
747
|
+
* for a slightly larger minimum. This is a floor the plugin must clear, not
|
|
748
|
+
* evidence about any model's effort support: a model still refusing that
|
|
749
|
+
* minimum is reported as an incompatible request, never as "effort
|
|
750
|
+
* unsupported", and the ceiling is never raised further to force an answer.
|
|
751
|
+
*/
|
|
752
|
+
async probeEffort(credential, model, effort, signal) {
|
|
753
|
+
const international = regionOf(credential.domain) === "global";
|
|
754
|
+
const payload = {
|
|
755
|
+
model,
|
|
756
|
+
stream: true,
|
|
757
|
+
messages: [...international ? [{
|
|
758
|
+
role: "system",
|
|
759
|
+
content: INTERNATIONAL_SYSTEM_PROMPT
|
|
760
|
+
}] : [], {
|
|
761
|
+
role: "user",
|
|
762
|
+
content: PROBE_PROMPT
|
|
763
|
+
}],
|
|
764
|
+
max_tokens: international ? INTERNATIONAL_PROBE_MAX_TOKENS : 1
|
|
765
|
+
};
|
|
766
|
+
if (effort !== void 0) payload["reasoning_effort"] = effort;
|
|
767
|
+
let response;
|
|
768
|
+
try {
|
|
769
|
+
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
770
|
+
method: "POST",
|
|
771
|
+
headers: {
|
|
772
|
+
...chatHeaders(credential),
|
|
773
|
+
"Authorization": `Bearer ${credential.accessToken}`
|
|
774
|
+
},
|
|
775
|
+
body: JSON.stringify(payload),
|
|
776
|
+
signal
|
|
777
|
+
});
|
|
778
|
+
} catch (error) {
|
|
779
|
+
return {
|
|
780
|
+
status: 0,
|
|
781
|
+
streamed: false,
|
|
782
|
+
detail: `transport error: ${String(error)}`
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
if (!response.ok) {
|
|
786
|
+
const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
|
|
787
|
+
return {
|
|
788
|
+
status: response.status,
|
|
789
|
+
streamed: false,
|
|
790
|
+
...errorCodeOf(text)
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const streamed = await readFirstEvent(response);
|
|
794
|
+
return {
|
|
795
|
+
status: response.status,
|
|
796
|
+
streamed
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
/** Pull `extError.code` out of an upstream error body, if it is shaped that way. */
|
|
801
|
+
function errorCodeOf(text) {
|
|
802
|
+
try {
|
|
803
|
+
const parsed = JSON.parse(text);
|
|
804
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
805
|
+
const extError = parsed["extError"];
|
|
806
|
+
if (typeof extError === "object" && extError !== null && !Array.isArray(extError)) {
|
|
807
|
+
const code = extError["code"];
|
|
808
|
+
if (typeof code === "string") return {
|
|
809
|
+
errorCode: code,
|
|
810
|
+
detail: code
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
} catch {}
|
|
815
|
+
return { detail: text.slice(0, 200) };
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Consume just enough of a streaming response to know it really streams.
|
|
819
|
+
*
|
|
820
|
+
* Returns true on the first chunk containing a data line. Cancels the body
|
|
821
|
+
* afterwards; a stream that ends or errors before that counts as not streamed,
|
|
822
|
+
* because an empty 200 is not evidence the effort was accepted.
|
|
823
|
+
*/
|
|
824
|
+
async function readFirstEvent(response) {
|
|
825
|
+
const body = response.body;
|
|
826
|
+
if (body === null) return false;
|
|
827
|
+
const reader = body.getReader();
|
|
828
|
+
const decoder = new TextDecoder();
|
|
829
|
+
try {
|
|
830
|
+
for (;;) {
|
|
831
|
+
const { done, value } = await reader.read();
|
|
832
|
+
if (done) return false;
|
|
833
|
+
if (decoder.decode(value, { stream: true }).includes("data:")) return true;
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
return false;
|
|
837
|
+
} finally {
|
|
838
|
+
await reader.cancel().catch(() => {});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
function isObject(value) {
|
|
842
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
843
|
+
}
|
|
844
|
+
function positive(value) {
|
|
845
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
846
|
+
}
|
|
847
|
+
/** Parse either response shape after its envelope has been checked. */
|
|
848
|
+
function parseModelCatalog(data, international = false) {
|
|
849
|
+
const rawModels = Array.isArray(data["models"]) ? data["models"] : [];
|
|
850
|
+
const agents = Array.isArray(data["agents"]) ? data["agents"] : [];
|
|
851
|
+
let cliIds;
|
|
852
|
+
for (const agent of agents) if (typeof agent === "object" && agent !== null) {
|
|
853
|
+
const wrapped = agent;
|
|
854
|
+
if (wrapped["name"] === "cli" && Array.isArray(wrapped["models"])) {
|
|
855
|
+
cliIds = wrapped["models"].filter((id) => typeof id === "string");
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (cliIds === void 0 || cliIds.length === 0) throw new Error("workbuddy model catalog lists no cli agent models");
|
|
860
|
+
const byId = /* @__PURE__ */ new Map();
|
|
861
|
+
for (const model of rawModels) {
|
|
862
|
+
if (typeof model !== "object" || model === null) continue;
|
|
863
|
+
const wrapped = model;
|
|
864
|
+
const id = typeof wrapped["id"] === "string" ? wrapped["id"] : "";
|
|
865
|
+
if (id === "" || wrapped["disabled"] === true) continue;
|
|
866
|
+
const input = typeof wrapped["maxInputTokens"] === "number" ? wrapped["maxInputTokens"] : 0;
|
|
867
|
+
const output = typeof wrapped["maxOutputTokens"] === "number" ? wrapped["maxOutputTokens"] : 0;
|
|
868
|
+
if (input <= 0 || output <= 0) continue;
|
|
869
|
+
byId.set(id, {
|
|
870
|
+
id,
|
|
871
|
+
name: typeof wrapped["name"] === "string" && wrapped["name"] !== "" ? wrapped["name"] : id,
|
|
872
|
+
contextWindow: international && isObject(wrapped["contextWindow"]) && positive(wrapped["contextWindow"]["defaultLength"]) ? wrapped["contextWindow"]["defaultLength"] : input,
|
|
873
|
+
...international ? {
|
|
874
|
+
maxInputTokens: input,
|
|
875
|
+
supportedContextWindows: isObject(wrapped["contextWindow"]) && Array.isArray(wrapped["contextWindow"]["supportedLengths"]) ? wrapped["contextWindow"]["supportedLengths"].filter(positive) : [],
|
|
876
|
+
promotions: parsePromotions(data["modelPromotions"], id)
|
|
877
|
+
} : {},
|
|
878
|
+
maxTokens: output,
|
|
879
|
+
supportsImages: wrapped["supportsImages"] === true && wrapped["disabledMultimodal"] !== true,
|
|
880
|
+
...resolveUpstreamReasoning(wrapped),
|
|
881
|
+
...resolveUpstreamBilling(wrapped)
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
const models = cliIds.map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
885
|
+
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
886
|
+
return models;
|
|
887
|
+
}
|
|
888
|
+
/** Extract the promotions covering `model` from the `modelPromotions` array. */
|
|
889
|
+
function parsePromotions(value, model) {
|
|
890
|
+
if (!Array.isArray(value)) return [];
|
|
891
|
+
return value.flatMap((item) => {
|
|
892
|
+
if (!isObject(item) || item["enabled"] !== true) return [];
|
|
893
|
+
const modelIds = item["modelIds"];
|
|
894
|
+
if (!Array.isArray(modelIds) || !modelIds.includes(model)) return [];
|
|
895
|
+
const schedule = item["schedule"];
|
|
896
|
+
const discount = item["discount"];
|
|
897
|
+
const badge = item["badge"];
|
|
898
|
+
if (!isObject(schedule) || !isObject(discount) || !isObject(badge)) return [];
|
|
899
|
+
if (discount["displayMode"] !== "replace") return [];
|
|
900
|
+
const start = typeof schedule["validFrom"] === "string" ? Date.parse(schedule["validFrom"]) : NaN;
|
|
901
|
+
const end = typeof schedule["validUntil"] === "string" ? Date.parse(schedule["validUntil"]) : NaN;
|
|
902
|
+
const factor = discount["factor"];
|
|
903
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return [];
|
|
904
|
+
if (typeof factor !== "number" || !Number.isFinite(factor) || factor < 0) return [];
|
|
905
|
+
return [{
|
|
906
|
+
start,
|
|
907
|
+
end,
|
|
908
|
+
factor,
|
|
909
|
+
label: typeof badge["label"] === "string" ? badge["label"] : "",
|
|
910
|
+
priority: typeof item["priority"] === "number" && Number.isFinite(item["priority"]) ? item["priority"] : 0
|
|
911
|
+
}];
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Re-evaluate a model's promotion against the current time.
|
|
916
|
+
*
|
|
917
|
+
* Promotions are time-boxed, and the catalog they arrive in is cached for the
|
|
918
|
+
* life of the process. Frozen at parse time, a cached "Free now" would keep
|
|
919
|
+
* claiming a discount after `validUntil` had passed, and would keep showing the
|
|
920
|
+
* pre-discount rate as the discounted one. Re-deriving on every read means the
|
|
921
|
+
* badge disappears on its own and the rate reverts, with no refresh needed.
|
|
922
|
+
*
|
|
923
|
+
* Non-destructive: the model's own `credits` and `badges` are the base, and the
|
|
924
|
+
* promotion is layered onto a copy. A model with no live promotion is returned
|
|
925
|
+
* as-is, so the common case allocates nothing.
|
|
926
|
+
*/
|
|
927
|
+
function modelWithCurrentPromotion(model, now = Date.now()) {
|
|
928
|
+
if (model.promotions === void 0 || model.promotions.length === 0) return model;
|
|
929
|
+
const promotion = [...model.promotions].sort((a, b) => b.priority - a.priority).find((candidate) => now >= candidate.start && now < candidate.end);
|
|
930
|
+
if (promotion === void 0) {
|
|
931
|
+
if (!(model.billing?.free === true || (model.billing?.badges?.length ?? 0) > 0 || model.promotions.some((candidate) => candidate.factor !== 1))) return model;
|
|
932
|
+
return {
|
|
933
|
+
...model,
|
|
934
|
+
billing: {
|
|
935
|
+
free: false,
|
|
936
|
+
rateUnknown: true
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const rate = normalizeCredits(model.billing?.credits);
|
|
941
|
+
const original = rate !== void 0 && rate.startsWith("x") ? Number(rate.slice(1)) : NaN;
|
|
942
|
+
if (promotion.factor !== 0 && !Number.isFinite(original)) return model;
|
|
943
|
+
const value = promotion.factor === 0 ? 0 : original * promotion.factor;
|
|
944
|
+
return {
|
|
945
|
+
...model,
|
|
946
|
+
billing: {
|
|
947
|
+
...model.billing,
|
|
948
|
+
credits: `x${value.toFixed(2)}`,
|
|
949
|
+
free: value === 0,
|
|
950
|
+
badges: [...model.billing?.badges ?? [], ...promotion.label === "" ? [] : [promotion.label]]
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Apply the international endpoint's extra chat requirement: the first message
|
|
956
|
+
* must be a system prompt.
|
|
957
|
+
*
|
|
958
|
+
* The international gateway rejects a body whose first message is not `system`
|
|
959
|
+
* with HTTP 400 code 11128 ("first message is not system prompt"). Note that
|
|
960
|
+
* the *same* code means something else on the CN endpoint — there it reports a
|
|
961
|
+
* rejected `developer` role — so the two are never branched on by code alone.
|
|
962
|
+
*
|
|
963
|
+
* The added prompt is deliberately empty of user content and prepended, never
|
|
964
|
+
* merged: existing messages keep their order and wording. A body that is not a
|
|
965
|
+
* JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
|
|
966
|
+
* so this is safe to run over an already-prepared-or-not body.
|
|
967
|
+
*/
|
|
968
|
+
function prepareInternationalChatBody(source) {
|
|
969
|
+
const prepared = prepareChatBody(source);
|
|
970
|
+
let body;
|
|
971
|
+
try {
|
|
972
|
+
body = JSON.parse(prepared);
|
|
973
|
+
} catch {
|
|
974
|
+
return prepared;
|
|
975
|
+
}
|
|
976
|
+
if (!isObject(body)) return prepared;
|
|
977
|
+
const messages = body["messages"];
|
|
978
|
+
if (!Array.isArray(messages)) return prepared;
|
|
979
|
+
const first = messages[0];
|
|
980
|
+
if (isObject(first) && first["role"] === "system") return prepared;
|
|
981
|
+
messages.unshift({
|
|
982
|
+
role: "system",
|
|
983
|
+
content: INTERNATIONAL_SYSTEM_PROMPT
|
|
984
|
+
});
|
|
985
|
+
return JSON.stringify(body);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* The system prompt injected when the international endpoint receives a body
|
|
989
|
+
* with none.
|
|
990
|
+
*
|
|
991
|
+
* Minimal on purpose: it exists to satisfy a gateway precondition, not to
|
|
992
|
+
* steer the model. The plugin is not the place to invent a persona, and the
|
|
993
|
+
* normal path never reaches this — pi-ai already sends the harness's system
|
|
994
|
+
* prompt, so this only covers a caller that omitted one.
|
|
995
|
+
*/
|
|
996
|
+
const INTERNATIONAL_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
997
|
+
/**
|
|
998
|
+
* Output ceiling for an international probe request.
|
|
999
|
+
*
|
|
1000
|
+
* Above the smallest value that the strictest observed model accepts (the
|
|
1001
|
+
* GPT-5.6 family rejects `1` with 11133), while still being far too small to
|
|
1002
|
+
* produce a real answer. See {@link WorkBuddyUpstreamClient.probeEffort}.
|
|
1003
|
+
*/
|
|
1004
|
+
const INTERNATIONAL_PROBE_MAX_TOKENS = 16;
|
|
1005
|
+
//#endregion
|
|
1006
|
+
//#region src/auth.ts
|
|
1007
|
+
/**
|
|
1008
|
+
* WorkBuddy credential resolution. The primary source is the WorkBuddy
|
|
1009
|
+
* desktop app's own auth file, read-only; a plugin-owned copy under
|
|
1010
|
+
* `$DSH_HOME` holds token refreshes so the desktop file is never written.
|
|
1011
|
+
* The effective credential is whichever of the two expires later, so a
|
|
1012
|
+
* refresh by either side wins.
|
|
1013
|
+
*
|
|
1014
|
+
* @module dsh-workbuddy-connect/auth
|
|
1015
|
+
*/
|
|
1016
|
+
/** Basename of the plugin-owned credential copy inside the Harness home. */
|
|
1017
|
+
const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
|
|
1018
|
+
/** Env variable that overrides the desktop auth-file location. */
|
|
1019
|
+
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
1020
|
+
/** Current on-disk format of the plugin-owned copy; readers reject others. */
|
|
1021
|
+
const OWN_FORMAT_VERSION = 1;
|
|
1022
|
+
/** Plugin-owned copy path inside the Harness home. */
|
|
1023
|
+
function workbuddyOwnAuthPath() {
|
|
1024
|
+
return join(resolveDshHome(), WORKBUDDY_AUTH_FILENAME);
|
|
1025
|
+
}
|
|
1026
|
+
const DESKTOP_AUTH_RELATIVE_PATH = [
|
|
1027
|
+
"CodeBuddyExtension",
|
|
1028
|
+
"Data",
|
|
1029
|
+
"Public",
|
|
1030
|
+
"auth",
|
|
1031
|
+
"workbuddy-desktop.info"
|
|
1032
|
+
];
|
|
1033
|
+
/** Whether this Linux process is running inside Windows Subsystem for Linux. */
|
|
1034
|
+
function isWsl() {
|
|
1035
|
+
if (process.platform !== "linux") return false;
|
|
1036
|
+
if (process.env["WSL_DISTRO_NAME"] !== void 0 || process.env["WSL_INTEROP"] !== void 0) return true;
|
|
1037
|
+
return release().toLowerCase().includes("microsoft");
|
|
1038
|
+
}
|
|
1039
|
+
/** Convert a Windows drive path to WSL's conventional `/mnt/<drive>` form. */
|
|
1040
|
+
function windowsPathForWsl(value) {
|
|
1041
|
+
const path = value?.trim();
|
|
1042
|
+
if (!path) return void 0;
|
|
1043
|
+
if (path.startsWith("/")) return path;
|
|
1044
|
+
const drivePath = /^([a-z]):[\\/](.*)$/iu.exec(path);
|
|
1045
|
+
if (drivePath === null) return void 0;
|
|
1046
|
+
return join("/mnt", drivePath[1].toLowerCase(), ...drivePath[2].split(/[\\/]+/u));
|
|
1047
|
+
}
|
|
1048
|
+
/** Windows desktop credential candidates visible from a WSL process. */
|
|
1049
|
+
function wslDesktopAuthCandidates(home) {
|
|
1050
|
+
const profile = windowsPathForWsl(process.env["USERPROFILE"]) ?? join("/mnt/c/Users", basename(home));
|
|
1051
|
+
const localAppData = windowsPathForWsl(process.env["LOCALAPPDATA"]) ?? join(profile, "AppData", "Local");
|
|
1052
|
+
const roamingAppData = windowsPathForWsl(process.env["APPDATA"]) ?? join(profile, "AppData", "Roaming");
|
|
1053
|
+
return [join(localAppData, ...DESKTOP_AUTH_RELATIVE_PATH), join(roamingAppData, ...DESKTOP_AUTH_RELATIVE_PATH)];
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Platform-default candidates for the WorkBuddy desktop app's auth file, in
|
|
1057
|
+
* probe order. Windows probes both AppData roots: current builds write under
|
|
1058
|
+
* `%LOCALAPPDATA%` (Local), older ones under `%APPDATA%` (Roaming). WSL probes
|
|
1059
|
+
* those same Windows locations through its mounted Windows profile before the
|
|
1060
|
+
* native Linux location.
|
|
1061
|
+
*/
|
|
1062
|
+
function defaultDesktopAuthCandidates() {
|
|
1063
|
+
const home = homedir();
|
|
1064
|
+
if (process.platform === "darwin") return [join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info")];
|
|
1065
|
+
if (process.platform === "win32") return [join(home, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info"), join(home, "AppData", "Roaming", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info")];
|
|
1066
|
+
if (process.platform === "linux") {
|
|
1067
|
+
const linux = join(home, ".config", ...DESKTOP_AUTH_RELATIVE_PATH);
|
|
1068
|
+
return isWsl() ? [...wslDesktopAuthCandidates(home), linux] : [linux];
|
|
1069
|
+
}
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* The platform-default candidates for one variant, in probe order.
|
|
1074
|
+
*
|
|
1075
|
+
* Both apps write into the *same* shared `CodeBuddyExtension` auth directory
|
|
1076
|
+
* and differ only in the file's basename, so the per-platform ordering above
|
|
1077
|
+
* is reused verbatim and just the filename is swapped.
|
|
1078
|
+
*/
|
|
1079
|
+
function desktopAuthCandidatesFor(variant) {
|
|
1080
|
+
return defaultDesktopAuthCandidates().map((path) => join(dirname(path), variant.desktopFilename));
|
|
1081
|
+
}
|
|
1082
|
+
/** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
|
|
1083
|
+
function defaultDesktopAuthPath(variant) {
|
|
1084
|
+
return (variant === void 0 ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(variant))[0];
|
|
1085
|
+
}
|
|
1086
|
+
/** Normalize an expiry that may arrive in seconds or milliseconds. */
|
|
1087
|
+
function expiryToMs(value) {
|
|
1088
|
+
if (value <= 0) return 0;
|
|
1089
|
+
return value > 0xe8d4a51000 ? value : value * 1e3;
|
|
1090
|
+
}
|
|
1091
|
+
function optionalString(value) {
|
|
1092
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Parse a WorkBuddy auth document in either on-disk shape: the plugin OAuth
|
|
1096
|
+
* nested form `{"auth":{...},"account":{...}}` and the flat panel form.
|
|
1097
|
+
* Returns undefined when the document carries no access token.
|
|
1098
|
+
*/
|
|
1099
|
+
function parseWorkBuddyAuth(text) {
|
|
1100
|
+
let parsed;
|
|
1101
|
+
try {
|
|
1102
|
+
parsed = JSON.parse(text);
|
|
1103
|
+
} catch {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1107
|
+
const document = parsed;
|
|
1108
|
+
let auth;
|
|
1109
|
+
let identity;
|
|
1110
|
+
if (typeof document["auth"] === "object" && document["auth"] !== null) {
|
|
1111
|
+
auth = document["auth"];
|
|
1112
|
+
identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : {};
|
|
1113
|
+
} else {
|
|
1114
|
+
auth = document;
|
|
1115
|
+
identity = document;
|
|
1116
|
+
}
|
|
1117
|
+
const accessToken = typeof auth["accessToken"] === "string" ? auth["accessToken"] : "";
|
|
1118
|
+
if (accessToken === "") return void 0;
|
|
1119
|
+
const expiresAtMs = typeof auth["expiresAt"] === "number" ? expiryToMs(auth["expiresAt"]) : 0;
|
|
1120
|
+
const refreshExpiresAtMs = typeof auth["refreshExpiresAt"] === "number" ? expiryToMs(auth["refreshExpiresAt"]) : void 0;
|
|
1121
|
+
const enterpriseId = optionalString(identity["enterpriseId"]);
|
|
1122
|
+
const nickname = optionalString(identity["nickname"]);
|
|
1123
|
+
return {
|
|
1124
|
+
accessToken,
|
|
1125
|
+
refreshToken: typeof auth["refreshToken"] === "string" ? auth["refreshToken"] : "",
|
|
1126
|
+
expiresAtMs,
|
|
1127
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
1128
|
+
domain: optionalString(auth["domain"]) ?? "",
|
|
1129
|
+
uid: optionalString(identity["uid"]) ?? "",
|
|
1130
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
1131
|
+
...nickname === void 0 ? {} : { nickname },
|
|
1132
|
+
source: "desktop"
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/** Serialize the plugin-owned copy. */
|
|
1136
|
+
function ownDocument(credential) {
|
|
1137
|
+
return {
|
|
1138
|
+
version: OWN_FORMAT_VERSION,
|
|
1139
|
+
credential
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
/** Parse the plugin-owned copy; other versions and shapes are rejected. */
|
|
1143
|
+
function parseOwnDocument(text) {
|
|
1144
|
+
let parsed;
|
|
1145
|
+
try {
|
|
1146
|
+
parsed = JSON.parse(text);
|
|
1147
|
+
} catch {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1151
|
+
const document = parsed;
|
|
1152
|
+
if (document["version"] !== OWN_FORMAT_VERSION) return void 0;
|
|
1153
|
+
if (typeof document["credential"] !== "object" || document["credential"] === null) return void 0;
|
|
1154
|
+
const stored = document["credential"];
|
|
1155
|
+
const accessToken = typeof stored["accessToken"] === "string" ? stored["accessToken"] : "";
|
|
1156
|
+
if (accessToken === "") return void 0;
|
|
1157
|
+
const refreshExpiresAtMs = typeof stored["refreshExpiresAtMs"] === "number" ? stored["refreshExpiresAtMs"] : void 0;
|
|
1158
|
+
const enterpriseId = optionalString(stored["enterpriseId"]);
|
|
1159
|
+
const nickname = optionalString(stored["nickname"]);
|
|
1160
|
+
return {
|
|
1161
|
+
accessToken,
|
|
1162
|
+
refreshToken: typeof stored["refreshToken"] === "string" ? stored["refreshToken"] : "",
|
|
1163
|
+
expiresAtMs: typeof stored["expiresAtMs"] === "number" ? stored["expiresAtMs"] : 0,
|
|
1164
|
+
...refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs },
|
|
1165
|
+
domain: optionalString(stored["domain"]) ?? "",
|
|
1166
|
+
uid: optionalString(stored["uid"]) ?? "",
|
|
1167
|
+
...enterpriseId === void 0 ? {} : { enterpriseId },
|
|
1168
|
+
...nickname === void 0 ? {} : { nickname },
|
|
1169
|
+
source: "dsh"
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
/** Whether a filesystem error reports an absent path. */
|
|
1173
|
+
function isENOENT(error) {
|
|
1174
|
+
return error?.code === "ENOENT";
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Read-only credential store with demand-driven refresh.
|
|
1178
|
+
*
|
|
1179
|
+
* Refresh policy: refresh only when the access token is inside the margin
|
|
1180
|
+
* (or already expired), keep the refreshed credential in the plugin-owned
|
|
1181
|
+
* copy, and never write the desktop app's file. A failed refresh still
|
|
1182
|
+
* returns a not-yet-expired token so an unreachable refresh endpoint does
|
|
1183
|
+
* not take down a working session.
|
|
1184
|
+
*/
|
|
1185
|
+
var WorkBuddyCredentialStore = class {
|
|
1186
|
+
variant;
|
|
1187
|
+
refresh;
|
|
1188
|
+
refreshMarginMs;
|
|
1189
|
+
ownPath;
|
|
1190
|
+
desktopPathOverride;
|
|
1191
|
+
inflight;
|
|
1192
|
+
constructor(options) {
|
|
1193
|
+
this.variant = options.variant;
|
|
1194
|
+
this.refresh = options.refresh;
|
|
1195
|
+
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
1196
|
+
this.ownPath = options.ownPath ?? (options.variant ? join(resolveDshHome(), options.variant.ownFilename) : workbuddyOwnAuthPath());
|
|
1197
|
+
this.desktopPathOverride = options.desktopPath;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Configuration precedence for the desktop file: the plugin's configured
|
|
1201
|
+
* path, then the environment variable, then the platform defaults. An
|
|
1202
|
+
* explicit path is used verbatim; the defaults are a probe order.
|
|
1203
|
+
*/
|
|
1204
|
+
resolveDesktopCandidates() {
|
|
1205
|
+
const fromEnv = process.env[this.variant?.env ?? "WORKBUDDY_AUTH_FILE"];
|
|
1206
|
+
const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
|
|
1207
|
+
if (explicit !== void 0) return [explicit];
|
|
1208
|
+
return this.variant === void 0 ? defaultDesktopAuthCandidates() : desktopAuthCandidatesFor(this.variant);
|
|
1209
|
+
}
|
|
1210
|
+
resolveDesktopPath() {
|
|
1211
|
+
return this.resolveDesktopCandidates()[0];
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Repoint the desktop file; a settings change applies on the next read.
|
|
1215
|
+
*/
|
|
1216
|
+
setDesktopPath(path) {
|
|
1217
|
+
this.desktopPathOverride = path;
|
|
1218
|
+
}
|
|
1219
|
+
/** The resolved desktop auth-file path, for diagnostics. */
|
|
1220
|
+
desktopAuthPath() {
|
|
1221
|
+
return this.resolveDesktopPath();
|
|
1222
|
+
}
|
|
1223
|
+
/** The plugin-owned copy path, for diagnostics. */
|
|
1224
|
+
ownAuthPath() {
|
|
1225
|
+
return this.ownPath;
|
|
1226
|
+
}
|
|
1227
|
+
/** Read the freshest stored credential without refreshing anything. */
|
|
1228
|
+
async current() {
|
|
1229
|
+
const [desktop, own] = await Promise.all([this.readDesktop(), this.readOwn()]);
|
|
1230
|
+
if (this.variant !== void 0) for (const [label, credential] of [["desktop file", desktop], ["plugin copy", own]]) {
|
|
1231
|
+
if (credential === void 0) continue;
|
|
1232
|
+
const region = regionOf(credential.domain);
|
|
1233
|
+
if (region !== this.variant.region) throw new Error(`${this.variant.displayName} received a ${region === "cn" ? "WorkBuddy (CN)" : "WorkBuddy AI"} credential in its ${label} (domain ${JSON.stringify(credential.domain)}); point ${this.variant.env} at the ${this.variant.appName} sign-in, or remove the mismatched file`);
|
|
1234
|
+
}
|
|
1235
|
+
if (desktop === void 0) return own;
|
|
1236
|
+
if (own === void 0) return desktop;
|
|
1237
|
+
if (desktop.uid !== own.uid || desktop.enterpriseId !== own.enterpriseId) return desktop;
|
|
1238
|
+
return own.expiresAtMs > desktop.expiresAtMs ? own : desktop;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The credential to send upstream: {@link current}, refreshed on demand.
|
|
1242
|
+
* Single-flight, so parallel requests share one refresh.
|
|
1243
|
+
*/
|
|
1244
|
+
async resolve() {
|
|
1245
|
+
const credential = await this.current();
|
|
1246
|
+
if (credential === void 0) {
|
|
1247
|
+
const candidates = this.resolveDesktopCandidates();
|
|
1248
|
+
const desktop = candidates.length > 0 ? candidates.join(" or ") : "(no desktop path on this platform)";
|
|
1249
|
+
const app = this.variant?.appName ?? "WorkBuddy";
|
|
1250
|
+
throw new Error(`workbuddy: no signed-in ${app} account found; sign in once in the ${app} desktop app (expected ${desktop} or ${this.variant?.env ?? "WORKBUDDY_AUTH_FILE"}), or refresh an existing session`);
|
|
1251
|
+
}
|
|
1252
|
+
if (!this.needsRefresh(credential)) return credential;
|
|
1253
|
+
this.inflight ??= this.refreshNow(credential).finally(() => {
|
|
1254
|
+
this.inflight = void 0;
|
|
1255
|
+
});
|
|
1256
|
+
return this.inflight;
|
|
1257
|
+
}
|
|
1258
|
+
/** Read-only sign-in summary; never refreshes and never throws. */
|
|
1259
|
+
async status() {
|
|
1260
|
+
try {
|
|
1261
|
+
const credential = await this.current();
|
|
1262
|
+
if (credential === void 0) return { state: "signed-out" };
|
|
1263
|
+
return {
|
|
1264
|
+
state: "signed-in",
|
|
1265
|
+
expiresAtMs: credential.expiresAtMs,
|
|
1266
|
+
...credential.refreshExpiresAtMs === void 0 ? {} : { refreshExpiresAtMs: credential.refreshExpiresAtMs },
|
|
1267
|
+
...credential.nickname === void 0 ? {} : { nickname: credential.nickname },
|
|
1268
|
+
...credential.domain === "" ? {} : { domain: credential.domain },
|
|
1269
|
+
source: credential.source
|
|
1270
|
+
};
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
return {
|
|
1273
|
+
state: "signed-out",
|
|
1274
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/** Remove the plugin-owned copy; the desktop file is untouched. */
|
|
1279
|
+
async logout() {
|
|
1280
|
+
await rm(this.ownPath, { force: true });
|
|
1281
|
+
await rm(`${this.ownPath}.lock`, { force: true });
|
|
1282
|
+
}
|
|
1283
|
+
needsRefresh(credential) {
|
|
1284
|
+
if (credential.expiresAtMs <= 0) return true;
|
|
1285
|
+
return Date.now() + this.refreshMarginMs >= credential.expiresAtMs;
|
|
1286
|
+
}
|
|
1287
|
+
async refreshNow(credential) {
|
|
1288
|
+
if (credential.refreshToken === "") {
|
|
1289
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
1290
|
+
throw new Error("workbuddy: access token expired and no refresh token is stored; sign in again in the WorkBuddy desktop app");
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const outcome = await this.refresh(credential);
|
|
1294
|
+
const refreshed = {
|
|
1295
|
+
...credential,
|
|
1296
|
+
accessToken: outcome.accessToken,
|
|
1297
|
+
...outcome.refreshToken === void 0 ? {} : { refreshToken: outcome.refreshToken },
|
|
1298
|
+
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
1299
|
+
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain },
|
|
1300
|
+
source: "dsh"
|
|
1301
|
+
};
|
|
1302
|
+
await this.saveOwn(refreshed);
|
|
1303
|
+
return refreshed;
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return credential;
|
|
1306
|
+
throw new Error(`workbuddy: token refresh failed and the access token is expired (${String(error)}); open the WorkBuddy desktop app once to sign in again`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
async saveOwn(credential) {
|
|
1310
|
+
await withFileLock(this.ownPath, async () => {
|
|
1311
|
+
await writeFileAtomic(this.ownPath, `${JSON.stringify(ownDocument(credential), null, 2)}\n`, {
|
|
1312
|
+
mode: 384,
|
|
1313
|
+
dirMode: 448
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Read the first desktop candidate that exists. Only an absent file
|
|
1319
|
+
* (ENOENT) falls through to the next candidate; a file that is present
|
|
1320
|
+
* but unparsable is authoritative for its slot, so a stale older-version
|
|
1321
|
+
* file never silently wins over a broken newer one.
|
|
1322
|
+
*/
|
|
1323
|
+
async readDesktop() {
|
|
1324
|
+
for (const desktopPath of this.resolveDesktopCandidates()) try {
|
|
1325
|
+
return parseWorkBuddyAuth(await readFile(desktopPath, "utf8"));
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (!isENOENT(error)) throw error;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
async readOwn() {
|
|
1331
|
+
try {
|
|
1332
|
+
return parseOwnDocument(await readFile(this.ownPath, "utf8"));
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
if (isENOENT(error)) return void 0;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/** Whether any desktop-file candidate exists as a regular file; diagnostics only. */
|
|
1339
|
+
async desktopFilePresent() {
|
|
1340
|
+
for (const desktopPath of this.resolveDesktopCandidates()) try {
|
|
1341
|
+
if ((await stat(desktopPath)).isFile()) return true;
|
|
1342
|
+
} catch {}
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/catalog.ts
|
|
1348
|
+
/**
|
|
1349
|
+
* WorkBuddy model catalog: a static fallback list captured from the live
|
|
1350
|
+
* endpoint, replaced by the upstream's dynamic answer once it loads.
|
|
1351
|
+
*
|
|
1352
|
+
* @module dsh-workbuddy-connect/catalog
|
|
1353
|
+
*/
|
|
1354
|
+
/**
|
|
1355
|
+
* Static CLI models observed on the CN endpoint (re-verified against the live
|
|
1356
|
+
* catalog 2026-09-01, including the thinking-effort and billing metadata). The
|
|
1357
|
+
* upstream refresh replaces this list at startup; it exists so the provider
|
|
1358
|
+
* registers with a usable catalog even while the first fetch is in flight or
|
|
1359
|
+
* offline.
|
|
1360
|
+
*
|
|
1361
|
+
* The list tracks the `cli` agent's model roster exactly: the 16 models the
|
|
1362
|
+
* desktop CLI offers. Reasoning metadata is taken verbatim from the live
|
|
1363
|
+
* endpoint — each model's supported effort set and whether thinking can be
|
|
1364
|
+
* disabled — and the `free` flag follows the upstream `x0.00` credits marker.
|
|
1365
|
+
*/
|
|
1366
|
+
const FALLBACK_WORKBUDDY_MODELS = [
|
|
1367
|
+
{
|
|
1368
|
+
id: "auto",
|
|
1369
|
+
name: "Auto",
|
|
1370
|
+
contextWindow: 168e3,
|
|
1371
|
+
maxTokens: 32e3,
|
|
1372
|
+
supportsImages: true,
|
|
1373
|
+
reasoning: {
|
|
1374
|
+
supports: true,
|
|
1375
|
+
onlyReasoning: true,
|
|
1376
|
+
defaultEffort: "high",
|
|
1377
|
+
canDisableThinking: false
|
|
1378
|
+
},
|
|
1379
|
+
billing: { free: false }
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
id: "hy4-preview",
|
|
1383
|
+
name: "Hy4 preview",
|
|
1384
|
+
contextWindow: 1e6,
|
|
1385
|
+
maxTokens: 64e3,
|
|
1386
|
+
supportsImages: true,
|
|
1387
|
+
reasoning: {
|
|
1388
|
+
supports: true,
|
|
1389
|
+
onlyReasoning: true,
|
|
1390
|
+
supportedEfforts: ["high"],
|
|
1391
|
+
defaultEffort: "high",
|
|
1392
|
+
canDisableThinking: false
|
|
1393
|
+
},
|
|
1394
|
+
billing: {
|
|
1395
|
+
free: false,
|
|
1396
|
+
rateUnknown: true
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
id: "hy3",
|
|
1401
|
+
name: "Hy3",
|
|
1402
|
+
contextWindow: 192e3,
|
|
1403
|
+
maxTokens: 64e3,
|
|
1404
|
+
supportsImages: true,
|
|
1405
|
+
reasoning: {
|
|
1406
|
+
supports: true,
|
|
1407
|
+
onlyReasoning: true,
|
|
1408
|
+
defaultEffort: "high",
|
|
1409
|
+
canDisableThinking: false
|
|
1410
|
+
},
|
|
1411
|
+
billing: {
|
|
1412
|
+
free: false,
|
|
1413
|
+
rateUnknown: true
|
|
1414
|
+
}
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
id: "hy3-x",
|
|
1418
|
+
name: "Hy3",
|
|
1419
|
+
contextWindow: 192e3,
|
|
1420
|
+
maxTokens: 64e3,
|
|
1421
|
+
supportsImages: true,
|
|
1422
|
+
reasoning: {
|
|
1423
|
+
supports: true,
|
|
1424
|
+
onlyReasoning: true,
|
|
1425
|
+
supportedEfforts: ["low", "high"],
|
|
1426
|
+
defaultEffort: "high",
|
|
1427
|
+
canDisableThinking: false
|
|
1428
|
+
},
|
|
1429
|
+
billing: {
|
|
1430
|
+
credits: "x0.05",
|
|
1431
|
+
free: false
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
{
|
|
1435
|
+
id: "deepseek-v4.1-flash",
|
|
1436
|
+
name: "Deepseek-V4.1-Flash",
|
|
1437
|
+
contextWindow: 1e6,
|
|
1438
|
+
maxTokens: 128e3,
|
|
1439
|
+
supportsImages: true,
|
|
1440
|
+
reasoning: {
|
|
1441
|
+
supports: true,
|
|
1442
|
+
onlyReasoning: true,
|
|
1443
|
+
defaultEffort: "high",
|
|
1444
|
+
canDisableThinking: false
|
|
1445
|
+
},
|
|
1446
|
+
billing: {
|
|
1447
|
+
credits: "x0.03 credits",
|
|
1448
|
+
badges: ["独家优惠"],
|
|
1449
|
+
free: false
|
|
1450
|
+
}
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
id: "glm-5.3",
|
|
1454
|
+
name: "GLM-5.3",
|
|
1455
|
+
contextWindow: 1e6,
|
|
1456
|
+
maxTokens: 48e3,
|
|
1457
|
+
supportsImages: true,
|
|
1458
|
+
reasoning: {
|
|
1459
|
+
supports: true,
|
|
1460
|
+
onlyReasoning: true,
|
|
1461
|
+
supportedEfforts: [
|
|
1462
|
+
"low",
|
|
1463
|
+
"high",
|
|
1464
|
+
"max"
|
|
1465
|
+
],
|
|
1466
|
+
defaultEffort: "high",
|
|
1467
|
+
canDisableThinking: true
|
|
1468
|
+
},
|
|
1469
|
+
billing: {
|
|
1470
|
+
credits: "x0.79",
|
|
1471
|
+
free: false
|
|
1472
|
+
}
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
id: "glm-5.3-flash",
|
|
1476
|
+
name: "GLM-5.3-Flash",
|
|
1477
|
+
contextWindow: 1e6,
|
|
1478
|
+
maxTokens: 32e3,
|
|
1479
|
+
supportsImages: true,
|
|
1480
|
+
reasoning: {
|
|
1481
|
+
supports: true,
|
|
1482
|
+
onlyReasoning: true,
|
|
1483
|
+
supportedEfforts: [
|
|
1484
|
+
"low",
|
|
1485
|
+
"high",
|
|
1486
|
+
"max"
|
|
1487
|
+
],
|
|
1488
|
+
defaultEffort: "high",
|
|
1489
|
+
canDisableThinking: true
|
|
1490
|
+
},
|
|
1491
|
+
billing: {
|
|
1492
|
+
credits: "x0.06",
|
|
1493
|
+
free: false
|
|
1494
|
+
}
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
id: "glm-5.2",
|
|
1498
|
+
name: "GLM-5.2",
|
|
1499
|
+
contextWindow: 1e6,
|
|
1500
|
+
maxTokens: 48e3,
|
|
1501
|
+
supportsImages: true,
|
|
1502
|
+
reasoning: {
|
|
1503
|
+
supports: true,
|
|
1504
|
+
onlyReasoning: true,
|
|
1505
|
+
defaultEffort: "medium",
|
|
1506
|
+
canDisableThinking: false
|
|
1507
|
+
},
|
|
1508
|
+
billing: {
|
|
1509
|
+
credits: "x0.79 credits",
|
|
1510
|
+
badges: ["夜间折扣"],
|
|
1511
|
+
free: false
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
{
|
|
1515
|
+
id: "glm-5.1",
|
|
1516
|
+
name: "GLM-5.1",
|
|
1517
|
+
contextWindow: 2e5,
|
|
1518
|
+
maxTokens: 48e3,
|
|
1519
|
+
supportsImages: false,
|
|
1520
|
+
reasoning: {
|
|
1521
|
+
supports: true,
|
|
1522
|
+
onlyReasoning: true,
|
|
1523
|
+
defaultEffort: "medium",
|
|
1524
|
+
canDisableThinking: false
|
|
1525
|
+
},
|
|
1526
|
+
billing: {
|
|
1527
|
+
credits: "x0.79 credits",
|
|
1528
|
+
free: false
|
|
1529
|
+
}
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
id: "glm-5v-turbo",
|
|
1533
|
+
name: "GLM-5v-Turbo",
|
|
1534
|
+
contextWindow: 2e5,
|
|
1535
|
+
maxTokens: 64e3,
|
|
1536
|
+
supportsImages: true,
|
|
1537
|
+
reasoning: {
|
|
1538
|
+
supports: true,
|
|
1539
|
+
onlyReasoning: true,
|
|
1540
|
+
defaultEffort: "medium",
|
|
1541
|
+
canDisableThinking: false
|
|
1542
|
+
},
|
|
1543
|
+
billing: {
|
|
1544
|
+
credits: "x0.71 credits",
|
|
1545
|
+
free: false
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
id: "kimi-k3-1",
|
|
1550
|
+
name: "Kimi-K3",
|
|
1551
|
+
contextWindow: 1e6,
|
|
1552
|
+
maxTokens: 32e3,
|
|
1553
|
+
supportsImages: true,
|
|
1554
|
+
reasoning: {
|
|
1555
|
+
supports: true,
|
|
1556
|
+
onlyReasoning: true,
|
|
1557
|
+
defaultEffort: "medium",
|
|
1558
|
+
canDisableThinking: false
|
|
1559
|
+
},
|
|
1560
|
+
billing: {
|
|
1561
|
+
credits: "x1.62 credits",
|
|
1562
|
+
free: false
|
|
1563
|
+
}
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
id: "kimi-k2.8-preview",
|
|
1567
|
+
name: "Kimi-K2.8-Preview",
|
|
1568
|
+
contextWindow: 1e6,
|
|
1569
|
+
maxTokens: 32e3,
|
|
1570
|
+
supportsImages: true,
|
|
1571
|
+
reasoning: {
|
|
1572
|
+
supports: true,
|
|
1573
|
+
onlyReasoning: true,
|
|
1574
|
+
supportedEfforts: [
|
|
1575
|
+
"low",
|
|
1576
|
+
"high",
|
|
1577
|
+
"max"
|
|
1578
|
+
],
|
|
1579
|
+
defaultEffort: "high",
|
|
1580
|
+
canDisableThinking: true
|
|
1581
|
+
},
|
|
1582
|
+
billing: {
|
|
1583
|
+
credits: "x0.77 credits",
|
|
1584
|
+
free: false
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
{
|
|
1588
|
+
id: "kimi-k2.7",
|
|
1589
|
+
name: "Kimi-K2.7-Code",
|
|
1590
|
+
contextWindow: 256e3,
|
|
1591
|
+
maxTokens: 32e3,
|
|
1592
|
+
supportsImages: true,
|
|
1593
|
+
reasoning: {
|
|
1594
|
+
supports: true,
|
|
1595
|
+
onlyReasoning: true,
|
|
1596
|
+
defaultEffort: "medium",
|
|
1597
|
+
canDisableThinking: false
|
|
1598
|
+
},
|
|
1599
|
+
billing: {
|
|
1600
|
+
credits: "x0.57 credits",
|
|
1601
|
+
free: false
|
|
1602
|
+
}
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
id: "kimi-k2.6",
|
|
1606
|
+
name: "Kimi-K2.6",
|
|
1607
|
+
contextWindow: 256e3,
|
|
1608
|
+
maxTokens: 32e3,
|
|
1609
|
+
supportsImages: true,
|
|
1610
|
+
reasoning: {
|
|
1611
|
+
supports: true,
|
|
1612
|
+
onlyReasoning: true,
|
|
1613
|
+
defaultEffort: "medium",
|
|
1614
|
+
canDisableThinking: false
|
|
1615
|
+
},
|
|
1616
|
+
billing: {
|
|
1617
|
+
credits: "x0.52 credits",
|
|
1618
|
+
free: false
|
|
1619
|
+
}
|
|
1620
|
+
},
|
|
1621
|
+
{
|
|
1622
|
+
id: "minimax-m3",
|
|
1623
|
+
name: "MiniMax-M3",
|
|
1624
|
+
contextWindow: 512e3,
|
|
1625
|
+
maxTokens: 128e3,
|
|
1626
|
+
supportsImages: true,
|
|
1627
|
+
reasoning: {
|
|
1628
|
+
supports: true,
|
|
1629
|
+
onlyReasoning: true,
|
|
1630
|
+
defaultEffort: "medium",
|
|
1631
|
+
canDisableThinking: false
|
|
1632
|
+
},
|
|
1633
|
+
billing: {
|
|
1634
|
+
credits: "x0.25 credits",
|
|
1635
|
+
free: false
|
|
1636
|
+
}
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
id: "deepseek-v4-pro",
|
|
1640
|
+
name: "Deepseek-V4-Pro",
|
|
1641
|
+
contextWindow: 1e6,
|
|
1642
|
+
maxTokens: 5e4,
|
|
1643
|
+
supportsImages: true,
|
|
1644
|
+
reasoning: {
|
|
1645
|
+
supports: true,
|
|
1646
|
+
onlyReasoning: true,
|
|
1647
|
+
defaultEffort: "high",
|
|
1648
|
+
canDisableThinking: false
|
|
1649
|
+
},
|
|
1650
|
+
billing: {
|
|
1651
|
+
credits: "x0.51 credits",
|
|
1652
|
+
free: false
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
];
|
|
1656
|
+
/**
|
|
1657
|
+
* Static CLI models for the international endpoint, captured 2026-09-11 from
|
|
1658
|
+
* the App-form `/v3/config` document (the 20 ids of its `cli` agent, in order).
|
|
1659
|
+
*
|
|
1660
|
+
* Same purpose and same discipline as {@link FALLBACK_WORKBUDDY_MODELS}: it
|
|
1661
|
+
* covers the window before the first successful fetch and an offline start,
|
|
1662
|
+
* and it is deliberately *not* a promise about the upstream's current state.
|
|
1663
|
+
* Reasoning metadata is verbatim from that snapshot. No promo badge is baked
|
|
1664
|
+
* in: promotions are time-boxed (`modelPromotions` carries `validFrom`/
|
|
1665
|
+
* `validUntil`), so hard-coding a "Free now" label would keep claiming a
|
|
1666
|
+
* discount the upstream may have already ended.
|
|
1667
|
+
*/
|
|
1668
|
+
const FALLBACK_WORKBUDDY_AI_MODELS = [
|
|
1669
|
+
{
|
|
1670
|
+
id: "default-model",
|
|
1671
|
+
name: "Auto",
|
|
1672
|
+
contextWindow: 176e3,
|
|
1673
|
+
maxTokens: 24e3,
|
|
1674
|
+
supportsImages: true,
|
|
1675
|
+
reasoning: {
|
|
1676
|
+
supports: false,
|
|
1677
|
+
onlyReasoning: false,
|
|
1678
|
+
canDisableThinking: true
|
|
1679
|
+
},
|
|
1680
|
+
billing: { free: false }
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
id: "fast-model",
|
|
1684
|
+
name: "Fast",
|
|
1685
|
+
contextWindow: 2e5,
|
|
1686
|
+
maxTokens: 32e3,
|
|
1687
|
+
supportsImages: true,
|
|
1688
|
+
reasoning: {
|
|
1689
|
+
supports: true,
|
|
1690
|
+
onlyReasoning: true,
|
|
1691
|
+
defaultEffort: "medium",
|
|
1692
|
+
canDisableThinking: false
|
|
1693
|
+
},
|
|
1694
|
+
billing: {
|
|
1695
|
+
credits: "x0.34",
|
|
1696
|
+
free: false
|
|
1697
|
+
}
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
id: "balanced-model",
|
|
1701
|
+
name: "Balanced",
|
|
1702
|
+
contextWindow: 256e3,
|
|
1703
|
+
maxTokens: 32e3,
|
|
1704
|
+
supportsImages: true,
|
|
1705
|
+
reasoning: {
|
|
1706
|
+
supports: true,
|
|
1707
|
+
onlyReasoning: true,
|
|
1708
|
+
defaultEffort: "medium",
|
|
1709
|
+
canDisableThinking: false
|
|
1710
|
+
},
|
|
1711
|
+
billing: {
|
|
1712
|
+
credits: "x0.59",
|
|
1713
|
+
free: false
|
|
1714
|
+
}
|
|
1715
|
+
},
|
|
1716
|
+
{
|
|
1717
|
+
id: "primary-model",
|
|
1718
|
+
name: "Primary",
|
|
1719
|
+
contextWindow: 272e3,
|
|
1720
|
+
maxTokens: 72e3,
|
|
1721
|
+
supportsImages: true,
|
|
1722
|
+
reasoning: {
|
|
1723
|
+
supports: true,
|
|
1724
|
+
onlyReasoning: true,
|
|
1725
|
+
defaultEffort: "high",
|
|
1726
|
+
canDisableThinking: false
|
|
1727
|
+
},
|
|
1728
|
+
billing: {
|
|
1729
|
+
credits: "x3.31",
|
|
1730
|
+
free: false
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
id: "deep-model",
|
|
1735
|
+
name: "Deep",
|
|
1736
|
+
contextWindow: 176e3,
|
|
1737
|
+
maxTokens: 24e3,
|
|
1738
|
+
supportsImages: true,
|
|
1739
|
+
reasoning: {
|
|
1740
|
+
supports: false,
|
|
1741
|
+
onlyReasoning: false,
|
|
1742
|
+
canDisableThinking: true
|
|
1743
|
+
},
|
|
1744
|
+
billing: {
|
|
1745
|
+
credits: "x3.33",
|
|
1746
|
+
free: false
|
|
1747
|
+
}
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
id: "hy4-preview-f",
|
|
1751
|
+
name: "Hy4 preview",
|
|
1752
|
+
contextWindow: 3e5,
|
|
1753
|
+
maxTokens: 64e3,
|
|
1754
|
+
supportsImages: true,
|
|
1755
|
+
reasoning: {
|
|
1756
|
+
supports: true,
|
|
1757
|
+
onlyReasoning: true,
|
|
1758
|
+
supportedEfforts: ["high"],
|
|
1759
|
+
defaultEffort: "high",
|
|
1760
|
+
canDisableThinking: false
|
|
1761
|
+
},
|
|
1762
|
+
billing: {
|
|
1763
|
+
free: false,
|
|
1764
|
+
rateUnknown: true
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
{
|
|
1768
|
+
id: "hy3",
|
|
1769
|
+
name: "Hy3",
|
|
1770
|
+
contextWindow: 192e3,
|
|
1771
|
+
maxTokens: 64e3,
|
|
1772
|
+
supportsImages: true,
|
|
1773
|
+
reasoning: {
|
|
1774
|
+
supports: true,
|
|
1775
|
+
onlyReasoning: true,
|
|
1776
|
+
supportedEfforts: ["low", "high"],
|
|
1777
|
+
defaultEffort: "high",
|
|
1778
|
+
canDisableThinking: false
|
|
1779
|
+
},
|
|
1780
|
+
billing: {
|
|
1781
|
+
free: false,
|
|
1782
|
+
rateUnknown: true
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
id: "deepseek-v4.1-flash",
|
|
1787
|
+
name: "Deepseek-V4.1-Flash",
|
|
1788
|
+
contextWindow: 3e5,
|
|
1789
|
+
maxTokens: 128e3,
|
|
1790
|
+
supportsImages: true,
|
|
1791
|
+
reasoning: {
|
|
1792
|
+
supports: true,
|
|
1793
|
+
onlyReasoning: true,
|
|
1794
|
+
defaultEffort: "high",
|
|
1795
|
+
canDisableThinking: false
|
|
1796
|
+
},
|
|
1797
|
+
billing: {
|
|
1798
|
+
free: false,
|
|
1799
|
+
rateUnknown: true
|
|
1800
|
+
}
|
|
1801
|
+
},
|
|
1802
|
+
{
|
|
1803
|
+
id: "gpt-6-astra",
|
|
1804
|
+
name: "GPT-6-Astra",
|
|
1805
|
+
contextWindow: 4e5,
|
|
1806
|
+
maxTokens: 128e3,
|
|
1807
|
+
supportsImages: true,
|
|
1808
|
+
reasoning: {
|
|
1809
|
+
supports: true,
|
|
1810
|
+
onlyReasoning: true,
|
|
1811
|
+
supportedEfforts: [
|
|
1812
|
+
"low",
|
|
1813
|
+
"medium",
|
|
1814
|
+
"high",
|
|
1815
|
+
"xhigh",
|
|
1816
|
+
"max"
|
|
1817
|
+
],
|
|
1818
|
+
defaultEffort: "medium",
|
|
1819
|
+
canDisableThinking: true
|
|
1820
|
+
},
|
|
1821
|
+
billing: {
|
|
1822
|
+
credits: "x6.67",
|
|
1823
|
+
free: false
|
|
1824
|
+
}
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
id: "gpt-5.6-sol",
|
|
1828
|
+
name: "GPT-5.6-Sol",
|
|
1829
|
+
contextWindow: 1e6,
|
|
1830
|
+
maxTokens: 128e3,
|
|
1831
|
+
supportsImages: true,
|
|
1832
|
+
reasoning: {
|
|
1833
|
+
supports: true,
|
|
1834
|
+
onlyReasoning: true,
|
|
1835
|
+
supportedEfforts: [
|
|
1836
|
+
"low",
|
|
1837
|
+
"medium",
|
|
1838
|
+
"high",
|
|
1839
|
+
"xhigh",
|
|
1840
|
+
"max"
|
|
1841
|
+
],
|
|
1842
|
+
defaultEffort: "medium",
|
|
1843
|
+
canDisableThinking: true
|
|
1844
|
+
},
|
|
1845
|
+
billing: {
|
|
1846
|
+
credits: "x3.47",
|
|
1847
|
+
free: false
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
id: "gpt-5.6-terra",
|
|
1852
|
+
name: "GPT-5.6-Terra",
|
|
1853
|
+
contextWindow: 1e6,
|
|
1854
|
+
maxTokens: 128e3,
|
|
1855
|
+
supportsImages: true,
|
|
1856
|
+
reasoning: {
|
|
1857
|
+
supports: true,
|
|
1858
|
+
onlyReasoning: true,
|
|
1859
|
+
supportedEfforts: [
|
|
1860
|
+
"low",
|
|
1861
|
+
"medium",
|
|
1862
|
+
"high",
|
|
1863
|
+
"xhigh",
|
|
1864
|
+
"max"
|
|
1865
|
+
],
|
|
1866
|
+
defaultEffort: "medium",
|
|
1867
|
+
canDisableThinking: true
|
|
1868
|
+
},
|
|
1869
|
+
billing: {
|
|
1870
|
+
credits: "x1.39",
|
|
1871
|
+
free: false
|
|
1872
|
+
}
|
|
1873
|
+
},
|
|
1874
|
+
{
|
|
1875
|
+
id: "gpt-5.6-luna",
|
|
1876
|
+
name: "GPT-5.6-Luna",
|
|
1877
|
+
contextWindow: 1e6,
|
|
1878
|
+
maxTokens: 128e3,
|
|
1879
|
+
supportsImages: true,
|
|
1880
|
+
reasoning: {
|
|
1881
|
+
supports: true,
|
|
1882
|
+
onlyReasoning: true,
|
|
1883
|
+
supportedEfforts: [
|
|
1884
|
+
"low",
|
|
1885
|
+
"medium",
|
|
1886
|
+
"high",
|
|
1887
|
+
"xhigh",
|
|
1888
|
+
"max"
|
|
1889
|
+
],
|
|
1890
|
+
defaultEffort: "medium",
|
|
1891
|
+
canDisableThinking: true
|
|
1892
|
+
},
|
|
1893
|
+
billing: {
|
|
1894
|
+
credits: "x0.14",
|
|
1895
|
+
free: false
|
|
1896
|
+
}
|
|
1897
|
+
},
|
|
1898
|
+
{
|
|
1899
|
+
id: "gpt-5.5",
|
|
1900
|
+
name: "GPT-5.5",
|
|
1901
|
+
contextWindow: 1e6,
|
|
1902
|
+
maxTokens: 128e3,
|
|
1903
|
+
supportsImages: true,
|
|
1904
|
+
reasoning: {
|
|
1905
|
+
supports: true,
|
|
1906
|
+
onlyReasoning: true,
|
|
1907
|
+
supportedEfforts: [
|
|
1908
|
+
"low",
|
|
1909
|
+
"medium",
|
|
1910
|
+
"high",
|
|
1911
|
+
"xhigh"
|
|
1912
|
+
],
|
|
1913
|
+
defaultEffort: "medium",
|
|
1914
|
+
canDisableThinking: false
|
|
1915
|
+
},
|
|
1916
|
+
billing: {
|
|
1917
|
+
credits: "x3.31",
|
|
1918
|
+
free: false
|
|
1919
|
+
}
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
id: "gpt-5.4",
|
|
1923
|
+
name: "GPT-5.4",
|
|
1924
|
+
contextWindow: 272e3,
|
|
1925
|
+
maxTokens: 72e3,
|
|
1926
|
+
supportsImages: true,
|
|
1927
|
+
reasoning: {
|
|
1928
|
+
supports: true,
|
|
1929
|
+
onlyReasoning: true,
|
|
1930
|
+
supportedEfforts: [
|
|
1931
|
+
"low",
|
|
1932
|
+
"medium",
|
|
1933
|
+
"high",
|
|
1934
|
+
"xhigh"
|
|
1935
|
+
],
|
|
1936
|
+
defaultEffort: "medium",
|
|
1937
|
+
canDisableThinking: false
|
|
1938
|
+
},
|
|
1939
|
+
billing: {
|
|
1940
|
+
credits: "x1.65",
|
|
1941
|
+
free: false
|
|
1942
|
+
}
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
id: "gpt-5.3-codex",
|
|
1946
|
+
name: "GPT-5.3-Codex",
|
|
1947
|
+
contextWindow: 272e3,
|
|
1948
|
+
maxTokens: 72e3,
|
|
1949
|
+
supportsImages: true,
|
|
1950
|
+
reasoning: {
|
|
1951
|
+
supports: true,
|
|
1952
|
+
onlyReasoning: true,
|
|
1953
|
+
defaultEffort: "medium",
|
|
1954
|
+
canDisableThinking: false
|
|
1955
|
+
},
|
|
1956
|
+
billing: {
|
|
1957
|
+
credits: "x1.25",
|
|
1958
|
+
free: false
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
{
|
|
1962
|
+
id: "gemini-3.5-flash",
|
|
1963
|
+
name: "Gemini-3.5-Flash",
|
|
1964
|
+
contextWindow: 1e6,
|
|
1965
|
+
maxTokens: 65536,
|
|
1966
|
+
supportsImages: true,
|
|
1967
|
+
reasoning: {
|
|
1968
|
+
supports: true,
|
|
1969
|
+
onlyReasoning: true,
|
|
1970
|
+
defaultEffort: "medium",
|
|
1971
|
+
canDisableThinking: false
|
|
1972
|
+
},
|
|
1973
|
+
billing: {
|
|
1974
|
+
credits: "x0.99",
|
|
1975
|
+
free: false
|
|
1976
|
+
}
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
id: "glm-5.3",
|
|
1980
|
+
name: "GLM-5.3",
|
|
1981
|
+
contextWindow: 1e6,
|
|
1982
|
+
maxTokens: 48e3,
|
|
1983
|
+
supportsImages: true,
|
|
1984
|
+
reasoning: {
|
|
1985
|
+
supports: true,
|
|
1986
|
+
onlyReasoning: true,
|
|
1987
|
+
supportedEfforts: [
|
|
1988
|
+
"low",
|
|
1989
|
+
"high",
|
|
1990
|
+
"max"
|
|
1991
|
+
],
|
|
1992
|
+
defaultEffort: "high",
|
|
1993
|
+
canDisableThinking: true
|
|
1994
|
+
},
|
|
1995
|
+
billing: {
|
|
1996
|
+
credits: "x0.79",
|
|
1997
|
+
free: false
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
id: "glm-5.2",
|
|
2002
|
+
name: "GLM-5.2",
|
|
2003
|
+
contextWindow: 1e6,
|
|
2004
|
+
maxTokens: 48e3,
|
|
2005
|
+
supportsImages: true,
|
|
2006
|
+
reasoning: {
|
|
2007
|
+
supports: true,
|
|
2008
|
+
onlyReasoning: true,
|
|
2009
|
+
supportedEfforts: ["high", "xhigh"],
|
|
2010
|
+
defaultEffort: "high",
|
|
2011
|
+
canDisableThinking: true
|
|
2012
|
+
},
|
|
2013
|
+
billing: {
|
|
2014
|
+
credits: "x0.79",
|
|
2015
|
+
free: false
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
{
|
|
2019
|
+
id: "kimi-k3",
|
|
2020
|
+
name: "Kimi-K3",
|
|
2021
|
+
contextWindow: 1e6,
|
|
2022
|
+
maxTokens: 32e3,
|
|
2023
|
+
supportsImages: true,
|
|
2024
|
+
reasoning: {
|
|
2025
|
+
supports: true,
|
|
2026
|
+
onlyReasoning: true,
|
|
2027
|
+
defaultEffort: "medium",
|
|
2028
|
+
canDisableThinking: false
|
|
2029
|
+
},
|
|
2030
|
+
billing: {
|
|
2031
|
+
credits: "x1.62",
|
|
2032
|
+
free: false
|
|
2033
|
+
}
|
|
2034
|
+
},
|
|
2035
|
+
{
|
|
2036
|
+
id: "kimi-k2.6",
|
|
2037
|
+
name: "Kimi-K2.6",
|
|
2038
|
+
contextWindow: 256e3,
|
|
2039
|
+
maxTokens: 32e3,
|
|
2040
|
+
supportsImages: true,
|
|
2041
|
+
reasoning: {
|
|
2042
|
+
supports: true,
|
|
2043
|
+
onlyReasoning: true,
|
|
2044
|
+
defaultEffort: "medium",
|
|
2045
|
+
canDisableThinking: false
|
|
2046
|
+
},
|
|
2047
|
+
billing: {
|
|
2048
|
+
credits: "x0.52",
|
|
2049
|
+
free: false
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
];
|
|
2053
|
+
/**
|
|
2054
|
+
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
2055
|
+
*
|
|
2056
|
+
* Visibility is separate from content. A variant whose app has no credentials
|
|
2057
|
+
* must expose *no* models rather than a fallback roster: the DSH model picker
|
|
2058
|
+
* drops an empty group, so an empty catalog is exactly how a provider hides
|
|
2059
|
+
* without touching registration. Serving the fallback to a signed-out user
|
|
2060
|
+
* instead offers models that can only fail (`store.resolve()` throws on the
|
|
2061
|
+
* first message), which is worse than showing nothing.
|
|
2062
|
+
*
|
|
2063
|
+
* The flag defaults to visible so a directly-constructed catalog behaves as it
|
|
2064
|
+
* always has; the plugin runtime applies the credential gate.
|
|
2065
|
+
*/
|
|
2066
|
+
var WorkBuddyCatalog = class {
|
|
2067
|
+
models;
|
|
2068
|
+
visible = true;
|
|
2069
|
+
constructor(initial = FALLBACK_WORKBUDDY_MODELS) {
|
|
2070
|
+
this.models = initial;
|
|
2071
|
+
}
|
|
2072
|
+
/** Current entries; empty while the variant has no usable credential. */
|
|
2073
|
+
current() {
|
|
2074
|
+
if (!this.visible) return [];
|
|
2075
|
+
return this.models.map((model) => modelWithCurrentPromotion(model));
|
|
2076
|
+
}
|
|
2077
|
+
/** Replace the list; callers invalidate their adapter snapshot after this. */
|
|
2078
|
+
set(models) {
|
|
2079
|
+
this.models = [...models];
|
|
2080
|
+
}
|
|
2081
|
+
/** Whether this variant's models are exposed at all. */
|
|
2082
|
+
isVisible() {
|
|
2083
|
+
return this.visible;
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Show or hide the whole catalog. Returns whether the value changed, so the
|
|
2087
|
+
* caller can skip an invalidation that would re-render an identical list.
|
|
2088
|
+
*/
|
|
2089
|
+
setVisible(visible) {
|
|
2090
|
+
if (this.visible === visible) return false;
|
|
2091
|
+
this.visible = visible;
|
|
2092
|
+
return true;
|
|
2093
|
+
}
|
|
2094
|
+
/** Models to fall back to when the upstream fetch fails; ignores visibility. */
|
|
2095
|
+
fallback() {
|
|
2096
|
+
return this.models;
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
//#endregion
|
|
2100
|
+
//#region src/status-paths.ts
|
|
2101
|
+
/** Node-free constants and types shared by the Host and browser halves. */
|
|
2102
|
+
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
2103
|
+
const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
|
|
2104
|
+
/**
|
|
2105
|
+
* Plugin-owned probe control endpoint.
|
|
2106
|
+
*
|
|
2107
|
+
* Separate from the status route because it accepts writes: the status route's
|
|
2108
|
+
* loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
|
|
2109
|
+
* not the same as authorizing a state-changing action. This route therefore
|
|
2110
|
+
* also requires the in-process key the browser half receives with the status
|
|
2111
|
+
* document.
|
|
2112
|
+
*/
|
|
2113
|
+
const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
|
|
2114
|
+
/**
|
|
2115
|
+
* The international (WorkBuddy AI) variant's own pair of routes.
|
|
2116
|
+
*
|
|
2117
|
+
* Kept as separate constants rather than a computed suffix so both halves
|
|
2118
|
+
* reference literal strings: the browser bundle and the host bundle are built
|
|
2119
|
+
* independently, and a shared expression is one build-config drift away from
|
|
2120
|
+
* the desk asking a route the host never mounted.
|
|
2121
|
+
*/
|
|
2122
|
+
const WORKBUDDY_AI_STATUS_PATH = "/plugins/dsh-workbuddy-connect/ai/status";
|
|
2123
|
+
const WORKBUDDY_AI_PROBE_PATH = "/plugins/dsh-workbuddy-connect/ai/probe";
|
|
2124
|
+
//#endregion
|
|
2125
|
+
//#region src/version.ts
|
|
2126
|
+
const WORKBUDDY_CONNECT_VERSION = "0.5.0";
|
|
2127
|
+
//#endregion
|
|
2128
|
+
//#region src/host-heartbeat.ts
|
|
2129
|
+
/**
|
|
2130
|
+
* Host-side heartbeat: a small JSON file written under `$DSH_HOME` once the
|
|
2131
|
+
* `workbuddy` provider is registered. The status CLI reads it to report
|
|
2132
|
+
* whether the host bundle is alive, independent of the browser card.
|
|
2133
|
+
*
|
|
2134
|
+
* The browser (client) bundle cannot write files; its health is reported
|
|
2135
|
+
* only through `console.error` on failure (see `src/client/index.tsx`).
|
|
2136
|
+
* This asymmetry is intentional: the host is the load-bearing half, and
|
|
2137
|
+
* a missing heartbeat unambiguously means the host never started.
|
|
2138
|
+
*
|
|
2139
|
+
* @module dsh-workbuddy-connect/host-heartbeat
|
|
2140
|
+
*/
|
|
2141
|
+
/** Basename of the host heartbeat file inside the Harness home. */
|
|
2142
|
+
const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
|
|
2143
|
+
/** Current on-disk heartbeat format; readers reject others. */
|
|
2144
|
+
const HEARTBEAT_FORMAT_VERSION = 1;
|
|
2145
|
+
/** Absolute path of the host heartbeat file. */
|
|
2146
|
+
function workbuddyHostHeartbeatPath() {
|
|
2147
|
+
return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Write (or overwrite) the heartbeat after the host bundle registered the
|
|
2151
|
+
* provider. A failed write is non-fatal: the host is already running, and
|
|
2152
|
+
* the status CLI will simply report "heartbeat missing" rather than failing.
|
|
2153
|
+
*/
|
|
2154
|
+
async function writeHostHeartbeat() {
|
|
2155
|
+
const document = {
|
|
2156
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
2157
|
+
package: "dsh-workbuddy-connect",
|
|
2158
|
+
pluginVersion: WORKBUDDY_CONNECT_VERSION,
|
|
2159
|
+
registeredAt: Date.now(),
|
|
2160
|
+
pid: process.pid
|
|
2161
|
+
};
|
|
2162
|
+
try {
|
|
2163
|
+
await writeFile(workbuddyHostHeartbeatPath(), JSON.stringify(document), "utf8");
|
|
2164
|
+
} catch {}
|
|
2165
|
+
}
|
|
2166
|
+
/** Remove the heartbeat on plugin disposal so a stale file does not linger. */
|
|
2167
|
+
async function clearHostHeartbeat() {
|
|
2168
|
+
try {
|
|
2169
|
+
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
2170
|
+
} catch {}
|
|
2171
|
+
}
|
|
2172
|
+
/** Read and validate the heartbeat; returns `undefined` when absent or malformed. */
|
|
2173
|
+
async function readHostHeartbeat() {
|
|
2174
|
+
let raw;
|
|
2175
|
+
try {
|
|
2176
|
+
raw = await readFile(workbuddyHostHeartbeatPath(), "utf8");
|
|
2177
|
+
} catch {
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
try {
|
|
2181
|
+
const parsed = JSON.parse(raw);
|
|
2182
|
+
if (parsed.version === HEARTBEAT_FORMAT_VERSION && parsed.package === "dsh-workbuddy-connect" && typeof parsed.registeredAt === "number" && typeof parsed.pid === "number") return {
|
|
2183
|
+
version: HEARTBEAT_FORMAT_VERSION,
|
|
2184
|
+
package: "dsh-workbuddy-connect",
|
|
2185
|
+
pluginVersion: typeof parsed.pluginVersion === "string" ? parsed.pluginVersion : "unknown",
|
|
2186
|
+
registeredAt: parsed.registeredAt,
|
|
2187
|
+
pid: parsed.pid
|
|
2188
|
+
};
|
|
2189
|
+
} catch {}
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Absolute start time (epoch ms) of the process holding `pid`, or `undefined`
|
|
2193
|
+
* when it cannot be determined (no such PID, platform lacks a readable source).
|
|
2194
|
+
*
|
|
2195
|
+
* - macOS / Linux: `ps -o lstart=` prints a local-time "EEE MMM DD HH:MM:SS YYYY";
|
|
2196
|
+
* `Date.parse` resolves it against the local clock, which matches how
|
|
2197
|
+
* `registeredAt` (a `Date.now()` absolute value) is expressed.
|
|
2198
|
+
* - Windows: WMI `CreationDate` is UTC (`YYYYMMDDHHMMSS.mmm+zzzz`); parsed with
|
|
2199
|
+
* `Date.UTC`, again comparable to `registeredAt`.
|
|
2200
|
+
*
|
|
2201
|
+
* Failures return `undefined` so callers can fall back to plain PID liveness
|
|
2202
|
+
* rather than mis-report a running host as dead.
|
|
2203
|
+
*/
|
|
2204
|
+
function processStartTimeMs(pid) {
|
|
2205
|
+
try {
|
|
2206
|
+
if (process.platform === "win32") {
|
|
2207
|
+
const m = execFileSync("wmic", [
|
|
2208
|
+
"process",
|
|
2209
|
+
"where",
|
|
2210
|
+
`processid=${pid}`,
|
|
2211
|
+
"get",
|
|
2212
|
+
"CreationDate"
|
|
2213
|
+
], {
|
|
2214
|
+
encoding: "utf8",
|
|
2215
|
+
windowsHide: true
|
|
2216
|
+
}).match(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.\d+([+-]\d{4})/);
|
|
2217
|
+
if (m === null) return void 0;
|
|
2218
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
2219
|
+
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
2220
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
2221
|
+
}
|
|
2222
|
+
const out = execFileSync("ps", [
|
|
2223
|
+
"-o",
|
|
2224
|
+
"lstart=",
|
|
2225
|
+
"-p",
|
|
2226
|
+
String(pid)
|
|
2227
|
+
], {
|
|
2228
|
+
encoding: "utf8",
|
|
2229
|
+
env: {
|
|
2230
|
+
...process.env,
|
|
2231
|
+
LC_ALL: "C",
|
|
2232
|
+
LANG: "C"
|
|
2233
|
+
}
|
|
2234
|
+
}).trim();
|
|
2235
|
+
if (out === "") return void 0;
|
|
2236
|
+
const ms = Date.parse(out);
|
|
2237
|
+
return Number.isFinite(ms) ? ms : void 0;
|
|
2238
|
+
} catch {
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Whether the heartbeat's PID is still alive *and* still the same process that
|
|
2244
|
+
* registered it. A stale heartbeat (host crashed without clearing the file)
|
|
2245
|
+
* is distinguished from a live host by two checks:
|
|
2246
|
+
*
|
|
2247
|
+
* 1. `process.kill(pid, 0)` — the PID exists (signal 0 tests existence).
|
|
2248
|
+
* 2. The process holding that PID started at or before `registeredAt`. A host
|
|
2249
|
+
* that registered the heartbeat must have been started before writing it,
|
|
2250
|
+
* so `start <= registeredAt`; a recycled PID belongs to an unrelated process
|
|
2251
|
+
* started after the host died, so `start > registeredAt` correctly reads dead.
|
|
2252
|
+
*
|
|
2253
|
+
* PID-only detection is not enough: after a crash the OS may hand the same PID
|
|
2254
|
+
* to an unrelated process, and the un-cleared stale heartbeat would otherwise
|
|
2255
|
+
* produce a false "Host running". When the process start time cannot be read
|
|
2256
|
+
* (e.g. unsupported platform) the check degrades to plain PID liveness.
|
|
2257
|
+
*/
|
|
2258
|
+
function isHeartbeatProcessAlive(heartbeat) {
|
|
2259
|
+
try {
|
|
2260
|
+
process.kill(heartbeat.pid, 0);
|
|
2261
|
+
} catch {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
const startAtMs = processStartTimeMs(heartbeat.pid);
|
|
2265
|
+
if (startAtMs === void 0) return true;
|
|
2266
|
+
return startAtMs <= heartbeat.registeredAt;
|
|
2267
|
+
}
|
|
2268
|
+
//#endregion
|
|
2269
|
+
//#region src/variants.ts
|
|
2270
|
+
/**
|
|
2271
|
+
* The two WorkBuddy desktop apps this one plugin serves.
|
|
2272
|
+
*
|
|
2273
|
+
* Both products are the same client framework in different regions, and both
|
|
2274
|
+
* write their sign-in into the *same* shared `CodeBuddyExtension` auth
|
|
2275
|
+
* directory — they differ by file basename, base URL, catalog endpoint, and
|
|
2276
|
+
* display identity. Everything that varies between them is collected here as
|
|
2277
|
+
* one descriptor, so no module has to carry its own `if (international)`
|
|
2278
|
+
* branch and a third variant would be a data change rather than a refactor.
|
|
2279
|
+
*
|
|
2280
|
+
* This module is host-side (it names files and env vars). The browser half
|
|
2281
|
+
* takes the same ids and routes from the Node-free `status-paths.ts`, which
|
|
2282
|
+
* stays the single source shared by both halves.
|
|
2283
|
+
*
|
|
2284
|
+
* @module dsh-workbuddy-connect/variants
|
|
2285
|
+
*/
|
|
2286
|
+
/** CN WorkBuddy first: the existing provider keeps its id, paths, and copy. */
|
|
2287
|
+
const WORKBUDDY_VARIANTS = [{
|
|
2288
|
+
id: "workbuddy",
|
|
2289
|
+
displayName: "WorkBuddy",
|
|
2290
|
+
appName: "WorkBuddy",
|
|
2291
|
+
region: "cn",
|
|
2292
|
+
env: "WORKBUDDY_AUTH_FILE",
|
|
2293
|
+
desktopFilename: "workbuddy-desktop.info",
|
|
2294
|
+
ownFilename: ".workbuddy-auth.json",
|
|
2295
|
+
probeFilename: ".workbuddy-probe.json",
|
|
2296
|
+
catalogFilename: ".workbuddy-catalog.json",
|
|
2297
|
+
statusPath: WORKBUDDY_STATUS_PATH,
|
|
2298
|
+
probePath: WORKBUDDY_PROBE_PATH
|
|
2299
|
+
}, {
|
|
2300
|
+
id: "workbuddy-ai",
|
|
2301
|
+
displayName: "WorkBuddy AI",
|
|
2302
|
+
appName: "WorkBuddy AI",
|
|
2303
|
+
region: "global",
|
|
2304
|
+
env: "WORKBUDDY_AI_AUTH_FILE",
|
|
2305
|
+
desktopFilename: "workbuddy-desktop-ai.info",
|
|
2306
|
+
ownFilename: ".workbuddy-ai-auth.json",
|
|
2307
|
+
probeFilename: ".workbuddy-ai-probe.json",
|
|
2308
|
+
catalogFilename: ".workbuddy-ai-catalog.json",
|
|
2309
|
+
statusPath: WORKBUDDY_AI_STATUS_PATH,
|
|
2310
|
+
probePath: WORKBUDDY_AI_PROBE_PATH
|
|
2311
|
+
}];
|
|
2312
|
+
/** The CN variant; the plugin's long-standing default and compatibility anchor. */
|
|
2313
|
+
const CN_VARIANT = WORKBUDDY_VARIANTS[0];
|
|
2314
|
+
/** The international variant. */
|
|
2315
|
+
const AI_VARIANT = WORKBUDDY_VARIANTS[1];
|
|
2316
|
+
/** Look up a variant by provider id. */
|
|
2317
|
+
function variantFor(id) {
|
|
2318
|
+
return WORKBUDDY_VARIANTS.find((variant) => variant.id === id);
|
|
2319
|
+
}
|
|
2320
|
+
//#endregion
|
|
2321
|
+
export { parseModelCatalog as A, readBundleVersion as B, desktopAuthCandidatesFor as C, classifyUpstreamError as D, WorkBuddyUpstreamClient as E, probeModel as F, validAppVersion as H, randomSentinel as I, WORKBUDDY_APP_VERSION_FILENAME as L, prepareInternationalChatBody as M, regionOf as N, modelWithCurrentPromotion as O, PROBE_EFFORT_CANDIDATES as P, appUserAgent as R, defaultDesktopAuthPath as S, workbuddyOwnAuthPath as T, resolveAppVersion as V, WorkBuddyCatalog as _, WORKBUDDY_HOST_HEARTBEAT_FILENAME as a, WorkBuddyCredentialStore as b, processStartTimeMs as c, writeHostHeartbeat as d, WORKBUDDY_CONNECT_VERSION as f, FALLBACK_WORKBUDDY_MODELS as g, FALLBACK_WORKBUDDY_AI_MODELS as h, variantFor as i, prepareChatBody as j, normalizeCredits as k, readHostHeartbeat as l, WORKBUDDY_STATUS_PATH as m, CN_VARIANT as n, clearHostHeartbeat as o, WORKBUDDY_PROBE_PATH as p, WORKBUDDY_VARIANTS as r, isHeartbeatProcessAlive as s, AI_VARIANT as t, workbuddyHostHeartbeatPath as u, WORKBUDDY_AUTH_FILENAME as v, parseWorkBuddyAuth as w, defaultDesktopAuthCandidates as x, WORKBUDDY_AUTH_FILE_ENV as y, installedAppVersion as z };
|