dsh-workbuddy-connect 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +24 -9
- package/README.md +26 -12
- package/lib/bin.js +1 -1
- package/lib/client.js +866 -31
- package/lib/{host-heartbeat-9FtwMcIF.js → host-heartbeat-0UAWqWac.js} +232 -3
- package/lib/index.d.ts +231 -1
- package/lib/index.js +571 -43
- package/package.json +29 -23
package/lib/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { S as workbuddyOwnAuthPath, _ as
|
|
1
|
+
import { C as defaultDesktopAuthPath, S as defaultDesktopAuthCandidates, T as workbuddyOwnAuthPath, _ as FALLBACK_WORKBUDDY_MODELS, a as readHostHeartbeat, b as WORKBUDDY_AUTH_FILE_ENV, c as WORKBUDDY_CONNECT_VERSION, d as normalizeCredits, f as prepareChatBody, g as randomSentinel, h as probeModel, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as PROBE_EFFORT_CANDIDATES, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as regionOf, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as classifyUpstreamError, v as WorkBuddyCatalog, w as parseWorkBuddyAuth, x as WorkBuddyCredentialStore, y as WORKBUDDY_AUTH_FILENAME } from "./host-heartbeat-0UAWqWac.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
3
5
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
4
6
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
5
7
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
6
8
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
7
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
10
|
import { createServer } from "node:http";
|
|
9
11
|
import { Readable } from "node:stream";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
13
|
//#region src/adapter.ts
|
|
11
14
|
/**
|
|
12
15
|
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
@@ -107,29 +110,40 @@ function withCatalogDisplay(name, info) {
|
|
|
107
110
|
* `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
|
|
108
111
|
* unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
|
|
109
112
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
113
|
+
* Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
|
|
114
|
+
*
|
|
115
|
+
* 1. **The declared set.** When the upstream declares a non-empty
|
|
116
|
+
* `supportedEfforts`, exactly those values are offered and nothing else.
|
|
117
|
+
* This always wins: an observation never widens or narrows a declared set.
|
|
118
|
+
* 2. **A local observation.** Rows without a declared set (the older
|
|
119
|
+
* `{effort, summary}` shape) normally get no control at all — their
|
|
120
|
+
* selectable set is client-side knowledge the catalog does not carry, and
|
|
121
|
+
* the desktop app differs per model there. If the user authorized a probe
|
|
122
|
+
* and it established that the upstream *validates* the parameter, the
|
|
123
|
+
* verified spellings are offered.
|
|
124
|
+
*
|
|
125
|
+
* A `non-validating` observation deliberately yields no control: the upstream
|
|
126
|
+
* accepts values that cannot exist (measured on `glm-5.2`), so every per-level
|
|
127
|
+
* acceptance it produced would be a false positive.
|
|
128
|
+
*
|
|
129
|
+
* `off` is offered only when the upstream declares `canDisableThinking: true`.
|
|
130
|
+
* It is never probed — disabling thinking is a separate capability, and the
|
|
131
|
+
* per-model acceptance of `off` cannot be inferred from the row's shape.
|
|
132
|
+
*
|
|
133
|
+
* The offered set is described internally as "verified accepted", never as
|
|
134
|
+
* "verified effective": acceptance proves the upstream did not reject the
|
|
135
|
+
* spelling, not that it changes what the model does.
|
|
123
136
|
*/
|
|
124
|
-
function reasoningFields(info) {
|
|
137
|
+
function reasoningFields(info, observed) {
|
|
125
138
|
const reasoning = info.reasoning;
|
|
126
139
|
if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
|
|
127
|
-
const
|
|
128
|
-
|
|
140
|
+
const declared = reasoning.supportedEfforts;
|
|
141
|
+
const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
|
|
142
|
+
if (efforts === void 0) return { reasoning: false };
|
|
129
143
|
return {
|
|
130
144
|
reasoning: true,
|
|
131
145
|
thinkingLevelMap: {
|
|
132
|
-
off: reasoning.canDisableThinking === true ? "off" : null,
|
|
146
|
+
off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
|
|
133
147
|
minimal: null,
|
|
134
148
|
low: efforts.includes("low") ? "low" : null,
|
|
135
149
|
medium: efforts.includes("medium") ? "medium" : null,
|
|
@@ -140,7 +154,7 @@ function reasoningFields(info) {
|
|
|
140
154
|
};
|
|
141
155
|
}
|
|
142
156
|
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
143
|
-
function toPiModel(info, baseUrl) {
|
|
157
|
+
function toPiModel(info, baseUrl, observed) {
|
|
144
158
|
return {
|
|
145
159
|
id: info.id,
|
|
146
160
|
name: info.name,
|
|
@@ -148,7 +162,7 @@ function toPiModel(info, baseUrl) {
|
|
|
148
162
|
provider: WORKBUDDY_PROVIDER,
|
|
149
163
|
baseUrl,
|
|
150
164
|
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
151
|
-
...reasoningFields(info),
|
|
165
|
+
...reasoningFields(info, observed),
|
|
152
166
|
cost: NO_COST,
|
|
153
167
|
contextWindow: info.contextWindow,
|
|
154
168
|
maxTokens: info.maxTokens
|
|
@@ -158,12 +172,19 @@ function toPiModel(info, baseUrl) {
|
|
|
158
172
|
* Assemble the adapter. The provider's `getModels` reads the live catalog,
|
|
159
173
|
* and every model's `baseUrl` is re-resolved per read so the shim's
|
|
160
174
|
* ephemeral port applies from the first snapshot after startup.
|
|
175
|
+
*
|
|
176
|
+
* The profile is constructed by hand rather than through dsh-llm-pi-ai's
|
|
177
|
+
* internal `resolveProfiles()`: that helper is not part of the package's
|
|
178
|
+
* public export surface (root entry, `lib/` deep imports blocked by the
|
|
179
|
+
* exports map, `src/` not shipped), so hand-assembly is the only supported
|
|
180
|
+
* path and every newly required field must be adopted here explicitly —
|
|
181
|
+
* `modelErrors` since 0.1.5-alpha.2 (#12).
|
|
161
182
|
*/
|
|
162
183
|
function createWorkBuddyAdapter(options) {
|
|
163
|
-
const { shim, store, catalog, resolveAttachments } = options;
|
|
184
|
+
const { shim, store, catalog, resolveAttachments, observe } = options;
|
|
164
185
|
const buildModels = () => {
|
|
165
186
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
166
|
-
return catalog.current().map((info) => toPiModel(info, baseUrl));
|
|
187
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id)));
|
|
167
188
|
};
|
|
168
189
|
const provider = {
|
|
169
190
|
...createProvider({
|
|
@@ -190,6 +211,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
190
211
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
191
212
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-connect retryPolicy"),
|
|
192
213
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
214
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
193
215
|
...REQUEST_IMAGE_BUDGETS,
|
|
194
216
|
piProvider: provider
|
|
195
217
|
};
|
|
@@ -349,7 +371,7 @@ function writeOpenAIError(res, status, kind, message) {
|
|
|
349
371
|
} });
|
|
350
372
|
}
|
|
351
373
|
/** Read a request body with a size cap; over-limit bodies fail the request. */
|
|
352
|
-
function readBody(req) {
|
|
374
|
+
function readBody$1(req) {
|
|
353
375
|
return new Promise((resolve, reject) => {
|
|
354
376
|
const chunks = [];
|
|
355
377
|
let size = 0;
|
|
@@ -453,7 +475,7 @@ function createWorkBuddyShim(options) {
|
|
|
453
475
|
writeOpenAIError(res, 401, "not_signed_in", String(error));
|
|
454
476
|
return;
|
|
455
477
|
}
|
|
456
|
-
const raw = (await readBody(req)).toString("utf8");
|
|
478
|
+
const raw = (await readBody$1(req)).toString("utf8");
|
|
457
479
|
const prepared = prepareChatBody(raw);
|
|
458
480
|
const controller = new AbortController();
|
|
459
481
|
req.on("close", () => controller.abort());
|
|
@@ -491,17 +513,317 @@ function createWorkBuddyShim(options) {
|
|
|
491
513
|
};
|
|
492
514
|
}
|
|
493
515
|
//#endregion
|
|
516
|
+
//#region src/probe-store.ts
|
|
517
|
+
/**
|
|
518
|
+
* Local record of reasoning-effort probes.
|
|
519
|
+
*
|
|
520
|
+
* What this stores is an *observation*, never a claim about the upstream: a
|
|
521
|
+
* model's row is only consulted when the catalog carries no explicit
|
|
522
|
+
* `supportedEfforts` set, and it always loses to a declared set. The plan this
|
|
523
|
+
* implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
|
|
524
|
+
* is invalidated whenever the model's catalog row changes, so every record
|
|
525
|
+
* carries a fingerprint of the fields the probe depended on.
|
|
526
|
+
*
|
|
527
|
+
* The file lives beside the plugin's own credential copy under `$DSH_HOME`,
|
|
528
|
+
* never in the desktop app's files, and carries no token, prompt, or response
|
|
529
|
+
* body — only model ids, effort spellings, and timestamps.
|
|
530
|
+
*
|
|
531
|
+
* @module dsh-workbuddy-connect/probe-store
|
|
532
|
+
*/
|
|
533
|
+
/** Basename of the probe record inside the Harness home. */
|
|
534
|
+
const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
|
|
535
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
536
|
+
const PROBE_FORMAT_VERSION = 1;
|
|
537
|
+
/**
|
|
538
|
+
* How long an observation stays usable. Conservative on purpose: the plan's
|
|
539
|
+
* whole argument is that upstream metadata moves fast, so a result that has
|
|
540
|
+
* outlived its fingerprint's usefulness should not quietly keep granting a
|
|
541
|
+
* picker entry.
|
|
542
|
+
*/
|
|
543
|
+
const DEFAULT_TTL_MS = 12096e5;
|
|
544
|
+
/** Plugin-owned probe record path inside the Harness home. */
|
|
545
|
+
function workbuddyProbePath() {
|
|
546
|
+
return join(resolveDshHome(), WORKBUDDY_PROBE_FILENAME);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Fingerprint the catalog fields a probe depends on.
|
|
550
|
+
*
|
|
551
|
+
* Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
|
|
552
|
+
* so a rename or a promo badge does not throw away a valid observation, and
|
|
553
|
+
* deliberately includes the whole reasoning object so any change to the
|
|
554
|
+
* declared shape re-probes.
|
|
555
|
+
*/
|
|
556
|
+
function fingerprintModel(info) {
|
|
557
|
+
const basis = JSON.stringify({
|
|
558
|
+
id: info.id,
|
|
559
|
+
reasoning: info.reasoning ?? null,
|
|
560
|
+
supportsImages: info.supportsImages ?? null
|
|
561
|
+
});
|
|
562
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
563
|
+
}
|
|
564
|
+
/** Read-and-validate the documents on disk; anything malformed reads as empty. */
|
|
565
|
+
function readDocument(path) {
|
|
566
|
+
if (!existsSync(path)) return void 0;
|
|
567
|
+
let parsed;
|
|
568
|
+
try {
|
|
569
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
570
|
+
} catch {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
574
|
+
const wrapped = parsed;
|
|
575
|
+
if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
|
|
576
|
+
const records = wrapped["records"];
|
|
577
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
|
|
578
|
+
return parsed;
|
|
579
|
+
}
|
|
580
|
+
/** One record's shape check; a bad row is dropped rather than trusted. */
|
|
581
|
+
function isRecord(value) {
|
|
582
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
583
|
+
const wrapped = value;
|
|
584
|
+
const validation = wrapped["validation"];
|
|
585
|
+
if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
|
|
586
|
+
if (typeof wrapped["fingerprint"] !== "string") return false;
|
|
587
|
+
if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
|
|
588
|
+
if (typeof wrapped["pluginVersion"] !== "string") return false;
|
|
589
|
+
const efforts = wrapped["efforts"];
|
|
590
|
+
if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The plugin's probe records: read once, written atomically, never trusted
|
|
595
|
+
* across a fingerprint change or past the TTL.
|
|
596
|
+
*/
|
|
597
|
+
var WorkBuddyProbeStore = class {
|
|
598
|
+
path;
|
|
599
|
+
ttlMs;
|
|
600
|
+
pluginVersion;
|
|
601
|
+
now;
|
|
602
|
+
records;
|
|
603
|
+
constructor(options) {
|
|
604
|
+
const opts = typeof options === "string" ? {
|
|
605
|
+
path: options,
|
|
606
|
+
pluginVersion: "0.0.0"
|
|
607
|
+
} : options;
|
|
608
|
+
this.path = opts.path ?? workbuddyProbePath();
|
|
609
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
610
|
+
this.pluginVersion = opts.pluginVersion;
|
|
611
|
+
this.now = opts.now ?? (() => Date.now());
|
|
612
|
+
}
|
|
613
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
614
|
+
filePath() {
|
|
615
|
+
return this.path;
|
|
616
|
+
}
|
|
617
|
+
load() {
|
|
618
|
+
if (this.records === void 0) {
|
|
619
|
+
const document = readDocument(this.path);
|
|
620
|
+
const records = {};
|
|
621
|
+
for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
|
|
622
|
+
this.records = records;
|
|
623
|
+
}
|
|
624
|
+
return this.records;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* The usable record for a model, or `undefined` when there is none, it is
|
|
628
|
+
* expired, or it was taken against a different catalog row.
|
|
629
|
+
*/
|
|
630
|
+
get(modelId, fingerprint) {
|
|
631
|
+
const record = this.load()[modelId];
|
|
632
|
+
if (record === void 0) return void 0;
|
|
633
|
+
if (record.fingerprint !== fingerprint) return void 0;
|
|
634
|
+
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
635
|
+
return record;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Store one observation. Only a decisive answer (`validating` /
|
|
639
|
+
* `non-validating`) replaces an existing decisive record: a transient
|
|
640
|
+
* `unknown` must not erase knowledge the user already paid for.
|
|
641
|
+
*/
|
|
642
|
+
set(modelId, record) {
|
|
643
|
+
const records = this.load();
|
|
644
|
+
const existing = records[modelId];
|
|
645
|
+
if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
|
|
646
|
+
records[modelId] = record;
|
|
647
|
+
this.persist();
|
|
648
|
+
}
|
|
649
|
+
/** Drop every record; used by the card's explicit "clear" action. */
|
|
650
|
+
clear() {
|
|
651
|
+
this.records = {};
|
|
652
|
+
this.persist();
|
|
653
|
+
}
|
|
654
|
+
/** Every record currently held, for status display. */
|
|
655
|
+
all() {
|
|
656
|
+
return { ...this.load() };
|
|
657
|
+
}
|
|
658
|
+
/** Build a record stamped with this store's clock and version. */
|
|
659
|
+
record(fingerprint, validation, efforts) {
|
|
660
|
+
return {
|
|
661
|
+
fingerprint,
|
|
662
|
+
validation,
|
|
663
|
+
efforts: validation === "validating" ? [...efforts] : [],
|
|
664
|
+
probedAtMs: this.now(),
|
|
665
|
+
pluginVersion: this.pluginVersion
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Write through a temporary file and rename, so a crash mid-write cannot
|
|
670
|
+
* leave a half-parsed document that reads as "no records" and silently drops
|
|
671
|
+
* every observation.
|
|
672
|
+
*/
|
|
673
|
+
persist() {
|
|
674
|
+
const directory = dirname(this.path);
|
|
675
|
+
try {
|
|
676
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
677
|
+
const document = {
|
|
678
|
+
version: PROBE_FORMAT_VERSION,
|
|
679
|
+
records: this.load()
|
|
680
|
+
};
|
|
681
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
682
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
683
|
+
renameSync(temporary, this.path);
|
|
684
|
+
} catch {}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* Order observations newest-first for display.
|
|
689
|
+
*
|
|
690
|
+
* The store keeps insertion order so the file reads chronologically, but the
|
|
691
|
+
* card wants the most recent detection at the top: a sweep the user just ran
|
|
692
|
+
* should not appear below every earlier one, which is what appending to an
|
|
693
|
+
* insertion-ordered list does.
|
|
694
|
+
*/
|
|
695
|
+
function newestFirst(records) {
|
|
696
|
+
return [...records].sort((a, b) => b.probedAt - a.probedAt);
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/probe-service.ts
|
|
700
|
+
/**
|
|
701
|
+
* Serial probe runner. One instance is shared by the manual API and any
|
|
702
|
+
* future automatic trigger, so the two can never overlap.
|
|
703
|
+
*/
|
|
704
|
+
var WorkBuddyProbeService = class {
|
|
705
|
+
options;
|
|
706
|
+
queue = Promise.resolve();
|
|
707
|
+
pending = /* @__PURE__ */ new Map();
|
|
708
|
+
running = false;
|
|
709
|
+
constructor(options) {
|
|
710
|
+
this.options = options;
|
|
711
|
+
}
|
|
712
|
+
/** Whether a sweep is in flight right now. */
|
|
713
|
+
isRunning() {
|
|
714
|
+
return this.running;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The record the adapter may use for this model, or `undefined`.
|
|
718
|
+
*
|
|
719
|
+
* Applies the plan's precedence (§5): a declared set always wins, so a model
|
|
720
|
+
* that declares `supportedEfforts` is never answered from an observation.
|
|
721
|
+
*/
|
|
722
|
+
recordFor(modelId) {
|
|
723
|
+
const info = this.options.catalog.current().find((model) => model.id === modelId);
|
|
724
|
+
if (info === void 0) return void 0;
|
|
725
|
+
if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
|
|
726
|
+
return this.options.store.get(modelId, fingerprintModel(info));
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Probe one model, serially.
|
|
730
|
+
*
|
|
731
|
+
* The authenticated manual route supplies one-request consent after UI
|
|
732
|
+
* confirmation. Other callers must pass the configured consent gate.
|
|
733
|
+
* Manual consent never changes the automatic-probing configuration.
|
|
734
|
+
* Explicit requests bypass historical results, but share an ongoing run.
|
|
735
|
+
*/
|
|
736
|
+
async probe(modelId, manualConsent = false) {
|
|
737
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
738
|
+
state: "unavailable",
|
|
739
|
+
reason: "probing is not authorized"
|
|
740
|
+
};
|
|
741
|
+
if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
|
|
742
|
+
state: "unavailable",
|
|
743
|
+
reason: `unknown model: ${modelId}`
|
|
744
|
+
};
|
|
745
|
+
const pending = this.pending.get(modelId);
|
|
746
|
+
if (pending !== void 0) return pending;
|
|
747
|
+
const run = this.queue.then(async () => {
|
|
748
|
+
const current = this.options.catalog.current().find((model) => model.id === modelId);
|
|
749
|
+
if (current === void 0) return {
|
|
750
|
+
state: "unavailable",
|
|
751
|
+
reason: `unknown model: ${modelId}`
|
|
752
|
+
};
|
|
753
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
754
|
+
state: "unavailable",
|
|
755
|
+
reason: "probing is not authorized"
|
|
756
|
+
};
|
|
757
|
+
if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
|
|
758
|
+
state: "unavailable",
|
|
759
|
+
reason: "model does not need detection"
|
|
760
|
+
};
|
|
761
|
+
const cached = this.recordFor(modelId);
|
|
762
|
+
if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
|
|
763
|
+
state: "ok",
|
|
764
|
+
validation: cached.validation,
|
|
765
|
+
efforts: cached.efforts,
|
|
766
|
+
requests: 0
|
|
767
|
+
};
|
|
768
|
+
const credential = await this.options.credentials.current();
|
|
769
|
+
if (credential === void 0) return {
|
|
770
|
+
state: "unavailable",
|
|
771
|
+
reason: "no WorkBuddy credential"
|
|
772
|
+
};
|
|
773
|
+
const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
|
|
774
|
+
this.running = true;
|
|
775
|
+
try {
|
|
776
|
+
const outcome = await probeModel({
|
|
777
|
+
send,
|
|
778
|
+
...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
|
|
779
|
+
});
|
|
780
|
+
const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts);
|
|
781
|
+
this.options.store.set(modelId, record);
|
|
782
|
+
if (outcome.validation === "unknown") return {
|
|
783
|
+
state: "unavailable",
|
|
784
|
+
reason: outcome.reason
|
|
785
|
+
};
|
|
786
|
+
return {
|
|
787
|
+
state: "ok",
|
|
788
|
+
validation: outcome.validation,
|
|
789
|
+
efforts: record.efforts,
|
|
790
|
+
requests: outcome.requests
|
|
791
|
+
};
|
|
792
|
+
} finally {
|
|
793
|
+
this.running = false;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
this.queue = run.catch(() => void 0);
|
|
797
|
+
this.pending.set(modelId, run);
|
|
798
|
+
try {
|
|
799
|
+
return await run;
|
|
800
|
+
} finally {
|
|
801
|
+
this.pending.delete(modelId);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
494
806
|
//#region src/status-paths.ts
|
|
495
807
|
/** Node-free constants and types shared by the Host and browser halves. */
|
|
496
808
|
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
497
809
|
const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
|
|
810
|
+
/**
|
|
811
|
+
* Plugin-owned probe control endpoint.
|
|
812
|
+
*
|
|
813
|
+
* Separate from the status route because it accepts writes: the status route's
|
|
814
|
+
* loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
|
|
815
|
+
* not the same as authorizing a state-changing action. This route therefore
|
|
816
|
+
* also requires the in-process key the browser half receives with the status
|
|
817
|
+
* document.
|
|
818
|
+
*/
|
|
819
|
+
const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
|
|
498
820
|
//#endregion
|
|
499
821
|
//#region src/web-status.ts
|
|
500
822
|
/** Redact token-like content before it crosses to the browser. */
|
|
501
823
|
function safeMessage(error) {
|
|
502
824
|
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);
|
|
503
825
|
}
|
|
504
|
-
function json(res, status, body) {
|
|
826
|
+
function json$1(res, status, body) {
|
|
505
827
|
const payload = JSON.stringify(body);
|
|
506
828
|
res.writeHead(status, {
|
|
507
829
|
"Content-Type": "application/json",
|
|
@@ -533,52 +855,58 @@ async function workBuddyWebStatus(deps) {
|
|
|
533
855
|
...authStatus.source === void 0 ? {} : { source: authStatus.source },
|
|
534
856
|
...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
|
|
535
857
|
};
|
|
536
|
-
const modelsField = deps.models().
|
|
858
|
+
const modelsField = deps.models().map((model) => {
|
|
537
859
|
const rate = normalizeCredits(model.billing?.credits);
|
|
538
860
|
return {
|
|
539
861
|
id: model.id,
|
|
540
862
|
name: model.name,
|
|
541
863
|
...model.billing?.free === true ? { free: true } : {},
|
|
542
864
|
...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
|
|
543
|
-
...rate === void 0 ? {} : { credits: rate }
|
|
865
|
+
...rate === void 0 ? {} : { credits: rate },
|
|
866
|
+
...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}
|
|
544
867
|
};
|
|
545
868
|
});
|
|
546
869
|
const statusWithModels = modelsField.length > 0 ? {
|
|
547
870
|
...status,
|
|
548
871
|
models: modelsField
|
|
549
872
|
} : status;
|
|
873
|
+
const probed = deps.probe === void 0 ? statusWithModels : {
|
|
874
|
+
...statusWithModels,
|
|
875
|
+
probe: deps.probe(),
|
|
876
|
+
...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey }
|
|
877
|
+
};
|
|
550
878
|
try {
|
|
551
879
|
const credential = await deps.store.current();
|
|
552
880
|
if (credential !== void 0) {
|
|
553
881
|
const credits = await deps.client.fetchCredits(credential);
|
|
554
882
|
return {
|
|
555
|
-
...
|
|
883
|
+
...probed,
|
|
556
884
|
credits
|
|
557
885
|
};
|
|
558
886
|
}
|
|
559
887
|
} catch (error) {
|
|
560
888
|
return {
|
|
561
|
-
...
|
|
889
|
+
...probed,
|
|
562
890
|
creditsError: safeMessage(error)
|
|
563
891
|
};
|
|
564
892
|
}
|
|
565
|
-
return
|
|
893
|
+
return probed;
|
|
566
894
|
}
|
|
567
895
|
/** The status route's request handler, extracted so tests can mount it on a bare server. */
|
|
568
896
|
function workBuddyStatusHandler(deps) {
|
|
569
897
|
return async (req, res) => {
|
|
570
898
|
if (req.method !== "GET") {
|
|
571
|
-
json(res, 405, { error: "method not allowed" });
|
|
899
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
572
900
|
return;
|
|
573
901
|
}
|
|
574
902
|
if (!loopbackRequest(req)) {
|
|
575
|
-
json(res, 403, { error: "request-not-trusted" });
|
|
903
|
+
json$1(res, 403, { error: "request-not-trusted" });
|
|
576
904
|
return;
|
|
577
905
|
}
|
|
578
906
|
try {
|
|
579
|
-
json(res, 200, await workBuddyWebStatus(deps));
|
|
907
|
+
json$1(res, 200, await workBuddyWebStatus(deps));
|
|
580
908
|
} catch (error) {
|
|
581
|
-
json(res, 500, { error: safeMessage(error) });
|
|
909
|
+
json$1(res, 500, { error: safeMessage(error) });
|
|
582
910
|
}
|
|
583
911
|
};
|
|
584
912
|
}
|
|
@@ -596,6 +924,136 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
596
924
|
}, "dsh-workbuddy-connect: Web status route");
|
|
597
925
|
}
|
|
598
926
|
//#endregion
|
|
927
|
+
//#region src/probe-route.ts
|
|
928
|
+
/**
|
|
929
|
+
* Probe control route: the only state-changing endpoint the plugin exposes.
|
|
930
|
+
*
|
|
931
|
+
* Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
|
|
932
|
+
* §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
|
|
933
|
+
*
|
|
934
|
+
* 1. **Loopback Host + Origin**, shared with the status route. This drops
|
|
935
|
+
* DNS-rebinding pages, whose requests arrive addressed to the attacker's
|
|
936
|
+
* domain.
|
|
937
|
+
* 2. **An in-process random key**, minted per process and handed only to the
|
|
938
|
+
* same-origin card. Loopback alone is *not* authentication — any local
|
|
939
|
+
* process can write `Host: 127.0.0.1` — so a route that spends the user's
|
|
940
|
+
* credit must prove the caller was told the key.
|
|
941
|
+
*
|
|
942
|
+
* The route never accepts a prompt, a model id outside the live catalog, or a
|
|
943
|
+
* sentinel from the browser: a probe request is assembled entirely host-side.
|
|
944
|
+
*
|
|
945
|
+
* @module dsh-workbuddy-connect/probe-route
|
|
946
|
+
*/
|
|
947
|
+
/** Largest control body accepted; these payloads are a few dozen bytes. */
|
|
948
|
+
const MAX_BODY_BYTES = 4096;
|
|
949
|
+
/** Mint the per-process control key. */
|
|
950
|
+
function createProbeKey() {
|
|
951
|
+
return randomBytes(24).toString("hex");
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Constant-time key comparison; a length mismatch is a failure, not a crash.
|
|
955
|
+
*/
|
|
956
|
+
function keyMatches(expected, presented) {
|
|
957
|
+
if (presented === void 0 || presented.length !== expected.length) return false;
|
|
958
|
+
const a = Buffer.from(expected);
|
|
959
|
+
const b = Buffer.from(presented);
|
|
960
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
961
|
+
}
|
|
962
|
+
function json(res, status, body) {
|
|
963
|
+
const payload = JSON.stringify(body);
|
|
964
|
+
res.writeHead(status, {
|
|
965
|
+
"Content-Type": "application/json",
|
|
966
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
967
|
+
});
|
|
968
|
+
res.end(payload);
|
|
969
|
+
}
|
|
970
|
+
/** Read the request body with a hard ceiling. */
|
|
971
|
+
async function readBody(req) {
|
|
972
|
+
const chunks = [];
|
|
973
|
+
let total = 0;
|
|
974
|
+
for await (const chunk of req) {
|
|
975
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
976
|
+
total += buffer.length;
|
|
977
|
+
if (total > MAX_BODY_BYTES) return void 0;
|
|
978
|
+
chunks.push(buffer);
|
|
979
|
+
}
|
|
980
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
981
|
+
}
|
|
982
|
+
/** Parse and shape-check an action; unknown fields are ignored, not trusted. */
|
|
983
|
+
function parseAction(text) {
|
|
984
|
+
let parsed;
|
|
985
|
+
try {
|
|
986
|
+
parsed = JSON.parse(text);
|
|
987
|
+
} catch {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
991
|
+
const wrapped = parsed;
|
|
992
|
+
const action = wrapped["action"];
|
|
993
|
+
if (action === "clear") return { action: "clear" };
|
|
994
|
+
if (action === "probe") {
|
|
995
|
+
const model = wrapped["model"];
|
|
996
|
+
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
997
|
+
return {
|
|
998
|
+
action: "probe",
|
|
999
|
+
model: model.trim()
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* The control route's handler, extracted so tests can mount it on a bare
|
|
1005
|
+
* server with a known key.
|
|
1006
|
+
*/
|
|
1007
|
+
function workBuddyProbeHandler(deps, key) {
|
|
1008
|
+
return async (req, res) => {
|
|
1009
|
+
if (req.method !== "POST") {
|
|
1010
|
+
json(res, 405, { error: "method not allowed" });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1014
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
|
|
1018
|
+
json(res, 403, { error: "invalid-probe-key" });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const body = await readBody(req);
|
|
1022
|
+
if (body === void 0) {
|
|
1023
|
+
json(res, 413, { error: "body too large" });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const action = parseAction(body);
|
|
1027
|
+
if (action === void 0) {
|
|
1028
|
+
json(res, 400, { error: "invalid action" });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
if (action.action === "clear") {
|
|
1033
|
+
deps.clear();
|
|
1034
|
+
json(res, 200, { state: "cleared" });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
json(res, 200, await deps.probe(action.model));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/** Mount the POST probe-control route on an optional webServer context. */
|
|
1044
|
+
function registerWorkBuddyProbeRoute(ctx, deps, key) {
|
|
1045
|
+
ctx.effect(() => {
|
|
1046
|
+
const dispose = ctx.webServer.register({
|
|
1047
|
+
kind: "exact",
|
|
1048
|
+
path: WORKBUDDY_PROBE_PATH,
|
|
1049
|
+
handler: workBuddyProbeHandler(deps, key)
|
|
1050
|
+
});
|
|
1051
|
+
return () => {
|
|
1052
|
+
dispose();
|
|
1053
|
+
};
|
|
1054
|
+
}, "dsh-workbuddy-connect: probe control route");
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
599
1057
|
//#region src/index.ts
|
|
600
1058
|
/** Stable Cordis plugin name. */
|
|
601
1059
|
const name = "llm-workbuddy";
|
|
@@ -614,7 +1072,10 @@ const inject = ["llm"];
|
|
|
614
1072
|
* their namespaces as plain string literals).
|
|
615
1073
|
*/
|
|
616
1074
|
const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
617
|
-
const Config = z.object({
|
|
1075
|
+
const Config = z.object({
|
|
1076
|
+
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
|
|
1077
|
+
probeConsent: z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)")
|
|
1078
|
+
});
|
|
618
1079
|
/**
|
|
619
1080
|
* Start the loopback endpoint, register the `workbuddy` provider, and
|
|
620
1081
|
* refresh the model catalog from the upstream once credentials allow it.
|
|
@@ -634,12 +1095,73 @@ function apply(ctx, config) {
|
|
|
634
1095
|
catalog,
|
|
635
1096
|
logger: ctx.logger
|
|
636
1097
|
});
|
|
637
|
-
ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
|
|
638
|
-
store,
|
|
639
|
-
client,
|
|
640
|
-
models: () => catalog.current()
|
|
641
|
-
}));
|
|
642
1098
|
let current = () => config;
|
|
1099
|
+
const probeStore = new WorkBuddyProbeStore({ pluginVersion: WORKBUDDY_CONNECT_VERSION });
|
|
1100
|
+
const probeService = new WorkBuddyProbeService({
|
|
1101
|
+
store: probeStore,
|
|
1102
|
+
catalog,
|
|
1103
|
+
credentials: store,
|
|
1104
|
+
client,
|
|
1105
|
+
consent: () => current().probeConsent === true
|
|
1106
|
+
});
|
|
1107
|
+
/**
|
|
1108
|
+
* Whether a model can be probed by hand: it reasons and the upstream declares
|
|
1109
|
+
* no effort set for it.
|
|
1110
|
+
*
|
|
1111
|
+
* Deliberately *not* filtered by whether a result already exists. Dropping a
|
|
1112
|
+
* model once it has been detected made the list shrink with use, so
|
|
1113
|
+
* re-detecting one model — after an upstream change, say — meant clearing
|
|
1114
|
+
* every other result first. The list stays stable and the card marks which
|
|
1115
|
+
* entries already have an answer.
|
|
1116
|
+
*/
|
|
1117
|
+
const isProbeCandidate = (info) => {
|
|
1118
|
+
if (info.reasoning?.supports !== true) return false;
|
|
1119
|
+
return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
|
|
1120
|
+
};
|
|
1121
|
+
/** Compact probe state for the card: consent, candidates, observations. */
|
|
1122
|
+
const probeSection = () => {
|
|
1123
|
+
const config = current();
|
|
1124
|
+
const models = catalog.current();
|
|
1125
|
+
const results = models.flatMap((info) => {
|
|
1126
|
+
const record = probeService.recordFor(info.id);
|
|
1127
|
+
if (record === void 0) return [];
|
|
1128
|
+
return [{
|
|
1129
|
+
id: info.id,
|
|
1130
|
+
name: info.name,
|
|
1131
|
+
validation: record.validation,
|
|
1132
|
+
efforts: record.efforts,
|
|
1133
|
+
probedAt: record.probedAtMs
|
|
1134
|
+
}];
|
|
1135
|
+
});
|
|
1136
|
+
return {
|
|
1137
|
+
consent: config.probeConsent === true,
|
|
1138
|
+
running: probeService.isRunning(),
|
|
1139
|
+
candidates: models.filter(isProbeCandidate).map((info) => info.id),
|
|
1140
|
+
results: newestFirst(results)
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
const probeKey = createProbeKey();
|
|
1144
|
+
let refreshProbeModels = () => {};
|
|
1145
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
1146
|
+
registerWorkBuddyStatusRoute(webCtx, {
|
|
1147
|
+
store,
|
|
1148
|
+
client,
|
|
1149
|
+
models: () => catalog.current(),
|
|
1150
|
+
probe: () => probeSection(),
|
|
1151
|
+
probeKey
|
|
1152
|
+
});
|
|
1153
|
+
registerWorkBuddyProbeRoute(webCtx, {
|
|
1154
|
+
probe: async (modelId) => {
|
|
1155
|
+
const result = await probeService.probe(modelId, true);
|
|
1156
|
+
if (result.state === "ok") refreshProbeModels();
|
|
1157
|
+
return result;
|
|
1158
|
+
},
|
|
1159
|
+
clear: () => {
|
|
1160
|
+
probeStore.clear();
|
|
1161
|
+
refreshProbeModels();
|
|
1162
|
+
}
|
|
1163
|
+
}, probeKey);
|
|
1164
|
+
});
|
|
643
1165
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
644
1166
|
settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
|
|
645
1167
|
setSource(source) {
|
|
@@ -665,9 +1187,15 @@ function apply(ctx, config) {
|
|
|
665
1187
|
shim,
|
|
666
1188
|
store,
|
|
667
1189
|
catalog,
|
|
668
|
-
resolveAttachments: () => ctx.get("attachments")
|
|
1190
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
1191
|
+
observe: (modelId) => probeService.recordFor(modelId)
|
|
669
1192
|
});
|
|
670
1193
|
invalidate = workbuddy.invalidate;
|
|
1194
|
+
refreshProbeModels = () => {
|
|
1195
|
+
if (stopped) return;
|
|
1196
|
+
workbuddy.invalidate();
|
|
1197
|
+
ctx.emit("llm/adapters-updated");
|
|
1198
|
+
};
|
|
671
1199
|
let releaseAdapter;
|
|
672
1200
|
let releaseDirectory;
|
|
673
1201
|
try {
|
|
@@ -716,4 +1244,4 @@ function apply(ctx, config) {
|
|
|
716
1244
|
});
|
|
717
1245
|
}
|
|
718
1246
|
//#endregion
|
|
719
|
-
export { Config, FALLBACK_WORKBUDDY_MODELS, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath };
|
|
1247
|
+
export { Config, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, fingerprintModel, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, probeModel, processStartTimeMs, randomSentinel, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };
|