dsh-workbuddy-bridge 0.1.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/LICENSE +22 -0
- package/NOTICE +15 -0
- package/README.md +226 -0
- package/README_en.md +249 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +31 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.js +237 -0
- package/lib/client.js +1780 -0
- package/lib/index.d.ts +1642 -0
- package/lib/index.js +2000 -0
- package/lib/variants-CFO_LIBm.js +3807 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +155 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
import { A as normalizeCredits, B as FALLBACK_CN_APP_VERSION, C as desktopAuthCandidatesFor, D as cnAppDiscovery, E as WorkBuddyAtRestKeyProvider, F as PROBE_EFFORT_CANDIDATES, G as validCliVersion, H as fallbackChatIdentity, I as probeModel, J as installedAppVersion, K as WORKBUDDY_APP_VERSION_FILENAME, L as randomSentinel, M as prepareChatBody, N as prepareInternationalChatBody, O as WorkBuddyUpstreamClient, P as regionOf, R as classifyUpstreamError, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, U as readCliVersion, V as chatUserAgent, W as resolveChatIdentity, X as resolveAppVersion, Y as readBundleVersion, Z as validAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_BRIDGE_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as parseModelCatalog, k as modelWithCurrentPromotion, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, q as appUserAgent, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as CN_APP_VERSION_FILENAME } from "./variants-CFO_LIBm.js";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
8
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
9
|
+
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
10
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
13
|
+
//#region src/catalog/store.ts
|
|
14
|
+
/**
|
|
15
|
+
* The last catalog that actually loaded, kept per variant and per account.
|
|
16
|
+
*
|
|
17
|
+
* Both the plan (§4 "降级顺序为同版同来源的最近成功目录 → 本版内置保守目录")
|
|
18
|
+
* and the README promise this fallback, and without it a restart always drops
|
|
19
|
+
* the user to the built-in roster even when a good catalog was fetched minutes
|
|
20
|
+
* earlier. The built-in roster is a snapshot taken once; a fetched catalog is
|
|
21
|
+
* what the upstream actually serves to this account.
|
|
22
|
+
*
|
|
23
|
+
* What it deliberately is *not*:
|
|
24
|
+
*
|
|
25
|
+
* - not a cache with a freshness policy — it never prevents a fetch, it only
|
|
26
|
+
* answers when a fetch cannot;
|
|
27
|
+
* - not shared across accounts (a different account can see a different roster
|
|
28
|
+
* and different promotions), nor across variants (the CN and international
|
|
29
|
+
* endpoints disagree about rates and windows for the same model id);
|
|
30
|
+
* - not a place for secrets: model metadata only, never a token. The account
|
|
31
|
+
* key is a `uid:enterpriseId` identity already visible in the status document.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-workbuddy-bridge/catalog-store
|
|
34
|
+
*/
|
|
35
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
36
|
+
const CATALOG_FORMAT_VERSION = 1;
|
|
37
|
+
/** Basename of the CN variant's saved catalog inside the Harness home. */
|
|
38
|
+
const WORKBUDDY_CATALOG_FILENAME = ".workbuddy-catalog.json";
|
|
39
|
+
/** Plugin-owned saved-catalog path inside the Harness home. */
|
|
40
|
+
function workbuddyCatalogPath(filename = WORKBUDDY_CATALOG_FILENAME) {
|
|
41
|
+
return join(resolveDshHome(), filename);
|
|
42
|
+
}
|
|
43
|
+
/** Whether a parsed value is a model row worth keeping. */
|
|
44
|
+
function isModel(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
46
|
+
const row = value;
|
|
47
|
+
return typeof row["id"] === "string" && row["id"] !== "" && typeof row["name"] === "string" && typeof row["contextWindow"] === "number" && Number.isFinite(row["contextWindow"]) && typeof row["maxTokens"] === "number" && Number.isFinite(row["maxTokens"]) && typeof row["supportsImages"] === "boolean";
|
|
48
|
+
}
|
|
49
|
+
/** Whether a parsed value is a saved catalog this reader can trust. */
|
|
50
|
+
function isSaved$1(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
52
|
+
const entry = value;
|
|
53
|
+
if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
|
|
54
|
+
if (typeof entry["source"] !== "string" || entry["source"] === "") return false;
|
|
55
|
+
if (typeof entry["fetchedAtMs"] !== "number" || !Number.isFinite(entry["fetchedAtMs"])) return false;
|
|
56
|
+
const models = entry["models"];
|
|
57
|
+
if (!Array.isArray(models) || models.length === 0) return false;
|
|
58
|
+
return models.every(isModel);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The last successful catalog per account, read once and written atomically.
|
|
62
|
+
*
|
|
63
|
+
* Malformed content reads as "nothing saved" rather than throwing: this file
|
|
64
|
+
* is an optimization for the offline and first-seconds cases, and a corrupt one
|
|
65
|
+
* must never be able to stop the plugin from serving models.
|
|
66
|
+
*/
|
|
67
|
+
var WorkBuddyCatalogStore = class {
|
|
68
|
+
path;
|
|
69
|
+
entries;
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
this.path = typeof options === "string" ? options : options.path ?? workbuddyCatalogPath();
|
|
72
|
+
}
|
|
73
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
74
|
+
filePath() {
|
|
75
|
+
return this.path;
|
|
76
|
+
}
|
|
77
|
+
load() {
|
|
78
|
+
if (this.entries !== void 0) return this.entries;
|
|
79
|
+
const entries = {};
|
|
80
|
+
if (existsSync(this.path)) try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
82
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
83
|
+
const document = parsed;
|
|
84
|
+
const raw = document["version"] === CATALOG_FORMAT_VERSION ? document["entries"] : void 0;
|
|
85
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
86
|
+
for (const [key, value] of Object.entries(raw)) if (isSaved$1(value)) entries[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
this.entries = entries;
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
/** The saved catalog for one account, or `undefined` when there is none. */
|
|
94
|
+
get(account) {
|
|
95
|
+
const entry = this.load()[account];
|
|
96
|
+
return entry === void 0 ? void 0 : entry;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remember a catalog for an account, replacing whatever was saved before.
|
|
100
|
+
*
|
|
101
|
+
* A failed write is swallowed: the plugin has already served these models,
|
|
102
|
+
* and losing the *memory* of them is not worth surfacing.
|
|
103
|
+
*/
|
|
104
|
+
set(account, catalog) {
|
|
105
|
+
const entries = this.load();
|
|
106
|
+
entries[account] = {
|
|
107
|
+
account,
|
|
108
|
+
...catalog
|
|
109
|
+
};
|
|
110
|
+
this.persist();
|
|
111
|
+
}
|
|
112
|
+
/** Forget one account's catalog — used when that account signs out. */
|
|
113
|
+
delete(account) {
|
|
114
|
+
const entries = this.load();
|
|
115
|
+
if (!(account in entries)) return;
|
|
116
|
+
delete entries[account];
|
|
117
|
+
this.persist();
|
|
118
|
+
}
|
|
119
|
+
persist() {
|
|
120
|
+
const directory = dirname(this.path);
|
|
121
|
+
try {
|
|
122
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
123
|
+
const document = {
|
|
124
|
+
version: CATALOG_FORMAT_VERSION,
|
|
125
|
+
entries: this.load()
|
|
126
|
+
};
|
|
127
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
128
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
129
|
+
renameSync(temporary, this.path);
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/catalog/visibility.ts
|
|
135
|
+
/**
|
|
136
|
+
* Per-account model-visibility preferences: which models the signed-in account
|
|
137
|
+
* has hidden from the DSH model picker (issue #36).
|
|
138
|
+
*
|
|
139
|
+
* A hidden-*id* list, deliberately not an enabled whitelist: a new account and a
|
|
140
|
+
* model the upstream adds both start visible, and an id that temporarily
|
|
141
|
+
* disappears from the catalog is kept — when the model returns it stays hidden
|
|
142
|
+
* until this account says otherwise. Entries are also kept across sign-outs, so
|
|
143
|
+
* returning to an account restores exactly what it left.
|
|
144
|
+
*
|
|
145
|
+
* One file per variant (the two endpoints share model ids but never
|
|
146
|
+
* preferences), keyed by the same `uid:enterpriseId` identity the saved
|
|
147
|
+
* catalogs and probe records use. Not a place for secrets: model-id strings
|
|
148
|
+
* only, never a token, and never written into the desktop auth file or the
|
|
149
|
+
* plugin-owned credential copy — hiding a model is a picker preference, not
|
|
150
|
+
* credential state.
|
|
151
|
+
*
|
|
152
|
+
* Why a plugin-owned file rather than a settings section: the settings sections
|
|
153
|
+
* are statically-typed schemastery objects, and `settings.yaml` is account-global
|
|
154
|
+
* — a per-uid dynamic map fits neither without weakening the schema or mixing
|
|
155
|
+
* one account's preferences into another's config. The saved-catalog and probe
|
|
156
|
+
* stores already persist per-account data this way, so this store follows them:
|
|
157
|
+
* version-tagged document, atomic write with `0o600`, and a malformed file that
|
|
158
|
+
* reads as "nothing saved" rather than throwing.
|
|
159
|
+
*
|
|
160
|
+
* @module dsh-workbuddy-bridge/visibility-store
|
|
161
|
+
*/
|
|
162
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
163
|
+
const VISIBILITY_FORMAT_VERSION = 1;
|
|
164
|
+
/** Basename of the CN variant's visibility file inside the Harness home. */
|
|
165
|
+
const WORKBUDDY_VISIBILITY_FILENAME = ".workbuddy-model-visibility.json";
|
|
166
|
+
/** Plugin-owned visibility-file path inside the Harness home. */
|
|
167
|
+
function workbuddyVisibilityPath(filename = WORKBUDDY_VISIBILITY_FILENAME) {
|
|
168
|
+
return join(resolveDshHome(), filename);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a parsed value is a saved preference entry this reader can trust.
|
|
172
|
+
*
|
|
173
|
+
* The list is read from `hidden`, falling back to `disabled`: that was the
|
|
174
|
+
* field's first name and the file format never bumped a version for the rename,
|
|
175
|
+
* so a file written by an older build still parses instead of silently reading
|
|
176
|
+
* as "nothing hidden".
|
|
177
|
+
*/
|
|
178
|
+
function isSaved(value) {
|
|
179
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
180
|
+
const entry = value;
|
|
181
|
+
if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
|
|
182
|
+
if (typeof entry["updatedAtMs"] !== "number" || !Number.isFinite(entry["updatedAtMs"])) return false;
|
|
183
|
+
const list = entry["hidden"] ?? entry["disabled"];
|
|
184
|
+
if (!Array.isArray(list)) return false;
|
|
185
|
+
return list.every((id) => typeof id === "string" && id !== "");
|
|
186
|
+
}
|
|
187
|
+
/** Normalize one parsed entry: pick the list up from either field name. */
|
|
188
|
+
function normalize(value) {
|
|
189
|
+
return {
|
|
190
|
+
account: value.account,
|
|
191
|
+
hidden: value.hidden ?? value.disabled ?? [],
|
|
192
|
+
updatedAtMs: value.updatedAtMs
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The per-account hidden-model lists, read once and written atomically.
|
|
197
|
+
*
|
|
198
|
+
* Unlike the saved-catalog store, a failed *write* propagates: the caller
|
|
199
|
+
* reports it to the user rather than answering "hidden" for a preference that
|
|
200
|
+
* did not persist. Reads stay forgiving — a corrupt or unreadable file is
|
|
201
|
+
* "nothing hidden", which only ever shows models the account can still pick.
|
|
202
|
+
*/
|
|
203
|
+
var WorkBuddyVisibilityStore = class {
|
|
204
|
+
path;
|
|
205
|
+
accounts;
|
|
206
|
+
constructor(options = {}) {
|
|
207
|
+
this.path = typeof options === "string" ? options : options.path ?? workbuddyVisibilityPath();
|
|
208
|
+
}
|
|
209
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
210
|
+
filePath() {
|
|
211
|
+
return this.path;
|
|
212
|
+
}
|
|
213
|
+
load() {
|
|
214
|
+
if (this.accounts !== void 0) return this.accounts;
|
|
215
|
+
const accounts = {};
|
|
216
|
+
if (existsSync(this.path)) try {
|
|
217
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
218
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
219
|
+
const document = parsed;
|
|
220
|
+
const raw = document["version"] === VISIBILITY_FORMAT_VERSION ? document["accounts"] : void 0;
|
|
221
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
222
|
+
for (const [key, value] of Object.entries(raw)) if (isSaved(value)) accounts[key] = normalize(value);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
this.accounts = accounts;
|
|
227
|
+
return accounts;
|
|
228
|
+
}
|
|
229
|
+
/** The model ids one account has hidden; empty when it never hid any. */
|
|
230
|
+
hidden(account) {
|
|
231
|
+
return this.load()[account]?.hidden ?? [];
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Show or hide one model for one account, persisting before committing.
|
|
235
|
+
*
|
|
236
|
+
* Re-enabling (showing) the last hidden model removes the account's entry
|
|
237
|
+
* entirely — an absent entry and an empty list mean the same thing
|
|
238
|
+
* (everything visible), and the file should not accumulate empty buckets.
|
|
239
|
+
* Throws when the write fails, leaving the in-memory state untouched so a
|
|
240
|
+
* re-read cannot lie about what was persisted.
|
|
241
|
+
*/
|
|
242
|
+
setVisible(account, model, visible) {
|
|
243
|
+
const current = this.load()[account]?.hidden ?? [];
|
|
244
|
+
const next = visible ? current.filter((id) => id !== model) : [.../* @__PURE__ */ new Set([...current, model])];
|
|
245
|
+
const accounts = { ...this.load() };
|
|
246
|
+
if (next.length === 0) delete accounts[account];
|
|
247
|
+
else accounts[account] = {
|
|
248
|
+
account,
|
|
249
|
+
hidden: next,
|
|
250
|
+
updatedAtMs: Date.now()
|
|
251
|
+
};
|
|
252
|
+
this.persist(accounts);
|
|
253
|
+
this.accounts = accounts;
|
|
254
|
+
}
|
|
255
|
+
persist(accounts) {
|
|
256
|
+
const directory = dirname(this.path);
|
|
257
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
258
|
+
const document = {
|
|
259
|
+
version: VISIBILITY_FORMAT_VERSION,
|
|
260
|
+
accounts
|
|
261
|
+
};
|
|
262
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
263
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
264
|
+
renameSync(temporary, this.path);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/llm/adapter.ts
|
|
269
|
+
/**
|
|
270
|
+
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
271
|
+
* into the Harness LLM seam, assembled from public `dsh-llm-pi-ai`
|
|
272
|
+
* extension points the way `dsh-codex-connect` assembles its Codex route.
|
|
273
|
+
*
|
|
274
|
+
* @module dsh-workbuddy-bridge/adapter
|
|
275
|
+
*/
|
|
276
|
+
/** Provider route this bundle owns. */
|
|
277
|
+
const WORKBUDDY_PROVIDER = "workbuddy";
|
|
278
|
+
/** Provider idle ceiling while one stream read is outstanding. */
|
|
279
|
+
const WORKBUDDY_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
280
|
+
/**
|
|
281
|
+
* Image-request budgets at the dsh-llm-pi-ai defaults; the profile type made
|
|
282
|
+
* them required in 0.1.1-rc.2. They bound requests to models whose catalog
|
|
283
|
+
* entry declares `supportsImages`; text-only models never receive images.
|
|
284
|
+
*/
|
|
285
|
+
const REQUEST_IMAGE_BUDGETS = {
|
|
286
|
+
maxRequestImageBytes: 20971520,
|
|
287
|
+
requestImagePixelBudget: 4194304,
|
|
288
|
+
requestImageMaxBytes: 1048576
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Inert pi-ai auth plane. The workbuddy route authenticates only through the
|
|
292
|
+
* shim shared secret resolved per request by `resolveApiKey`, so pi-ai's own
|
|
293
|
+
* credential lifecycle and ambient discovery must never manufacture a
|
|
294
|
+
* credential for it. `PiAiAdapterOptions.auth` is required since 0.1.1-rc.2;
|
|
295
|
+
* every ambient question here answers "nothing stored, nothing set".
|
|
296
|
+
*/
|
|
297
|
+
const INERT_AUTH = {
|
|
298
|
+
credentials: {
|
|
299
|
+
async read() {},
|
|
300
|
+
async list() {
|
|
301
|
+
return [];
|
|
302
|
+
},
|
|
303
|
+
async modify() {
|
|
304
|
+
throw new Error("dsh-workbuddy-bridge: the workbuddy route has no pi-ai credential lifecycle");
|
|
305
|
+
},
|
|
306
|
+
async delete() {}
|
|
307
|
+
},
|
|
308
|
+
authContext: {
|
|
309
|
+
async env() {},
|
|
310
|
+
async fileExists() {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
/** No per-token pricing is knowable for a subscription quota; report zero. */
|
|
316
|
+
const NO_COST = {
|
|
317
|
+
input: 0,
|
|
318
|
+
output: 0,
|
|
319
|
+
cacheRead: 0,
|
|
320
|
+
cacheWrite: 0
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* The suffix appended to a model's display name so its billing rate is visible
|
|
324
|
+
* wherever the name is shown.
|
|
325
|
+
*
|
|
326
|
+
* The separator is a middle dot rather than a hyphen or colon: model names
|
|
327
|
+
* already contain hyphens (`GLM-5.3-Flash`, `Deepseek-V4-Flash`), so a hyphen
|
|
328
|
+
* separator would be ambiguous about where the name ends and the rate begins.
|
|
329
|
+
*/
|
|
330
|
+
const RATE_SEPARATOR = " · ";
|
|
331
|
+
/**
|
|
332
|
+
* Append the billing rate to one model's display name.
|
|
333
|
+
*
|
|
334
|
+
* The rate AND the declared promo badges ride the *name* alone: since DSH
|
|
335
|
+
* 0.1.2 the composer's model seat (`ModelSelect`) renders `model.name` only —
|
|
336
|
+
* `description` is no longer read there at all (the 0.1.1-era client rendered
|
|
337
|
+
* it, which is why the badges used to be visible in the seat). The `/model`
|
|
338
|
+
* popup renders the name too, so a separate `description` copy would either
|
|
339
|
+
* duplicate (rate) or vanish (badges) depending on client generation.
|
|
340
|
+
*
|
|
341
|
+
* This is display-only and cannot affect routing: the wire request is built
|
|
342
|
+
* from `model.id` (pi-ai's completions API sets `model: model.id`), the
|
|
343
|
+
* selection a picker submits is `{provider, model: id, reasoningEffort}`, and
|
|
344
|
+
* `dsh-llm` validates `name` as a non-empty string without comparing its
|
|
345
|
+
* contents. Nothing in the host resolves a model *by* name.
|
|
346
|
+
*/
|
|
347
|
+
/**
|
|
348
|
+
* The catalog display suffix: the billing rate followed by the declared promo
|
|
349
|
+
* badges (`限时免费`, `夜间折扣`), or undefined when the row carries neither.
|
|
350
|
+
* The badge labels are the upstream's own spellings and the host seam has no
|
|
351
|
+
* locale service, so non-Chinese UIs see them verbatim — accepted until the
|
|
352
|
+
* picker grows a localized badge slot.
|
|
353
|
+
*/
|
|
354
|
+
function displaySuffix(info) {
|
|
355
|
+
const parts = [normalizeCredits(info.billing?.credits), ...info.billing?.badges ?? []].filter((part) => part !== void 0 && part !== "");
|
|
356
|
+
return parts.length === 0 ? void 0 : parts.join(" · ");
|
|
357
|
+
}
|
|
358
|
+
/** Append the catalog display suffix to one model's display name. */
|
|
359
|
+
function withCatalogDisplay(name, info) {
|
|
360
|
+
const suffix = displaySuffix(info);
|
|
361
|
+
return suffix === void 0 ? name : `${name}${RATE_SEPARATOR}${suffix}`;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Resolve a WorkBuddy model's reasoning capability into pi-ai's
|
|
365
|
+
* `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
|
|
366
|
+
* unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
|
|
367
|
+
*
|
|
368
|
+
* Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
|
|
369
|
+
*
|
|
370
|
+
* 1. **The declared set.** When the upstream declares a non-empty
|
|
371
|
+
* `supportedEfforts`, exactly those values are offered and nothing else.
|
|
372
|
+
* This always wins: an observation never widens or narrows a declared set.
|
|
373
|
+
* 2. **A local observation.** Rows without a declared set (the older
|
|
374
|
+
* `{effort, summary}` shape) normally get no control at all — their
|
|
375
|
+
* selectable set is client-side knowledge the catalog does not carry, and
|
|
376
|
+
* the desktop app differs per model there. If the user authorized a probe
|
|
377
|
+
* and it established that the upstream *validates* the parameter, the
|
|
378
|
+
* verified spellings are offered.
|
|
379
|
+
*
|
|
380
|
+
* A `non-validating` observation deliberately yields no control: the upstream
|
|
381
|
+
* accepts values that cannot exist (measured on `glm-5.2`), so every per-level
|
|
382
|
+
* acceptance it produced would be a false positive.
|
|
383
|
+
*
|
|
384
|
+
* `off` is offered only when the upstream declares `canDisableThinking: true`.
|
|
385
|
+
* It is never probed — disabling thinking is a separate capability, and the
|
|
386
|
+
* per-model acceptance of `off` cannot be inferred from the row's shape.
|
|
387
|
+
*
|
|
388
|
+
* The offered set is described internally as "verified accepted", never as
|
|
389
|
+
* "verified effective": acceptance proves the upstream did not reject the
|
|
390
|
+
* spelling, not that it changes what the model does.
|
|
391
|
+
*/
|
|
392
|
+
function reasoningFields(info, observed) {
|
|
393
|
+
const reasoning = info.reasoning;
|
|
394
|
+
if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
|
|
395
|
+
const declared = reasoning.supportedEfforts;
|
|
396
|
+
const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
|
|
397
|
+
if (efforts === void 0) return { reasoning: false };
|
|
398
|
+
return {
|
|
399
|
+
reasoning: true,
|
|
400
|
+
thinkingLevelMap: {
|
|
401
|
+
off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
|
|
402
|
+
minimal: null,
|
|
403
|
+
low: efforts.includes("low") ? "low" : null,
|
|
404
|
+
medium: efforts.includes("medium") ? "medium" : null,
|
|
405
|
+
high: efforts.includes("high") ? "high" : null,
|
|
406
|
+
xhigh: efforts.includes("xhigh") ? "xhigh" : null,
|
|
407
|
+
max: efforts.includes("max") ? "max" : null
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
412
|
+
function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
|
|
413
|
+
return {
|
|
414
|
+
id: info.id,
|
|
415
|
+
name: info.name,
|
|
416
|
+
api: "openai-completions",
|
|
417
|
+
provider: providerId,
|
|
418
|
+
baseUrl,
|
|
419
|
+
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
420
|
+
...reasoningFields(info, observed),
|
|
421
|
+
cost: NO_COST,
|
|
422
|
+
contextWindow: info.contextWindow,
|
|
423
|
+
maxTokens: info.maxTokens,
|
|
424
|
+
compat: { maxTokensField: "max_tokens" }
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Assemble the adapter. The provider's `getModels` reads the live catalog,
|
|
429
|
+
* and every model's `baseUrl` is re-resolved per read so the shim's
|
|
430
|
+
* ephemeral port applies from the first snapshot after startup.
|
|
431
|
+
*
|
|
432
|
+
* The profile is constructed by hand rather than through dsh-llm-pi-ai's
|
|
433
|
+
* internal `resolveProfiles()`: that helper is not part of the package's
|
|
434
|
+
* public export surface (root entry, `lib/` deep imports blocked by the
|
|
435
|
+
* exports map, `src/` not shipped), so hand-assembly is the only supported
|
|
436
|
+
* path and every newly required field must be adopted here explicitly —
|
|
437
|
+
* `modelErrors` is one such field.
|
|
438
|
+
*/
|
|
439
|
+
function createWorkBuddyAdapter(options) {
|
|
440
|
+
const { shim, catalog, resolveAttachments, observe, hidden } = options;
|
|
441
|
+
const providerId = options.providerId ?? "workbuddy";
|
|
442
|
+
const displayName = options.displayName ?? "WorkBuddy";
|
|
443
|
+
const buildModels = () => {
|
|
444
|
+
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
445
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
|
|
446
|
+
};
|
|
447
|
+
const provider = {
|
|
448
|
+
...createProvider({
|
|
449
|
+
id: providerId,
|
|
450
|
+
name: displayName,
|
|
451
|
+
auth: { apiKey: {
|
|
452
|
+
name: "WorkBuddy OAuth bearer token",
|
|
453
|
+
async resolve({ credential }) {
|
|
454
|
+
const apiKey = credential?.key;
|
|
455
|
+
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
456
|
+
auth: { apiKey },
|
|
457
|
+
source: "WorkBuddy"
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
} },
|
|
461
|
+
models: buildModels(),
|
|
462
|
+
api: openAICompletionsApi()
|
|
463
|
+
}),
|
|
464
|
+
getModels: () => buildModels()
|
|
465
|
+
};
|
|
466
|
+
const profile = {
|
|
467
|
+
provider: providerId,
|
|
468
|
+
displayName,
|
|
469
|
+
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
470
|
+
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-bridge retryPolicy"),
|
|
471
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
472
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
473
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
474
|
+
piProvider: provider
|
|
475
|
+
};
|
|
476
|
+
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
477
|
+
return {
|
|
478
|
+
adapter: new WorkBuddyPiAiAdapter(catalog, hidden ?? (() => []), {
|
|
479
|
+
profiles: () => profiles,
|
|
480
|
+
auth: INERT_AUTH,
|
|
481
|
+
resolveApiKey: async () => shim.token(),
|
|
482
|
+
...resolveAttachments === void 0 ? {} : { resolveAttachments }
|
|
483
|
+
}),
|
|
484
|
+
invalidate: () => {
|
|
485
|
+
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* The WorkBuddy route's adapter: `PiAiAdapter` with the billing rate folded
|
|
491
|
+
* into the catalog answers it returns to the DSH model pickers.
|
|
492
|
+
*
|
|
493
|
+
* `PiAiAdapter.listModels()` and `.resolveModel()` build their answers straight
|
|
494
|
+
* from the pi-ai descriptors, which carry no billing fact, so the rate is
|
|
495
|
+
* layered on here by looking the model up in the live catalog. Both overrides
|
|
496
|
+
* delegate to `super` and then rewrite only the display fields, so streaming,
|
|
497
|
+
* capability resolution, and effort mapping stay exactly as `dsh-llm-pi-ai`
|
|
498
|
+
* implements them.
|
|
499
|
+
*
|
|
500
|
+
* A model missing from the catalog (an id the shim would serve but the last
|
|
501
|
+
* upstream refresh did not list) falls through with its name untouched rather
|
|
502
|
+
* than being dropped: catalog membership is advisory, and the seam tolerates
|
|
503
|
+
* serving an unlisted id.
|
|
504
|
+
*/
|
|
505
|
+
var WorkBuddyPiAiAdapter = class extends PiAiAdapter {
|
|
506
|
+
catalog;
|
|
507
|
+
hidden;
|
|
508
|
+
constructor(catalog, hidden, options) {
|
|
509
|
+
super(options);
|
|
510
|
+
this.catalog = catalog;
|
|
511
|
+
this.hidden = hidden;
|
|
512
|
+
}
|
|
513
|
+
/** Catalog entry for one model id, or undefined when the catalog omits it. */
|
|
514
|
+
infoFor(model) {
|
|
515
|
+
return this.catalog.current().find((entry) => entry.id === model);
|
|
516
|
+
}
|
|
517
|
+
async listModels(provider) {
|
|
518
|
+
const models = await super.listModels(provider);
|
|
519
|
+
const hidden = new Set(this.hidden());
|
|
520
|
+
return models.flatMap((model) => {
|
|
521
|
+
if (hidden.has(model.id)) return [];
|
|
522
|
+
const info = this.infoFor(model.id);
|
|
523
|
+
if (info === void 0) return [model];
|
|
524
|
+
return [{
|
|
525
|
+
...model,
|
|
526
|
+
name: withCatalogDisplay(model.name, info)
|
|
527
|
+
}];
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async resolveModel(provider, model, signal) {
|
|
531
|
+
const resolved = await super.resolveModel(provider, model, signal);
|
|
532
|
+
const info = this.infoFor(model);
|
|
533
|
+
if (info === void 0) return resolved;
|
|
534
|
+
return {
|
|
535
|
+
...resolved,
|
|
536
|
+
name: withCatalogDisplay(resolved.name, info)
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/llm/loopback.ts
|
|
542
|
+
/**
|
|
543
|
+
* Shared loopback gates for the plugin's local HTTP surfaces: the loopback
|
|
544
|
+
* shim and the same-origin web-status route. Both are only ever meant to be
|
|
545
|
+
* addressed through the machine's loopback interface.
|
|
546
|
+
*
|
|
547
|
+
* @module dsh-workbuddy-bridge/loopback
|
|
548
|
+
*/
|
|
549
|
+
/** Loopback hostnames a local plugin surface may be addressed by. */
|
|
550
|
+
const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
551
|
+
"127.0.0.1",
|
|
552
|
+
"localhost",
|
|
553
|
+
"[::1]"
|
|
554
|
+
]);
|
|
555
|
+
/** Strip the optional :port from a Host header value, IPv6-bracket aware. */
|
|
556
|
+
function hostnameOfHost(host) {
|
|
557
|
+
let hostname = host.trim().toLowerCase();
|
|
558
|
+
if (hostname.startsWith("[")) {
|
|
559
|
+
const end = hostname.indexOf("]");
|
|
560
|
+
return end === -1 ? hostname : hostname.slice(0, end + 1);
|
|
561
|
+
}
|
|
562
|
+
const colon = hostname.lastIndexOf(":");
|
|
563
|
+
if (colon !== -1 && !hostname.slice(0, colon).includes(":") && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
|
|
564
|
+
return hostname;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* The request's Host header must name the loopback interface. A DNS-rebinding
|
|
568
|
+
* page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
|
|
569
|
+
* Host, so this check drops those before any routing happens.
|
|
570
|
+
*/
|
|
571
|
+
function hostIsLoopback(host) {
|
|
572
|
+
if (host === void 0 || host.trim() === "") return false;
|
|
573
|
+
return LOOPBACK_HOSTS.has(hostnameOfHost(host));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* A browser-sent Origin (present header) must be loopback. Non-browser
|
|
577
|
+
* clients (the plugin's own fetch calls) send no Origin at all and pass.
|
|
578
|
+
*/
|
|
579
|
+
function originIsLoopback(origin) {
|
|
580
|
+
if (origin === void 0 || origin.trim() === "") return true;
|
|
581
|
+
try {
|
|
582
|
+
const { hostname } = new URL(origin);
|
|
583
|
+
return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/llm/shim.ts
|
|
590
|
+
/**
|
|
591
|
+
* Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
|
|
592
|
+
* shim applies the WorkBuddy wire quirks (forced streaming, string
|
|
593
|
+
* `tool_choice`, CLI-shaped headers) and forwards to the real upstream.
|
|
594
|
+
* It binds 127.0.0.1 only and never serves another interface.
|
|
595
|
+
*
|
|
596
|
+
* Inbound hardening: the loopback bind alone is not a trust boundary (any
|
|
597
|
+
* local process or a DNS-rebinding page can reach 127.0.0.1), so every
|
|
598
|
+
* request must carry a loopback Host header, browser-sent Origins must be
|
|
599
|
+
* loopback, chat POSTs must be application/json, and the Authorization
|
|
600
|
+
* header must carry the shim's per-process shared secret. The plugin's
|
|
601
|
+
* own client satisfies all four by construction; local attackers cannot
|
|
602
|
+
* read the secret out of the plugin process's memory.
|
|
603
|
+
*
|
|
604
|
+
* @module dsh-workbuddy-bridge/shim
|
|
605
|
+
*/
|
|
606
|
+
const REQUEST_BODY_LIMIT = 67108864;
|
|
607
|
+
/** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
|
|
608
|
+
function isJsonContentType(req) {
|
|
609
|
+
const type = req.headers["content-type"];
|
|
610
|
+
return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
|
|
611
|
+
}
|
|
612
|
+
/** HTTP status each upstream failure class surfaces as. */
|
|
613
|
+
const KIND_STATUS = {
|
|
614
|
+
hard_credit: 402,
|
|
615
|
+
soft_rate: 429,
|
|
616
|
+
session_dead: 401,
|
|
617
|
+
not_found: 502,
|
|
618
|
+
server: 502,
|
|
619
|
+
client: 400
|
|
620
|
+
};
|
|
621
|
+
function writeJson(res, status, body) {
|
|
622
|
+
const payload = JSON.stringify(body);
|
|
623
|
+
res.writeHead(status, {
|
|
624
|
+
"Content-Type": "application/json",
|
|
625
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
626
|
+
});
|
|
627
|
+
res.end(payload);
|
|
628
|
+
}
|
|
629
|
+
function writeOpenAIError(res, status, kind, message) {
|
|
630
|
+
writeJson(res, status, { error: {
|
|
631
|
+
message,
|
|
632
|
+
type: kind,
|
|
633
|
+
code: kind
|
|
634
|
+
} });
|
|
635
|
+
}
|
|
636
|
+
/** Read a request body with a size cap; over-limit bodies fail the request. */
|
|
637
|
+
function readBody$1(req) {
|
|
638
|
+
return new Promise((resolve, reject) => {
|
|
639
|
+
const chunks = [];
|
|
640
|
+
let size = 0;
|
|
641
|
+
req.on("data", (chunk) => {
|
|
642
|
+
size += chunk.length;
|
|
643
|
+
if (size > REQUEST_BODY_LIMIT) {
|
|
644
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
645
|
+
req.destroy();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
chunks.push(chunk);
|
|
649
|
+
});
|
|
650
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
651
|
+
req.on("error", reject);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Start the loopback endpoint. Requests carry any bearer; the loopback bind
|
|
656
|
+
* is the boundary, and the upstream credential comes from the store alone.
|
|
657
|
+
*/
|
|
658
|
+
function createWorkBuddyShim(options) {
|
|
659
|
+
const { store, client, catalog } = options;
|
|
660
|
+
const logger = options.logger;
|
|
661
|
+
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
662
|
+
/** Constant-time bearer check; absent or mismatched bearers are rejected. */
|
|
663
|
+
function bearerOk(req) {
|
|
664
|
+
const header = req.headers.authorization;
|
|
665
|
+
if (typeof header !== "string") return false;
|
|
666
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
667
|
+
if (match === null) return false;
|
|
668
|
+
const presented = match[1];
|
|
669
|
+
const expected = SHARED_SECRET;
|
|
670
|
+
const a = Buffer.from(presented);
|
|
671
|
+
const b = Buffer.from(expected);
|
|
672
|
+
if (a.length !== b.length) return false;
|
|
673
|
+
return timingSafeEqual(a, b);
|
|
674
|
+
}
|
|
675
|
+
const server = createServer((req, res) => {
|
|
676
|
+
handle(req, res);
|
|
677
|
+
});
|
|
678
|
+
const ready = new Promise((resolve, reject) => {
|
|
679
|
+
server.once("listening", () => resolve());
|
|
680
|
+
server.once("error", reject);
|
|
681
|
+
});
|
|
682
|
+
server.listen(0, "127.0.0.1");
|
|
683
|
+
const baseUrl = () => {
|
|
684
|
+
const address = server.address();
|
|
685
|
+
if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
|
|
686
|
+
return `http://127.0.0.1:${address.port}`;
|
|
687
|
+
};
|
|
688
|
+
async function handle(req, res) {
|
|
689
|
+
try {
|
|
690
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
691
|
+
writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (!originIsLoopback(req.headers.origin)) {
|
|
695
|
+
writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (!bearerOk(req)) {
|
|
699
|
+
writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const url = req.url ?? "/";
|
|
703
|
+
if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
|
|
704
|
+
writeJson(res, 200, { ok: true });
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
|
|
708
|
+
writeJson(res, 200, {
|
|
709
|
+
object: "list",
|
|
710
|
+
data: catalog.current().map((model) => ({
|
|
711
|
+
id: model.id,
|
|
712
|
+
object: "model",
|
|
713
|
+
created: 0,
|
|
714
|
+
owned_by: "workbuddy"
|
|
715
|
+
}))
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
|
|
720
|
+
await chatCompletions(req, res);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
|
|
726
|
+
else res.end();
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async function chatCompletions(req, res) {
|
|
730
|
+
if (!isJsonContentType(req)) {
|
|
731
|
+
writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
let credential;
|
|
735
|
+
try {
|
|
736
|
+
credential = await store.resolve();
|
|
737
|
+
} catch (error) {
|
|
738
|
+
writeOpenAIError(res, 401, "not_signed_in", String(error));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const raw = (await readBody$1(req)).toString("utf8");
|
|
742
|
+
const prepared = prepareChatBody(raw);
|
|
743
|
+
const controller = new AbortController();
|
|
744
|
+
req.on("close", () => controller.abort());
|
|
745
|
+
const result = await client.chatStream(credential, prepared, controller.signal);
|
|
746
|
+
if (!result.ok) {
|
|
747
|
+
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
res.writeHead(200, {
|
|
751
|
+
"Content-Type": "text/event-stream",
|
|
752
|
+
"Cache-Control": "no-cache",
|
|
753
|
+
"Connection": "keep-alive",
|
|
754
|
+
"X-Accel-Buffering": "no"
|
|
755
|
+
});
|
|
756
|
+
let sawDone = false;
|
|
757
|
+
const body = Readable.fromWeb(result.response.body);
|
|
758
|
+
body.on("data", (chunk) => {
|
|
759
|
+
if (chunk.includes("[DONE]")) sawDone = true;
|
|
760
|
+
});
|
|
761
|
+
body.on("error", (error) => {
|
|
762
|
+
logger?.warn("dsh-workbuddy-bridge: upstream stream failed mid-flight", error);
|
|
763
|
+
if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
|
|
764
|
+
});
|
|
765
|
+
body.pipe(res);
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
ready,
|
|
769
|
+
baseUrl,
|
|
770
|
+
token: () => SHARED_SECRET,
|
|
771
|
+
close: () => new Promise((resolve, reject) => {
|
|
772
|
+
server.close(() => resolve());
|
|
773
|
+
server.closeAllConnections();
|
|
774
|
+
server.once("error", reject);
|
|
775
|
+
})
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
//#endregion
|
|
779
|
+
//#region src/probe/store.ts
|
|
780
|
+
/**
|
|
781
|
+
* Local record of reasoning-effort probes.
|
|
782
|
+
*
|
|
783
|
+
* What this stores is an *observation*, never a claim about the upstream: a
|
|
784
|
+
* model's row is only consulted when the catalog carries no explicit
|
|
785
|
+
* `supportedEfforts` set, and it always loses to a declared set. The plan this
|
|
786
|
+
* implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
|
|
787
|
+
* is invalidated whenever the model's catalog row changes, so every record
|
|
788
|
+
* carries a fingerprint of the fields the probe depended on.
|
|
789
|
+
*
|
|
790
|
+
* The file lives beside the plugin's own credential copy under `$DSH_HOME`,
|
|
791
|
+
* never in the desktop app's files, and carries no token, prompt, or response
|
|
792
|
+
* body — only model ids, effort spellings, and timestamps.
|
|
793
|
+
*
|
|
794
|
+
* @module dsh-workbuddy-bridge/probe-store
|
|
795
|
+
*/
|
|
796
|
+
/** Basename of the probe record inside the Harness home. */
|
|
797
|
+
const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
|
|
798
|
+
/**
|
|
799
|
+
* On-disk format this reader accepts; other versions are discarded.
|
|
800
|
+
*
|
|
801
|
+
* Version 2 nested the records under the account that produced them
|
|
802
|
+
* (`records[account][modelId]`), so two accounts no longer overwrite each
|
|
803
|
+
* other's observations for the same model. Version 1 files (flat, one record
|
|
804
|
+
* per model) are deliberately not migrated: they read as empty and the
|
|
805
|
+
* affected models are re-probed on demand, which keeps the reader free of
|
|
806
|
+
* half-understood compatibility paths.
|
|
807
|
+
*/
|
|
808
|
+
const PROBE_FORMAT_VERSION = 2;
|
|
809
|
+
/**
|
|
810
|
+
* How long an observation stays usable. Conservative on purpose: the plan's
|
|
811
|
+
* whole argument is that upstream metadata moves fast, so a result that has
|
|
812
|
+
* outlived its fingerprint's usefulness should not quietly keep granting a
|
|
813
|
+
* picker entry.
|
|
814
|
+
*/
|
|
815
|
+
const DEFAULT_TTL_MS = 12096e5;
|
|
816
|
+
/**
|
|
817
|
+
* Plugin-owned probe record path inside the Harness home.
|
|
818
|
+
*
|
|
819
|
+
* One file per variant. Same-named models exist on both endpoints (the
|
|
820
|
+
* international catalog repeats `glm-5.3`, `glm-5.2`, `hy3`, `kimi-k2.6`), and
|
|
821
|
+
* {@link fingerprintModel} covers only `id`/`reasoning`/`supportsImages` —
|
|
822
|
+
* never the provider — so a single shared file would let one variant's
|
|
823
|
+
* observation answer for the other. The paths differ; the format does not.
|
|
824
|
+
*/
|
|
825
|
+
function workbuddyProbePath(filename = WORKBUDDY_PROBE_FILENAME) {
|
|
826
|
+
return join(resolveDshHome(), filename);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Fingerprint the catalog fields a probe depends on.
|
|
830
|
+
*
|
|
831
|
+
* Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
|
|
832
|
+
* so a rename or a promo badge does not throw away a valid observation, and
|
|
833
|
+
* deliberately includes the whole reasoning object so any change to the
|
|
834
|
+
* declared shape re-probes.
|
|
835
|
+
*/
|
|
836
|
+
function fingerprintModel(info) {
|
|
837
|
+
const basis = JSON.stringify({
|
|
838
|
+
id: info.id,
|
|
839
|
+
reasoning: info.reasoning ?? null,
|
|
840
|
+
supportsImages: info.supportsImages ?? null
|
|
841
|
+
});
|
|
842
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
843
|
+
}
|
|
844
|
+
/** Read-and-validate the documents on disk; anything malformed reads as empty. */
|
|
845
|
+
function readDocument(path) {
|
|
846
|
+
if (!existsSync(path)) return void 0;
|
|
847
|
+
let parsed;
|
|
848
|
+
try {
|
|
849
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
850
|
+
} catch {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
854
|
+
const wrapped = parsed;
|
|
855
|
+
if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
|
|
856
|
+
const records = wrapped["records"];
|
|
857
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
|
|
858
|
+
return parsed;
|
|
859
|
+
}
|
|
860
|
+
/** One record's shape check; a bad row is dropped rather than trusted. */
|
|
861
|
+
function isRecord(value) {
|
|
862
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
863
|
+
const wrapped = value;
|
|
864
|
+
const validation = wrapped["validation"];
|
|
865
|
+
if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
|
|
866
|
+
if (typeof wrapped["fingerprint"] !== "string") return false;
|
|
867
|
+
if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
|
|
868
|
+
if (typeof wrapped["pluginVersion"] !== "string") return false;
|
|
869
|
+
if (typeof wrapped["account"] !== "string" || wrapped["account"] === "") return false;
|
|
870
|
+
const efforts = wrapped["efforts"];
|
|
871
|
+
if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* The plugin's probe records: read once, written atomically, keyed by the
|
|
876
|
+
* account that produced each observation, and never trusted across a
|
|
877
|
+
* fingerprint change or past the TTL.
|
|
878
|
+
*/
|
|
879
|
+
var WorkBuddyProbeStore = class {
|
|
880
|
+
path;
|
|
881
|
+
ttlMs;
|
|
882
|
+
pluginVersion;
|
|
883
|
+
now;
|
|
884
|
+
records;
|
|
885
|
+
constructor(options) {
|
|
886
|
+
const opts = typeof options === "string" ? {
|
|
887
|
+
path: options,
|
|
888
|
+
pluginVersion: "0.0.0"
|
|
889
|
+
} : options;
|
|
890
|
+
this.path = opts.path ?? workbuddyProbePath();
|
|
891
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
892
|
+
this.pluginVersion = opts.pluginVersion;
|
|
893
|
+
this.now = opts.now ?? (() => Date.now());
|
|
894
|
+
}
|
|
895
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
896
|
+
filePath() {
|
|
897
|
+
return this.path;
|
|
898
|
+
}
|
|
899
|
+
load() {
|
|
900
|
+
if (this.records === void 0) {
|
|
901
|
+
const document = readDocument(this.path);
|
|
902
|
+
const records = {};
|
|
903
|
+
for (const [account, bucket] of Object.entries(document?.records ?? {})) {
|
|
904
|
+
if (typeof bucket !== "object" || bucket === null || Array.isArray(bucket)) continue;
|
|
905
|
+
const parsed = {};
|
|
906
|
+
for (const [modelId, record] of Object.entries(bucket)) if (isRecord(record)) parsed[modelId] = record;
|
|
907
|
+
records[account] = parsed;
|
|
908
|
+
}
|
|
909
|
+
this.records = records;
|
|
910
|
+
}
|
|
911
|
+
return this.records;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* The usable record for one account and model, or `undefined` when there is
|
|
915
|
+
* none, it is expired, it was taken against a different catalog row, or it
|
|
916
|
+
* belongs to a different account.
|
|
917
|
+
*
|
|
918
|
+
* @param account - the account in effect, as `uid:enterpriseId`. Records are
|
|
919
|
+
* only returned for the account that produced them.
|
|
920
|
+
*/
|
|
921
|
+
get(modelId, fingerprint, account) {
|
|
922
|
+
const record = this.load()[account]?.[modelId];
|
|
923
|
+
if (record === void 0) return void 0;
|
|
924
|
+
if (record.fingerprint !== fingerprint) return void 0;
|
|
925
|
+
if (record.account !== account) return void 0;
|
|
926
|
+
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
927
|
+
return record;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Store one observation under the account stamped on it. Only a decisive
|
|
931
|
+
* answer (`validating` / `non-validating`) replaces an existing decisive
|
|
932
|
+
* record *of the same account*: a transient `unknown` must not erase
|
|
933
|
+
* knowledge the user already paid for.
|
|
934
|
+
*/
|
|
935
|
+
set(modelId, record) {
|
|
936
|
+
const records = this.load();
|
|
937
|
+
const bucket = records[record.account] ?? (records[record.account] = {});
|
|
938
|
+
const existing = bucket[modelId];
|
|
939
|
+
if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
|
|
940
|
+
bucket[modelId] = record;
|
|
941
|
+
this.persist();
|
|
942
|
+
}
|
|
943
|
+
/** Drop every record of every account; used by the card's explicit "clear" action. */
|
|
944
|
+
clear() {
|
|
945
|
+
this.records = {};
|
|
946
|
+
this.persist();
|
|
947
|
+
}
|
|
948
|
+
/** Every record currently held, grouped by account, for status display. */
|
|
949
|
+
all() {
|
|
950
|
+
const records = this.load();
|
|
951
|
+
return Object.fromEntries(Object.entries(records).map(([account, bucket]) => [account, { ...bucket }]));
|
|
952
|
+
}
|
|
953
|
+
/** Build a record stamped with this store's clock, version, and account. */
|
|
954
|
+
record(fingerprint, validation, efforts, account) {
|
|
955
|
+
return {
|
|
956
|
+
fingerprint,
|
|
957
|
+
validation,
|
|
958
|
+
efforts: validation === "validating" ? [...efforts] : [],
|
|
959
|
+
probedAtMs: this.now(),
|
|
960
|
+
pluginVersion: this.pluginVersion,
|
|
961
|
+
account
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Write through a temporary file and rename, so a crash mid-write cannot
|
|
966
|
+
* leave a half-parsed document that reads as "no records" and silently drops
|
|
967
|
+
* every observation.
|
|
968
|
+
*/
|
|
969
|
+
persist() {
|
|
970
|
+
const directory = dirname(this.path);
|
|
971
|
+
try {
|
|
972
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
973
|
+
const document = {
|
|
974
|
+
version: PROBE_FORMAT_VERSION,
|
|
975
|
+
records: this.load()
|
|
976
|
+
};
|
|
977
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
978
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
979
|
+
renameSync(temporary, this.path);
|
|
980
|
+
} catch {}
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
/**
|
|
984
|
+
* Order observations newest-first for display.
|
|
985
|
+
*
|
|
986
|
+
* The store keeps insertion order so the file reads chronologically, but the
|
|
987
|
+
* card wants the most recent detection at the top: a sweep the user just ran
|
|
988
|
+
* should not appear below every earlier one, which is what appending to an
|
|
989
|
+
* insertion-ordered list does.
|
|
990
|
+
*/
|
|
991
|
+
function newestFirst(records) {
|
|
992
|
+
return [...records].sort((a, b) => b.probedAt - a.probedAt);
|
|
993
|
+
}
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/probe/service.ts
|
|
996
|
+
/**
|
|
997
|
+
* Serial probe runner. One instance is shared by the manual API and any
|
|
998
|
+
* future automatic trigger, so the two can never overlap.
|
|
999
|
+
*/
|
|
1000
|
+
var WorkBuddyProbeService = class {
|
|
1001
|
+
options;
|
|
1002
|
+
queue = Promise.resolve();
|
|
1003
|
+
pending = /* @__PURE__ */ new Map();
|
|
1004
|
+
running = false;
|
|
1005
|
+
constructor(options) {
|
|
1006
|
+
this.options = options;
|
|
1007
|
+
}
|
|
1008
|
+
/** Whether a sweep is in flight right now. */
|
|
1009
|
+
isRunning() {
|
|
1010
|
+
return this.running;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* The record the adapter may use for this model, or `undefined`.
|
|
1014
|
+
*
|
|
1015
|
+
* Applies the plan's precedence (§5): a declared set always wins, so a model
|
|
1016
|
+
* that declares `supportedEfforts` is never answered from an observation.
|
|
1017
|
+
*/
|
|
1018
|
+
recordFor(modelId) {
|
|
1019
|
+
const info = this.options.catalog.current().find((model) => model.id === modelId);
|
|
1020
|
+
if (info === void 0) return void 0;
|
|
1021
|
+
if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
|
|
1022
|
+
const account = this.options.account();
|
|
1023
|
+
if (account === void 0) return void 0;
|
|
1024
|
+
return this.options.store.get(modelId, fingerprintModel(info), account);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Probe one model, serially.
|
|
1028
|
+
*
|
|
1029
|
+
* The authenticated manual route supplies one-request consent after UI
|
|
1030
|
+
* confirmation. Other callers must pass the configured consent gate.
|
|
1031
|
+
* Manual consent never changes the automatic-probing configuration.
|
|
1032
|
+
* Explicit requests bypass historical results, but share an ongoing run.
|
|
1033
|
+
*/
|
|
1034
|
+
async probe(modelId, manualConsent = false) {
|
|
1035
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
1036
|
+
state: "unavailable",
|
|
1037
|
+
reason: "probing is not authorized"
|
|
1038
|
+
};
|
|
1039
|
+
if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
|
|
1040
|
+
state: "unavailable",
|
|
1041
|
+
reason: `unknown model: ${modelId}`
|
|
1042
|
+
};
|
|
1043
|
+
const account = this.options.account();
|
|
1044
|
+
if (account === void 0) return {
|
|
1045
|
+
state: "unavailable",
|
|
1046
|
+
reason: "no WorkBuddy credential"
|
|
1047
|
+
};
|
|
1048
|
+
const pendingKey = JSON.stringify([account, modelId]);
|
|
1049
|
+
const pending = this.pending.get(pendingKey);
|
|
1050
|
+
if (pending !== void 0) return pending;
|
|
1051
|
+
const run = this.queue.then(async () => {
|
|
1052
|
+
const current = this.options.catalog.current().find((model) => model.id === modelId);
|
|
1053
|
+
if (current === void 0) return {
|
|
1054
|
+
state: "unavailable",
|
|
1055
|
+
reason: `unknown model: ${modelId}`
|
|
1056
|
+
};
|
|
1057
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
1058
|
+
state: "unavailable",
|
|
1059
|
+
reason: "probing is not authorized"
|
|
1060
|
+
};
|
|
1061
|
+
if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
|
|
1062
|
+
state: "unavailable",
|
|
1063
|
+
reason: "model does not need detection"
|
|
1064
|
+
};
|
|
1065
|
+
const cached = this.recordFor(modelId);
|
|
1066
|
+
if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
|
|
1067
|
+
state: "ok",
|
|
1068
|
+
validation: cached.validation,
|
|
1069
|
+
efforts: cached.efforts,
|
|
1070
|
+
requests: 0
|
|
1071
|
+
};
|
|
1072
|
+
if (this.options.account() !== account) return {
|
|
1073
|
+
state: "unavailable",
|
|
1074
|
+
reason: "account changed before detection"
|
|
1075
|
+
};
|
|
1076
|
+
const credential = await this.options.credentials.current();
|
|
1077
|
+
if (credential === void 0) return {
|
|
1078
|
+
state: "unavailable",
|
|
1079
|
+
reason: "no WorkBuddy credential"
|
|
1080
|
+
};
|
|
1081
|
+
const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
|
|
1082
|
+
this.running = true;
|
|
1083
|
+
try {
|
|
1084
|
+
const outcome = await probeModel({
|
|
1085
|
+
send,
|
|
1086
|
+
...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
|
|
1087
|
+
});
|
|
1088
|
+
if (this.options.account() !== account) return {
|
|
1089
|
+
state: "unavailable",
|
|
1090
|
+
reason: "account changed during detection"
|
|
1091
|
+
};
|
|
1092
|
+
const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts, account);
|
|
1093
|
+
this.options.store.set(modelId, record);
|
|
1094
|
+
if (outcome.validation === "unknown") return {
|
|
1095
|
+
state: "unavailable",
|
|
1096
|
+
reason: outcome.reason
|
|
1097
|
+
};
|
|
1098
|
+
return {
|
|
1099
|
+
state: "ok",
|
|
1100
|
+
validation: outcome.validation,
|
|
1101
|
+
efforts: record.efforts,
|
|
1102
|
+
requests: outcome.requests
|
|
1103
|
+
};
|
|
1104
|
+
} finally {
|
|
1105
|
+
this.running = false;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
this.queue = run.catch(() => void 0);
|
|
1109
|
+
this.pending.set(pendingKey, run);
|
|
1110
|
+
try {
|
|
1111
|
+
return await run;
|
|
1112
|
+
} finally {
|
|
1113
|
+
this.pending.delete(pendingKey);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/web/status.ts
|
|
1119
|
+
/** Redact token-like content before it crosses to the browser. */
|
|
1120
|
+
function safeMessage(error) {
|
|
1121
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
1122
|
+
}
|
|
1123
|
+
function json$1(res, status, body) {
|
|
1124
|
+
const payload = JSON.stringify(body);
|
|
1125
|
+
res.writeHead(status, {
|
|
1126
|
+
"Content-Type": "application/json",
|
|
1127
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1128
|
+
});
|
|
1129
|
+
res.end(payload);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* The request must be addressed to the loopback interface, and a
|
|
1133
|
+
* browser-attached Origin must be loopback too. The Host check drops
|
|
1134
|
+
* DNS-rebinding pages (their Host is the attacker's domain, not loopback);
|
|
1135
|
+
* the card's same-origin fetches carry no Origin and pass on Host alone.
|
|
1136
|
+
*/
|
|
1137
|
+
function loopbackRequest(req) {
|
|
1138
|
+
return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Assemble the card's status document. Sign-in state is read-only; credit is
|
|
1142
|
+
* a live billing answer whose failure degrades to `creditsError` rather than
|
|
1143
|
+
* failing the whole document.
|
|
1144
|
+
*/
|
|
1145
|
+
async function workBuddyWebStatus(deps) {
|
|
1146
|
+
const authStatus = await deps.store.status();
|
|
1147
|
+
if (authStatus.state !== "signed-in") return {
|
|
1148
|
+
status: "signed-out",
|
|
1149
|
+
...authStatus.reason === void 0 ? {} : { reason: authStatus.reason },
|
|
1150
|
+
...authStatus.reasonCode === void 0 ? {} : { reasonCode: authStatus.reasonCode }
|
|
1151
|
+
};
|
|
1152
|
+
const status = {
|
|
1153
|
+
status: "signed-in",
|
|
1154
|
+
...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
|
|
1155
|
+
...authStatus.domain === void 0 || authStatus.domain === "" ? {} : { domain: authStatus.domain },
|
|
1156
|
+
...authStatus.source === void 0 ? {} : { source: authStatus.source },
|
|
1157
|
+
...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
|
|
1158
|
+
};
|
|
1159
|
+
const modelsField = deps.models().map((model) => {
|
|
1160
|
+
const rate = normalizeCredits(model.billing?.credits);
|
|
1161
|
+
const supported = model.supportedContextWindows ?? [];
|
|
1162
|
+
const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
|
|
1163
|
+
const defaultContextWindow = model.defaultContextWindow ?? model.contextWindow;
|
|
1164
|
+
return {
|
|
1165
|
+
id: model.id,
|
|
1166
|
+
name: model.name,
|
|
1167
|
+
...model.billing?.free === true ? { free: true } : {},
|
|
1168
|
+
...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
|
|
1169
|
+
...rate === void 0 ? {} : { credits: rate },
|
|
1170
|
+
...model.billing?.rateUnknown === true ? { rateUnknown: true } : {},
|
|
1171
|
+
...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {},
|
|
1172
|
+
...typeof defaultContextWindow === "number" && defaultContextWindow > 0 && defaultContextWindow < model.contextWindow ? { defaultContextWindow } : {},
|
|
1173
|
+
...maxContextWindow === void 0 || maxContextWindow <= defaultContextWindow ? {} : { maxContextWindow },
|
|
1174
|
+
...typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}
|
|
1175
|
+
};
|
|
1176
|
+
});
|
|
1177
|
+
const catalog = deps.catalog?.();
|
|
1178
|
+
const withCatalog = catalog === void 0 ? status : {
|
|
1179
|
+
...status,
|
|
1180
|
+
catalog
|
|
1181
|
+
};
|
|
1182
|
+
const visibility = deps.visibility?.();
|
|
1183
|
+
const withVisibility = visibility === void 0 ? withCatalog : {
|
|
1184
|
+
...withCatalog,
|
|
1185
|
+
visibility
|
|
1186
|
+
};
|
|
1187
|
+
const statusWithModels = modelsField.length > 0 ? {
|
|
1188
|
+
...withVisibility,
|
|
1189
|
+
models: modelsField
|
|
1190
|
+
} : withVisibility;
|
|
1191
|
+
let probed = statusWithModels;
|
|
1192
|
+
if (deps.probe !== void 0) {
|
|
1193
|
+
const maximumContextWindow = deps.useMaximumContextWindow?.();
|
|
1194
|
+
probed = {
|
|
1195
|
+
...statusWithModels,
|
|
1196
|
+
probe: deps.probe(),
|
|
1197
|
+
...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
|
|
1198
|
+
...maximumContextWindow === void 0 ? {} : { useMaximumContextWindow: maximumContextWindow }
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const credential = await deps.store.current();
|
|
1203
|
+
if (credential !== void 0) {
|
|
1204
|
+
const credits = await deps.client.fetchCredits(credential);
|
|
1205
|
+
return {
|
|
1206
|
+
...probed,
|
|
1207
|
+
credits
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
return {
|
|
1212
|
+
...probed,
|
|
1213
|
+
creditsError: safeMessage(error)
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
return probed;
|
|
1217
|
+
}
|
|
1218
|
+
/** The status route's request handler, extracted so tests can mount it on a bare server. */
|
|
1219
|
+
function workBuddyStatusHandler(deps) {
|
|
1220
|
+
return async (req, res) => {
|
|
1221
|
+
if (req.method !== "GET") {
|
|
1222
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (!loopbackRequest(req)) {
|
|
1226
|
+
json$1(res, 403, { error: "request-not-trusted" });
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
json$1(res, 200, await workBuddyWebStatus(deps));
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
json$1(res, 500, { error: safeMessage(error) });
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
/** Mount the GET status route on an optional webServer context. */
|
|
1237
|
+
function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
1238
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-bridge/status";
|
|
1239
|
+
ctx.effect(() => {
|
|
1240
|
+
const dispose = ctx.webServer.register({
|
|
1241
|
+
kind: "exact",
|
|
1242
|
+
path,
|
|
1243
|
+
handler: workBuddyStatusHandler(deps)
|
|
1244
|
+
});
|
|
1245
|
+
return () => {
|
|
1246
|
+
dispose();
|
|
1247
|
+
};
|
|
1248
|
+
}, "dsh-workbuddy-bridge: Web status route");
|
|
1249
|
+
}
|
|
1250
|
+
//#endregion
|
|
1251
|
+
//#region src/web/probe-route.ts
|
|
1252
|
+
/**
|
|
1253
|
+
* Probe control route: the only state-changing endpoint the plugin exposes.
|
|
1254
|
+
*
|
|
1255
|
+
* Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
|
|
1256
|
+
* §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
|
|
1257
|
+
*
|
|
1258
|
+
* 1. **Loopback Host + Origin**, shared with the status route. This drops
|
|
1259
|
+
* DNS-rebinding pages, whose requests arrive addressed to the attacker's
|
|
1260
|
+
* domain.
|
|
1261
|
+
* 2. **An in-process random key**, minted per process and handed only to the
|
|
1262
|
+
* same-origin card. Loopback alone is *not* authentication — any local
|
|
1263
|
+
* process can write `Host: 127.0.0.1` — so a route that spends the user's
|
|
1264
|
+
* credit must prove the caller was told the key.
|
|
1265
|
+
*
|
|
1266
|
+
* A probe request is never accepted with a prompt, a model id outside the
|
|
1267
|
+
* live catalog, or a sentinel from the browser: it is assembled entirely
|
|
1268
|
+
* host-side. (Scope: the `probe` action only — `set-model-visibility`
|
|
1269
|
+
* deliberately accepts a model id the current catalog no longer lists, since
|
|
1270
|
+
* a hidden id is kept for when the model returns.)
|
|
1271
|
+
*
|
|
1272
|
+
* @module dsh-workbuddy-bridge/probe-route
|
|
1273
|
+
*/
|
|
1274
|
+
/** Largest control body accepted; these payloads are a few dozen bytes. */
|
|
1275
|
+
const MAX_BODY_BYTES = 4096;
|
|
1276
|
+
/** Mint the per-process control key. */
|
|
1277
|
+
function createProbeKey() {
|
|
1278
|
+
return randomBytes(24).toString("hex");
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Constant-time key comparison; a length mismatch is a failure, not a crash.
|
|
1282
|
+
*/
|
|
1283
|
+
function keyMatches(expected, presented) {
|
|
1284
|
+
if (presented === void 0 || presented.length !== expected.length) return false;
|
|
1285
|
+
const a = Buffer.from(expected);
|
|
1286
|
+
const b = Buffer.from(presented);
|
|
1287
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1288
|
+
}
|
|
1289
|
+
function json(res, status, body) {
|
|
1290
|
+
const payload = JSON.stringify(body);
|
|
1291
|
+
res.writeHead(status, {
|
|
1292
|
+
"Content-Type": "application/json",
|
|
1293
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1294
|
+
});
|
|
1295
|
+
res.end(payload);
|
|
1296
|
+
}
|
|
1297
|
+
/** Read the request body with a hard ceiling. */
|
|
1298
|
+
async function readBody(req) {
|
|
1299
|
+
const chunks = [];
|
|
1300
|
+
let total = 0;
|
|
1301
|
+
for await (const chunk of req) {
|
|
1302
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1303
|
+
total += buffer.length;
|
|
1304
|
+
if (total > MAX_BODY_BYTES) return void 0;
|
|
1305
|
+
chunks.push(buffer);
|
|
1306
|
+
}
|
|
1307
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1308
|
+
}
|
|
1309
|
+
/** Parse and shape-check an action; unknown fields are ignored, not trusted. */
|
|
1310
|
+
function parseAction(text) {
|
|
1311
|
+
let parsed;
|
|
1312
|
+
try {
|
|
1313
|
+
parsed = JSON.parse(text);
|
|
1314
|
+
} catch {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1318
|
+
const wrapped = parsed;
|
|
1319
|
+
const action = wrapped["action"];
|
|
1320
|
+
if (action === "clear") return { action: "clear" };
|
|
1321
|
+
if (action === "refresh") return { action: "refresh" };
|
|
1322
|
+
if (action === "set-model-visibility") {
|
|
1323
|
+
const model = wrapped["model"];
|
|
1324
|
+
const account = wrapped["account"];
|
|
1325
|
+
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
1326
|
+
if (typeof wrapped["visible"] !== "boolean") return void 0;
|
|
1327
|
+
if (typeof account !== "string" || account === "") return void 0;
|
|
1328
|
+
return {
|
|
1329
|
+
action: "set-model-visibility",
|
|
1330
|
+
model: model.trim(),
|
|
1331
|
+
visible: wrapped["visible"],
|
|
1332
|
+
account
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (action === "probe") {
|
|
1336
|
+
const model = wrapped["model"];
|
|
1337
|
+
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
1338
|
+
return {
|
|
1339
|
+
action: "probe",
|
|
1340
|
+
model: model.trim()
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* The control route's handler, extracted so tests can mount it on a bare
|
|
1346
|
+
* server with a known key.
|
|
1347
|
+
*/
|
|
1348
|
+
function workBuddyProbeHandler(deps, key) {
|
|
1349
|
+
return async (req, res) => {
|
|
1350
|
+
if (req.method !== "POST") {
|
|
1351
|
+
json(res, 405, { error: "method not allowed" });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1355
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
|
|
1359
|
+
json(res, 403, { error: "invalid-probe-key" });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const body = await readBody(req);
|
|
1363
|
+
if (body === void 0) {
|
|
1364
|
+
json(res, 413, { error: "body too large" });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const action = parseAction(body);
|
|
1368
|
+
if (action === void 0) {
|
|
1369
|
+
json(res, 400, { error: "invalid action" });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
if (action.action === "clear") {
|
|
1374
|
+
deps.clear();
|
|
1375
|
+
json(res, 200, { state: "cleared" });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (action.action === "refresh") {
|
|
1379
|
+
if (deps.refresh === void 0) {
|
|
1380
|
+
json(res, 404, { error: "refresh-not-supported" });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
json(res, 200, await deps.refresh());
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (action.action === "set-model-visibility") {
|
|
1387
|
+
if (deps.setModelVisibility === void 0) {
|
|
1388
|
+
json(res, 404, { error: "visibility-setting-not-supported" });
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
json(res, 200, await deps.setModelVisibility(action.model, action.visible === true, action.account));
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
json(res, 200, await deps.probe(action.model));
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/** Mount the POST probe-control route on an optional webServer context. */
|
|
1401
|
+
function registerWorkBuddyProbeRoute(ctx, deps, key) {
|
|
1402
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-bridge/probe";
|
|
1403
|
+
ctx.effect(() => {
|
|
1404
|
+
const dispose = ctx.webServer.register({
|
|
1405
|
+
kind: "exact",
|
|
1406
|
+
path,
|
|
1407
|
+
handler: workBuddyProbeHandler(deps, key)
|
|
1408
|
+
});
|
|
1409
|
+
return () => {
|
|
1410
|
+
dispose();
|
|
1411
|
+
};
|
|
1412
|
+
}, "dsh-workbuddy-bridge: probe control route");
|
|
1413
|
+
}
|
|
1414
|
+
//#endregion
|
|
1415
|
+
//#region src/index.ts
|
|
1416
|
+
/** Stable Cordis plugin name. */
|
|
1417
|
+
const name = "llm-workbuddy";
|
|
1418
|
+
/** The model registry required before the provider can register. */
|
|
1419
|
+
const inject = ["llm"];
|
|
1420
|
+
/**
|
|
1421
|
+
* How often the credential files are re-checked, in milliseconds.
|
|
1422
|
+
*
|
|
1423
|
+
* A startup-only catalog fetch cannot notice a sign-in that happens while DSH
|
|
1424
|
+
* is already running, so the model group would not appear until a restart. This
|
|
1425
|
+
* poll is a cheap existence/parse read of at most a few local files: it never
|
|
1426
|
+
* contacts the network and never runs a reasoning probe.
|
|
1427
|
+
*
|
|
1428
|
+
* `DSH_WORKBUDDY_POLL_MS` overrides it. That exists so the sweep can be
|
|
1429
|
+
* exercised end to end in tests and shortened while diagnosing a slow sign-in
|
|
1430
|
+
* on a real machine; it is not a product setting and no UI exposes it. The
|
|
1431
|
+
* value is clamped to a sane range so a mistaken override cannot turn the poll
|
|
1432
|
+
* into a busy loop.
|
|
1433
|
+
*/
|
|
1434
|
+
const CREDENTIAL_POLL_MS = 3e4;
|
|
1435
|
+
/** Floor and ceiling for the overridable poll interval. */
|
|
1436
|
+
const MIN_POLL_MS = 100;
|
|
1437
|
+
const MAX_POLL_MS = 864e5;
|
|
1438
|
+
/** Resolve the sweep interval, honoring the override when it is usable. */
|
|
1439
|
+
function credentialPollMs() {
|
|
1440
|
+
const override = Number(process.env["DSH_WORKBUDDY_POLL_MS"]);
|
|
1441
|
+
if (!Number.isFinite(override) || override < MIN_POLL_MS) return CREDENTIAL_POLL_MS;
|
|
1442
|
+
return Math.min(override, MAX_POLL_MS);
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* How long to wait before retrying a catalog fetch that failed.
|
|
1446
|
+
*
|
|
1447
|
+
* The credential sweep deliberately does not re-fetch a catalog it already has
|
|
1448
|
+
* (a same-identity token rotation carries no new model information). But a
|
|
1449
|
+
* *failed* fetch must not be treated the same way: without a retry, one
|
|
1450
|
+
* transient network blip at startup would leave the group on the built-in
|
|
1451
|
+
* fallback roster until the user noticed and pressed refresh. This bound keeps
|
|
1452
|
+
* that recovery automatic while still honoring the "not every round" rule — at
|
|
1453
|
+
* most one attempt per interval, and none at all once a live catalog lands.
|
|
1454
|
+
*
|
|
1455
|
+
* Expressed as a multiple of the sweep rather than a fixed duration so the two
|
|
1456
|
+
* stay in proportion under the `DSH_WORKBUDDY_POLL_MS` override.
|
|
1457
|
+
*/
|
|
1458
|
+
const CATALOG_RETRY_SWEEPS = 10;
|
|
1459
|
+
/** Explicit CN desktop auth-file path (shared by the plugin schema and its section). */
|
|
1460
|
+
const AUTH_FILE_FIELD = z.string().default("").volatile().description("WorkBuddy desktop auth file (defaults to the app's own location)");
|
|
1461
|
+
/** Explicit international desktop auth-file path (shared by the plugin schema and its section). */
|
|
1462
|
+
const AUTH_FILE_AI_FIELD = z.string().default("").volatile().description("WorkBuddy AI desktop auth file (defaults to the app's own location)");
|
|
1463
|
+
/** Probe authorization (shared by the plugin schema and the CN section). */
|
|
1464
|
+
const PROBE_CONSENT_FIELD = z.boolean().default(false).volatile().description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
|
|
1465
|
+
const MAXIMUM_CONTEXT_WINDOW_FIELD = z.boolean().default(true).volatile().description("Use the largest context window declared by WorkBuddy AI when alternatives are available (on by default)");
|
|
1466
|
+
const Config = z.object({
|
|
1467
|
+
authFile: AUTH_FILE_FIELD,
|
|
1468
|
+
authFileAI: AUTH_FILE_AI_FIELD,
|
|
1469
|
+
probeConsent: PROBE_CONSENT_FIELD,
|
|
1470
|
+
useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD
|
|
1471
|
+
});
|
|
1472
|
+
/**
|
|
1473
|
+
* Read the current plain configuration.
|
|
1474
|
+
*
|
|
1475
|
+
* Called at the point of use rather than cached: see {@link ConfigRefs}. Several
|
|
1476
|
+
* callers are lazy accessors evaluated long after `apply` returned, and those
|
|
1477
|
+
* must observe an edit made in between.
|
|
1478
|
+
* @param refs - the reference face handed to `apply`.
|
|
1479
|
+
* @returns the values in effect right now.
|
|
1480
|
+
*/
|
|
1481
|
+
function configOf(refs) {
|
|
1482
|
+
return {
|
|
1483
|
+
authFile: refs.authFile.get() ?? "",
|
|
1484
|
+
authFileAI: refs.authFileAI.get() ?? "",
|
|
1485
|
+
probeConsent: refs.probeConsent?.get() ?? false,
|
|
1486
|
+
useMaximumContextWindow: refs.useMaximumContextWindow?.get() ?? true
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
/** Stable identity key used by credentials, probe records, and catalog entries. */
|
|
1490
|
+
function credentialIdentity(credential) {
|
|
1491
|
+
return `${credential.uid}:${credential.enterpriseId ?? ""}`;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* The account key model-visibility preferences are stored under: the stable
|
|
1495
|
+
* identity, but only when it carries a uid.
|
|
1496
|
+
*
|
|
1497
|
+
* A credential whose desktop document carried no `account.uid` normalizes to
|
|
1498
|
+
* an empty string; keying preferences on the resulting `":enterpriseId"` would
|
|
1499
|
+
* silently share one bucket between every such account. Those accounts get no
|
|
1500
|
+
* per-account preferences at all — everything stays visible and the control
|
|
1501
|
+
* route explains the refusal — which is the only honest degradation: it never
|
|
1502
|
+
* applies one account's hidden list to another.
|
|
1503
|
+
*/
|
|
1504
|
+
function visibilityAccountOf(credential) {
|
|
1505
|
+
return credential.uid === "" ? void 0 : credentialIdentity(credential);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Read the configured explicit auth-file path for one variant.
|
|
1509
|
+
*
|
|
1510
|
+
* An empty string means "not set" — that is what the schema's default and the
|
|
1511
|
+
* form's empty field both produce — and it must be reported as `undefined`
|
|
1512
|
+
* rather than passed along. The consumers below treat a *present* path as
|
|
1513
|
+
* authoritative and stop falling back to the env var and the platform default,
|
|
1514
|
+
* so forwarding `''` would silently disable both.
|
|
1515
|
+
* @param config - the plain configuration in effect.
|
|
1516
|
+
* @param variant - which of the two desktop apps this is for.
|
|
1517
|
+
* @returns the configured path, or `undefined` when the field is empty.
|
|
1518
|
+
*/
|
|
1519
|
+
function configuredAuthFile(config, variant) {
|
|
1520
|
+
const path = variant.id === CN_VARIANT.id ? config.authFile : config.authFileAI;
|
|
1521
|
+
return path === "" ? void 0 : path;
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The static catalog a variant serves before its first successful fetch.
|
|
1525
|
+
*
|
|
1526
|
+
* Each variant has its own roster: the two endpoints share several model ids
|
|
1527
|
+
* but not their billing, context windows, or reasoning sets, so one shared
|
|
1528
|
+
* fallback would misdescribe whichever variant it was not captured from.
|
|
1529
|
+
*/
|
|
1530
|
+
function fallbackFor(variant) {
|
|
1531
|
+
return variant.id === CN_VARIANT.id ? FALLBACK_WORKBUDDY_MODELS : FALLBACK_WORKBUDDY_AI_MODELS;
|
|
1532
|
+
}
|
|
1533
|
+
/** Build one variant's stores and probe state. */
|
|
1534
|
+
function createVariantRuntime(config, variant, identityOf, accountOf, keyProvider) {
|
|
1535
|
+
const client = new WorkBuddyUpstreamClient();
|
|
1536
|
+
const configured = configuredAuthFile(config, variant);
|
|
1537
|
+
const store = new WorkBuddyCredentialStore({
|
|
1538
|
+
variant,
|
|
1539
|
+
...configured === void 0 ? {} : { desktopPath: configured },
|
|
1540
|
+
...keyProvider === void 0 ? {} : { keyProvider },
|
|
1541
|
+
refresh: (credential) => client.refreshToken(credential)
|
|
1542
|
+
});
|
|
1543
|
+
const fallback = fallbackFor(variant);
|
|
1544
|
+
const catalog = new WorkBuddyCatalog(fallback);
|
|
1545
|
+
if (variant.id !== CN_VARIANT.id) catalog.setUseMaximumContextWindow(config.useMaximumContextWindow === true);
|
|
1546
|
+
catalog.setVisible(false);
|
|
1547
|
+
const probeStore = new WorkBuddyProbeStore({
|
|
1548
|
+
pluginVersion: WORKBUDDY_BRIDGE_VERSION,
|
|
1549
|
+
path: workbuddyProbePath(variant.probeFilename)
|
|
1550
|
+
});
|
|
1551
|
+
const savedCatalogs = new WorkBuddyCatalogStore(workbuddyCatalogPath(variant.catalogFilename));
|
|
1552
|
+
const visibilityStore = new WorkBuddyVisibilityStore(workbuddyVisibilityPath(variant.visibilityFilename));
|
|
1553
|
+
return {
|
|
1554
|
+
variant,
|
|
1555
|
+
store,
|
|
1556
|
+
client,
|
|
1557
|
+
catalog,
|
|
1558
|
+
probeStore,
|
|
1559
|
+
probeService: new WorkBuddyProbeService({
|
|
1560
|
+
store: probeStore,
|
|
1561
|
+
catalog,
|
|
1562
|
+
credentials: store,
|
|
1563
|
+
client,
|
|
1564
|
+
consent: () => config.probeConsent,
|
|
1565
|
+
account: () => identityOf(variant.id)
|
|
1566
|
+
}),
|
|
1567
|
+
savedCatalogs,
|
|
1568
|
+
visibilityStore,
|
|
1569
|
+
account: () => accountOf(variant.id),
|
|
1570
|
+
fallback,
|
|
1571
|
+
catalogSource: "fallback",
|
|
1572
|
+
catalogFetchedAtMs: void 0,
|
|
1573
|
+
catalogError: void 0,
|
|
1574
|
+
lastFetchAtMs: 0,
|
|
1575
|
+
catalogGeneration: 0,
|
|
1576
|
+
inflightFetch: void 0,
|
|
1577
|
+
invalidate: () => {},
|
|
1578
|
+
registered: false
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
/** The catalog provenance the card displays. */
|
|
1582
|
+
function catalogSection(runtime) {
|
|
1583
|
+
const fetch = runtime.client.lastCatalog;
|
|
1584
|
+
return {
|
|
1585
|
+
source: runtime.catalogSource,
|
|
1586
|
+
...runtime.catalogFetchedAtMs === void 0 ? {} : { fetchedAt: runtime.catalogFetchedAtMs },
|
|
1587
|
+
...fetch?.appVersion === void 0 ? {} : { appVersion: fetch.appVersion.version },
|
|
1588
|
+
...runtime.catalogError === void 0 ? {} : { error: runtime.catalogError }
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Whether a model can be probed by hand: it reasons and the upstream declares
|
|
1593
|
+
* no effort set for it.
|
|
1594
|
+
*
|
|
1595
|
+
* Deliberately *not* filtered by whether a result already exists. Dropping a
|
|
1596
|
+
* model once it has been detected made the list shrink with use, so
|
|
1597
|
+
* re-detecting one model — after an upstream change, say — meant clearing every
|
|
1598
|
+
* other result first. The list stays stable and the card marks which entries
|
|
1599
|
+
* already have an answer.
|
|
1600
|
+
*/
|
|
1601
|
+
function isProbeCandidate(info) {
|
|
1602
|
+
if (info.reasoning?.supports !== true) return false;
|
|
1603
|
+
return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
|
|
1604
|
+
}
|
|
1605
|
+
/** Compact probe state for one card: consent, candidates, observations. */
|
|
1606
|
+
function probeSection(runtime, consent) {
|
|
1607
|
+
const models = runtime.catalog.current();
|
|
1608
|
+
const results = models.flatMap((info) => {
|
|
1609
|
+
const record = runtime.probeService.recordFor(info.id);
|
|
1610
|
+
if (record === void 0) return [];
|
|
1611
|
+
return [{
|
|
1612
|
+
id: info.id,
|
|
1613
|
+
name: info.name,
|
|
1614
|
+
validation: record.validation,
|
|
1615
|
+
efforts: record.efforts,
|
|
1616
|
+
probedAt: record.probedAtMs
|
|
1617
|
+
}];
|
|
1618
|
+
});
|
|
1619
|
+
return {
|
|
1620
|
+
consent,
|
|
1621
|
+
running: runtime.probeService.isRunning(),
|
|
1622
|
+
candidates: models.filter(isProbeCandidate).map((info) => info.id),
|
|
1623
|
+
results: newestFirst(results)
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Start one variant: its loopback endpoint, provider registration, and
|
|
1628
|
+
* configuration-card wiring.
|
|
1629
|
+
*
|
|
1630
|
+
* Registration waits for the shim to hold a port, because the provider's
|
|
1631
|
+
* models read the shim origin at construction time. A failure here is
|
|
1632
|
+
* contained to this variant: the caller logs it and the other keeps working.
|
|
1633
|
+
*
|
|
1634
|
+
* @returns whether the provider registered.
|
|
1635
|
+
*/
|
|
1636
|
+
async function startVariant(ctx, runtime) {
|
|
1637
|
+
const { variant, store, client, catalog, probeService } = runtime;
|
|
1638
|
+
const shim = createWorkBuddyShim({
|
|
1639
|
+
store,
|
|
1640
|
+
client,
|
|
1641
|
+
catalog,
|
|
1642
|
+
logger: ctx.logger
|
|
1643
|
+
});
|
|
1644
|
+
try {
|
|
1645
|
+
await shim.ready;
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
ctx.logger.error(`dsh-workbuddy-bridge: ${variant.displayName} loopback endpoint failed to start`, error);
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
const workbuddy = createWorkBuddyAdapter({
|
|
1652
|
+
providerId: variant.id,
|
|
1653
|
+
displayName: variant.displayName,
|
|
1654
|
+
shim,
|
|
1655
|
+
store,
|
|
1656
|
+
catalog,
|
|
1657
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
1658
|
+
observe: (modelId) => probeService.recordFor(modelId),
|
|
1659
|
+
hidden: () => {
|
|
1660
|
+
const account = runtime.account();
|
|
1661
|
+
return account === void 0 ? [] : runtime.visibilityStore.hidden(account);
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
runtime.invalidate = () => {
|
|
1665
|
+
workbuddy.invalidate();
|
|
1666
|
+
ctx.emit("llm/adapters-updated");
|
|
1667
|
+
};
|
|
1668
|
+
const releaseAdapter = ctx.llm.registerAdapter([variant.id], workbuddy.adapter);
|
|
1669
|
+
try {
|
|
1670
|
+
ctx.effect(() => () => {
|
|
1671
|
+
releaseAdapter();
|
|
1672
|
+
shim.close();
|
|
1673
|
+
});
|
|
1674
|
+
} catch {
|
|
1675
|
+
releaseAdapter();
|
|
1676
|
+
shim.close();
|
|
1677
|
+
}
|
|
1678
|
+
runtime.registered = true;
|
|
1679
|
+
return true;
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
ctx.logger.error(`dsh-workbuddy-bridge: ${variant.displayName} provider registration failed`, error);
|
|
1682
|
+
shim.close();
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Start both variants: their loopback endpoints, the `workbuddy` and
|
|
1688
|
+
* `workbuddy-ai` providers, their configuration cards, and their
|
|
1689
|
+
* credential-driven catalog lifecycles.
|
|
1690
|
+
*
|
|
1691
|
+
* Each variant registers unconditionally; what varies is whether its catalog is
|
|
1692
|
+
* *visible*. An empty catalog is how DSH hides a model group (the host filters
|
|
1693
|
+
* out groups with no models), which keeps a sign-in that happens after startup
|
|
1694
|
+
* working without re-registering the provider.
|
|
1695
|
+
*/
|
|
1696
|
+
function apply(ctx, refs) {
|
|
1697
|
+
/**
|
|
1698
|
+
* The configuration in effect *now*.
|
|
1699
|
+
*
|
|
1700
|
+
* A function rather than a captured object because the references are live:
|
|
1701
|
+
* the lazy accessors registered below outlive `apply`, and an edit made in
|
|
1702
|
+
* between must be visible to them.
|
|
1703
|
+
*/
|
|
1704
|
+
const config = () => configOf(refs);
|
|
1705
|
+
/** Timers and in-flight work belonging to this plugin instance. */
|
|
1706
|
+
let stopped = false;
|
|
1707
|
+
const timers = [];
|
|
1708
|
+
/**
|
|
1709
|
+
* The account identity each variant last published a catalog for. Keeps a
|
|
1710
|
+
* same-identity token rotation from re-fetching, and lets a late response
|
|
1711
|
+
* from a previous identity be discarded instead of overwriting a newer one.
|
|
1712
|
+
*/
|
|
1713
|
+
const lastIdentities = /* @__PURE__ */ new Map();
|
|
1714
|
+
/**
|
|
1715
|
+
* The visibility account key each variant last adopted, parallel to
|
|
1716
|
+
* {@link lastIdentities}: same credential, second key — undefined both when
|
|
1717
|
+
* signed out and when the credential carried no uid, which is exactly the
|
|
1718
|
+
* case that must not fall back to a shared preference bucket.
|
|
1719
|
+
*/
|
|
1720
|
+
const lastAccounts = /* @__PURE__ */ new Map();
|
|
1721
|
+
const atRestKeysFor = (variant) => new WorkBuddyAtRestKeyProvider({ discovery: variant.id === CN_VARIANT.id ? cnAppDiscovery() : "none" });
|
|
1722
|
+
const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config(), variant, (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeysFor(variant)));
|
|
1723
|
+
const probeKey = createProbeKey();
|
|
1724
|
+
/**
|
|
1725
|
+
* Point a variant at an account identity, invalidating whatever the previous
|
|
1726
|
+
* one left behind.
|
|
1727
|
+
*
|
|
1728
|
+
* One helper for all four transitions (sweep sign-in, sweep sign-out, manual
|
|
1729
|
+
* refresh, manual refresh sign-out) because each of them used to do its own
|
|
1730
|
+
* partial version, and the manual path forgot pieces the sweep did. Every
|
|
1731
|
+
* transition bumps {@link VariantRuntime.catalogGeneration}, which is what
|
|
1732
|
+
* makes an in-flight request from before the change refuse to write back.
|
|
1733
|
+
*
|
|
1734
|
+
* Probe observations are kept across an account change: the store nests them
|
|
1735
|
+
* per account, so the departing account's records simply stop being served
|
|
1736
|
+
* (every read is account-scoped) and are found intact if that account
|
|
1737
|
+
* returns. The "signed out, then in as someone else" sequence that used to
|
|
1738
|
+
* look like a first sighting is still safe — a record only ever answers for
|
|
1739
|
+
* the account stamped on it, so the new account inherits nothing. Visibility
|
|
1740
|
+
* preferences are kept for the same reason, and read through the new
|
|
1741
|
+
* account's key immediately: the picker re-lists after the invalidate below
|
|
1742
|
+
* and a returning account finds its own hidden list back in force.
|
|
1743
|
+
*
|
|
1744
|
+
* @param identity - the account now in effect, or `undefined` when signed out.
|
|
1745
|
+
* @param account - the visibility key for that same credential (`undefined`
|
|
1746
|
+
* also when the credential carries no uid); stored alongside the identity so
|
|
1747
|
+
* preference reads never guess it from the identity string.
|
|
1748
|
+
*/
|
|
1749
|
+
const adoptIdentity = (runtime, identity, account) => {
|
|
1750
|
+
const id = runtime.variant.id;
|
|
1751
|
+
const known = lastIdentities.get(id);
|
|
1752
|
+
if (known === identity) return;
|
|
1753
|
+
const hadCredential = known !== void 0;
|
|
1754
|
+
if (identity === void 0) lastIdentities.delete(id);
|
|
1755
|
+
else lastIdentities.set(id, identity);
|
|
1756
|
+
if (account === void 0) lastAccounts.delete(id);
|
|
1757
|
+
else lastAccounts.set(id, account);
|
|
1758
|
+
runtime.catalogGeneration += 1;
|
|
1759
|
+
runtime.inflightFetch?.controller.abort();
|
|
1760
|
+
runtime.inflightFetch = void 0;
|
|
1761
|
+
if (hadCredential && known !== identity) runtime.invalidate();
|
|
1762
|
+
if (identity === void 0) {
|
|
1763
|
+
if (known !== void 0) runtime.savedCatalogs.delete(known);
|
|
1764
|
+
runtime.catalog.set(runtime.fallback);
|
|
1765
|
+
runtime.catalogSource = "fallback";
|
|
1766
|
+
runtime.catalogFetchedAtMs = void 0;
|
|
1767
|
+
runtime.catalogError = void 0;
|
|
1768
|
+
if (runtime.catalog.setVisible(false)) runtime.invalidate();
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
const saved = runtime.savedCatalogs.get(identity);
|
|
1772
|
+
if (saved !== void 0) {
|
|
1773
|
+
runtime.catalog.set([...saved.models]);
|
|
1774
|
+
runtime.catalogSource = "saved";
|
|
1775
|
+
runtime.catalogFetchedAtMs = saved.fetchedAtMs;
|
|
1776
|
+
} else {
|
|
1777
|
+
runtime.catalog.set(runtime.fallback);
|
|
1778
|
+
runtime.catalogSource = "fallback";
|
|
1779
|
+
runtime.catalogFetchedAtMs = void 0;
|
|
1780
|
+
}
|
|
1781
|
+
runtime.catalogError = void 0;
|
|
1782
|
+
runtime.catalog.setVisible(true);
|
|
1783
|
+
runtime.invalidate();
|
|
1784
|
+
};
|
|
1785
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
1786
|
+
for (const runtime of runtimes) {
|
|
1787
|
+
registerWorkBuddyStatusRoute(webCtx, {
|
|
1788
|
+
path: runtime.variant.statusPath,
|
|
1789
|
+
store: runtime.store,
|
|
1790
|
+
client: runtime.client,
|
|
1791
|
+
models: () => runtime.catalog.current(),
|
|
1792
|
+
catalog: () => catalogSection(runtime),
|
|
1793
|
+
probe: () => probeSection(runtime, config().probeConsent),
|
|
1794
|
+
probeKey,
|
|
1795
|
+
visibility: () => {
|
|
1796
|
+
const account = runtime.account();
|
|
1797
|
+
return account === void 0 ? void 0 : {
|
|
1798
|
+
account,
|
|
1799
|
+
hidden: runtime.visibilityStore.hidden(account)
|
|
1800
|
+
};
|
|
1801
|
+
},
|
|
1802
|
+
...runtime.variant.id === CN_VARIANT.id ? {} : { useMaximumContextWindow: () => config().useMaximumContextWindow }
|
|
1803
|
+
});
|
|
1804
|
+
registerWorkBuddyProbeRoute(webCtx, {
|
|
1805
|
+
path: runtime.variant.probePath,
|
|
1806
|
+
probe: async (modelId) => {
|
|
1807
|
+
const result = await runtime.probeService.probe(modelId, true);
|
|
1808
|
+
if (result.state === "ok") runtime.invalidate();
|
|
1809
|
+
return result;
|
|
1810
|
+
},
|
|
1811
|
+
clear: () => {
|
|
1812
|
+
runtime.probeStore.clear();
|
|
1813
|
+
runtime.invalidate();
|
|
1814
|
+
},
|
|
1815
|
+
refresh: async () => {
|
|
1816
|
+
if (stopped) return {
|
|
1817
|
+
state: "failed",
|
|
1818
|
+
reason: "plugin is stopping"
|
|
1819
|
+
};
|
|
1820
|
+
let credential;
|
|
1821
|
+
try {
|
|
1822
|
+
credential = await runtime.store.current();
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
return {
|
|
1825
|
+
state: "failed",
|
|
1826
|
+
reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
if (credential === void 0) {
|
|
1830
|
+
adoptIdentity(runtime, void 0, void 0);
|
|
1831
|
+
return { state: "signed-out" };
|
|
1832
|
+
}
|
|
1833
|
+
const identity = credentialIdentity(credential);
|
|
1834
|
+
adoptIdentity(runtime, identity, visibilityAccountOf(credential));
|
|
1835
|
+
await fetchCatalog(runtime, identity);
|
|
1836
|
+
return runtime.catalogError === void 0 ? {
|
|
1837
|
+
state: "refreshed",
|
|
1838
|
+
reason: `${runtime.catalog.current().length} models`
|
|
1839
|
+
} : {
|
|
1840
|
+
state: "failed",
|
|
1841
|
+
reason: runtime.catalogError
|
|
1842
|
+
};
|
|
1843
|
+
},
|
|
1844
|
+
setModelVisibility: async (modelId, visible, expectedAccount) => {
|
|
1845
|
+
const account = runtime.account();
|
|
1846
|
+
if (account === void 0) return {
|
|
1847
|
+
state: "failed",
|
|
1848
|
+
reason: "model visibility needs a signed-in account with a stable user id"
|
|
1849
|
+
};
|
|
1850
|
+
if (expectedAccount !== account) return {
|
|
1851
|
+
state: "stale-account",
|
|
1852
|
+
reason: "the signed-in account changed"
|
|
1853
|
+
};
|
|
1854
|
+
try {
|
|
1855
|
+
runtime.visibilityStore.setVisible(account, modelId, visible);
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
return {
|
|
1858
|
+
state: "failed",
|
|
1859
|
+
reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
runtime.invalidate();
|
|
1863
|
+
return { state: "updated" };
|
|
1864
|
+
}
|
|
1865
|
+
}, probeKey);
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
ctx.effect(() => () => {
|
|
1869
|
+
stopped = true;
|
|
1870
|
+
for (const timer of timers) clearInterval(timer);
|
|
1871
|
+
timers.length = 0;
|
|
1872
|
+
clearHostHeartbeat();
|
|
1873
|
+
});
|
|
1874
|
+
/**
|
|
1875
|
+
* Fetch one variant's catalog for the current credential.
|
|
1876
|
+
*
|
|
1877
|
+
* Shared by the credential sweep and the card's manual refresh, and written
|
|
1878
|
+
* so that concurrent callers cost one request and cannot interleave badly:
|
|
1879
|
+
*
|
|
1880
|
+
* - **One request at a time.** A second caller joins the in-flight fetch
|
|
1881
|
+
* instead of starting its own (spec §5: one catalog request per variant at
|
|
1882
|
+
* a time).
|
|
1883
|
+
* - **Generation-checked write-back.** The request records the generation it
|
|
1884
|
+
* started under and writes nothing if the generation moved on — which is
|
|
1885
|
+
* what a slow answer from a superseded account must not do. Checking only
|
|
1886
|
+
* the *identity* was not enough: two refreshes for the same account can
|
|
1887
|
+
* still finish out of order, and the older one would win.
|
|
1888
|
+
* - **`resolve()`, not `config`.** Only `resolve()` performs the locked,
|
|
1889
|
+
* single-flight token renewal. Reading `config` meant an expired token
|
|
1890
|
+
* made every catalog request fail until something else happened to refresh
|
|
1891
|
+
* it, leaving the group on the fallback roster.
|
|
1892
|
+
*/
|
|
1893
|
+
const fetchCatalog = async (runtime, identity) => {
|
|
1894
|
+
const inflight = runtime.inflightFetch;
|
|
1895
|
+
const generation = runtime.catalogGeneration;
|
|
1896
|
+
if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
|
|
1897
|
+
inflight?.controller.abort();
|
|
1898
|
+
const controller = new AbortController();
|
|
1899
|
+
let run;
|
|
1900
|
+
run = (async () => {
|
|
1901
|
+
let models;
|
|
1902
|
+
try {
|
|
1903
|
+
const credential = await runtime.store.resolve();
|
|
1904
|
+
const resolvedIdentity = credentialIdentity(credential);
|
|
1905
|
+
if (resolvedIdentity !== identity) {
|
|
1906
|
+
adoptIdentity(runtime, resolvedIdentity, visibilityAccountOf(credential));
|
|
1907
|
+
await fetchCatalog(runtime, resolvedIdentity);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
models = await runtime.client.fetchModels(credential, controller.signal);
|
|
1911
|
+
const latest = await runtime.store.current();
|
|
1912
|
+
const latestIdentity = latest === void 0 ? void 0 : credentialIdentity(latest);
|
|
1913
|
+
if (latestIdentity !== identity) {
|
|
1914
|
+
adoptIdentity(runtime, latestIdentity, latest === void 0 ? void 0 : visibilityAccountOf(latest));
|
|
1915
|
+
if (latestIdentity !== void 0) await fetchCatalog(runtime, latestIdentity);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
1920
|
+
runtime.lastFetchAtMs = Date.now();
|
|
1921
|
+
runtime.catalogError = error instanceof Error ? error.message.slice(0, 300) : String(error);
|
|
1922
|
+
ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} catalog unavailable; serving the fallback list`, error);
|
|
1923
|
+
runtime.invalidate();
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
1927
|
+
runtime.lastFetchAtMs = Date.now();
|
|
1928
|
+
runtime.catalog.set([...models]);
|
|
1929
|
+
runtime.catalogSource = "live";
|
|
1930
|
+
runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
|
|
1931
|
+
runtime.catalogError = void 0;
|
|
1932
|
+
if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
|
|
1933
|
+
source: runtime.client.lastCatalog?.source ?? "unknown",
|
|
1934
|
+
fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
|
|
1935
|
+
models: [...models],
|
|
1936
|
+
...runtime.client.lastCatalog?.appVersion === void 0 ? {} : { appVersion: runtime.client.lastCatalog.appVersion.version }
|
|
1937
|
+
});
|
|
1938
|
+
runtime.invalidate();
|
|
1939
|
+
})().finally(() => {
|
|
1940
|
+
if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
|
|
1941
|
+
});
|
|
1942
|
+
runtime.inflightFetch = {
|
|
1943
|
+
identity,
|
|
1944
|
+
generation,
|
|
1945
|
+
controller,
|
|
1946
|
+
promise: run
|
|
1947
|
+
};
|
|
1948
|
+
return run;
|
|
1949
|
+
};
|
|
1950
|
+
/**
|
|
1951
|
+
* Reconcile one variant with its credentials.
|
|
1952
|
+
*
|
|
1953
|
+
* Four transitions matter, and each is a different action:
|
|
1954
|
+
*
|
|
1955
|
+
* - **none → some** (first sighting): reveal the group and fetch a catalog.
|
|
1956
|
+
* - **none → some, identity changed**: additionally drop the previous
|
|
1957
|
+
* account's observations, so another user's probe answers cannot be read as
|
|
1958
|
+
* the new account's.
|
|
1959
|
+
* - **some → none**: hide the group and stop serving its models.
|
|
1960
|
+
* - **same identity**: nothing to do — the store refreshes tokens on demand,
|
|
1961
|
+
* and re-fetching on every rotation would hit the catalog endpoint for no
|
|
1962
|
+
* new information.
|
|
1963
|
+
*/
|
|
1964
|
+
const syncVariant = async (runtime) => {
|
|
1965
|
+
if (stopped || !runtime.registered) return;
|
|
1966
|
+
const credential = await runtime.store.current().catch((error) => {
|
|
1967
|
+
ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} credential read failed`, error);
|
|
1968
|
+
});
|
|
1969
|
+
if (stopped) return;
|
|
1970
|
+
if (credential === void 0) {
|
|
1971
|
+
adoptIdentity(runtime, void 0, void 0);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
const identity = credentialIdentity(credential);
|
|
1975
|
+
if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
|
|
1976
|
+
const stale = runtime.catalogSource !== "live";
|
|
1977
|
+
const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
|
|
1978
|
+
if (stale && due) await fetchCatalog(runtime, identity);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
adoptIdentity(runtime, identity, visibilityAccountOf(credential));
|
|
1982
|
+
await fetchCatalog(runtime, identity);
|
|
1983
|
+
};
|
|
1984
|
+
/** Run one reconcile sweep across both variants. */
|
|
1985
|
+
const syncAll = async () => {
|
|
1986
|
+
for (const runtime of runtimes) await syncVariant(runtime);
|
|
1987
|
+
};
|
|
1988
|
+
Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
|
|
1989
|
+
if (stopped) return;
|
|
1990
|
+
if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
|
|
1991
|
+
syncAll();
|
|
1992
|
+
const timer = setInterval(() => {
|
|
1993
|
+
syncAll();
|
|
1994
|
+
}, credentialPollMs());
|
|
1995
|
+
timer.unref?.();
|
|
1996
|
+
timers.push(timer);
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
//#endregion
|
|
2000
|
+
export { AI_VARIANT, CN_APP_VERSION_FILENAME, CN_VARIANT, Config, FALLBACK_CN_APP_VERSION, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WORKBUDDY_VISIBILITY_FILENAME, WorkBuddyCatalog, WorkBuddyCatalogStore, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, WorkBuddyVisibilityStore, appUserAgent, apply, chatUserAgent, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fallbackChatIdentity, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readCliVersion, readHostHeartbeat, regionOf, resolveAppVersion, resolveChatIdentity, validAppVersion, validCliVersion, variantFor, visibilityAccountOf, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath, workbuddyVisibilityPath };
|