dsh-workbuddy-bridge 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -54
- package/README_en.md +61 -63
- package/lib/bin.js +1 -1
- package/lib/client.js +671 -133
- package/lib/index.d.ts +175 -1
- package/lib/index.js +581 -86
- package/lib/{variants-DP0Ho0Tc.js → variants-3SmUZ0e_.js} +40 -18
- package/locale/README.md +10 -0
- package/package.json +40 -28
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as modelWithCurrentPromotion, B as CN_APP_VERSION_FILENAME, C as defaultDesktopAuthPath, D as WorkBuddyAtRestKeyProvider, E as workbuddyOwnAuthPath, F as regionOf, G as resolveChatIdentity, H as chatUserAgent, I as PROBE_EFFORT_CANDIDATES, J as appUserAgent, K as validCliVersion, L as probeModel, M as parseModelCatalog, N as prepareChatBody, O as cnAppDiscovery, P as prepareInternationalChatBody, Q as validAppVersion, R as randomSentinel, S as defaultDesktopAuthCandidates, T as parseWorkBuddyAuth, U as fallbackChatIdentity, V as FALLBACK_CN_APP_VERSION, W as readCliVersion, X as readBundleVersion, Y as installedAppVersion, Z as resolveAppVersion, _ as FALLBACK_WORKBUDDY_MODELS, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WORKBUDDY_AUTH_FILE_ENV, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_BRIDGE_VERSION, g as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as normalizeCredits, k as WorkBuddyUpstreamClient, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, p as WORKBUDDY_CREDIT_PATH, q as WORKBUDDY_APP_VERSION_FILENAME, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WorkBuddyCatalog, w as desktopAuthCandidatesFor, x as WorkBuddyCredentialStore, y as WORKBUDDY_AUTH_FILENAME, z as classifyUpstreamError } from "./variants-3SmUZ0e_.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
@@ -10,6 +10,7 @@ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
|
10
10
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
11
11
|
import { createServer } from "node:http";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
14
|
//#region src/catalog/store.ts
|
|
14
15
|
/**
|
|
15
16
|
* The last catalog that actually loaded, kept per variant and per account.
|
|
@@ -265,6 +266,44 @@ var WorkBuddyVisibilityStore = class {
|
|
|
265
266
|
}
|
|
266
267
|
};
|
|
267
268
|
//#endregion
|
|
269
|
+
//#region src/probe/efforts.ts
|
|
270
|
+
/** The resolution for a model that does not reason: no switch, nothing to detect. */
|
|
271
|
+
const NO_SWITCH = {
|
|
272
|
+
efforts: [],
|
|
273
|
+
source: "none",
|
|
274
|
+
detectable: false,
|
|
275
|
+
canDisable: false
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* Resolve one model's thinking-level switch.
|
|
279
|
+
*
|
|
280
|
+
* @param info - the catalog row, whose `reasoning` block carries any declaration.
|
|
281
|
+
* @param observed - this account's recorded detection for the model, if any.
|
|
282
|
+
*/
|
|
283
|
+
function resolveEfforts(info, observed) {
|
|
284
|
+
const reasoning = info.reasoning;
|
|
285
|
+
if (reasoning === void 0 || reasoning.supports !== true) return NO_SWITCH;
|
|
286
|
+
const declared = reasoning.supportedEfforts;
|
|
287
|
+
if (declared !== void 0 && declared.length > 0) return {
|
|
288
|
+
efforts: declared,
|
|
289
|
+
source: "declared",
|
|
290
|
+
detectable: false,
|
|
291
|
+
canDisable: reasoning.canDisableThinking === true
|
|
292
|
+
};
|
|
293
|
+
if (observed?.validation === "validating" && observed.efforts.length > 0) return {
|
|
294
|
+
efforts: observed.efforts,
|
|
295
|
+
source: "observed",
|
|
296
|
+
detectable: true,
|
|
297
|
+
canDisable: false
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
efforts: [],
|
|
301
|
+
source: "none",
|
|
302
|
+
detectable: true,
|
|
303
|
+
canDisable: false
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
268
307
|
//#region src/llm/adapter.ts
|
|
269
308
|
/**
|
|
270
309
|
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
@@ -361,44 +400,28 @@ function withCatalogDisplay(name, info) {
|
|
|
361
400
|
return suffix === void 0 ? name : `${name}${RATE_SEPARATOR}${suffix}`;
|
|
362
401
|
}
|
|
363
402
|
/**
|
|
364
|
-
*
|
|
403
|
+
* Translate a model's resolved thinking-level switch into pi-ai's
|
|
365
404
|
* `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
|
|
366
405
|
* unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
|
|
367
406
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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.
|
|
407
|
+
* The *decision* of which levels a model offers is not made here — it lives in
|
|
408
|
+
* {@link resolveEfforts}, which the status document reads too. That sharing is
|
|
409
|
+
* the point: the composer's bulb is lit from the same answer the picker is
|
|
410
|
+
* built from, so a model cannot show a control in one place and none in the
|
|
411
|
+
* other. This function only reshapes that answer for pi-ai.
|
|
387
412
|
*
|
|
388
413
|
* The offered set is described internally as "verified accepted", never as
|
|
389
414
|
* "verified effective": acceptance proves the upstream did not reject the
|
|
390
415
|
* spelling, not that it changes what the model does.
|
|
391
416
|
*/
|
|
392
417
|
function reasoningFields(info, observed) {
|
|
393
|
-
const
|
|
394
|
-
if (
|
|
395
|
-
const
|
|
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 };
|
|
418
|
+
const resolved = resolveEfforts(info, observed);
|
|
419
|
+
if (resolved.efforts.length === 0) return { reasoning: false };
|
|
420
|
+
const efforts = resolved.efforts;
|
|
398
421
|
return {
|
|
399
422
|
reasoning: true,
|
|
400
423
|
thinkingLevelMap: {
|
|
401
|
-
off:
|
|
424
|
+
off: resolved.canDisable ? "off" : null,
|
|
402
425
|
minimal: null,
|
|
403
426
|
low: efforts.includes("low") ? "low" : null,
|
|
404
427
|
medium: efforts.includes("medium") ? "medium" : null,
|
|
@@ -604,6 +627,67 @@ function originIsLoopback(origin) {
|
|
|
604
627
|
* @module dsh-workbuddy-bridge/shim
|
|
605
628
|
*/
|
|
606
629
|
const REQUEST_BODY_LIMIT = 67108864;
|
|
630
|
+
/**
|
|
631
|
+
* Pull the response id and `credit` out of one SSE `data:` payload.
|
|
632
|
+
*
|
|
633
|
+
* WorkBuddy's OpenAI-compatible stream reports what a message cost as an extra
|
|
634
|
+
* `credit` member beside the standard token buckets on the final usage frame
|
|
635
|
+
* (measured: `glm-5.3` answering 900 tokens reported `credit: 0.51`). The
|
|
636
|
+
* member is not part of the OpenAI schema, so it is read defensively off the
|
|
637
|
+
* parsed object rather than through any typed shape, and every frame without a
|
|
638
|
+
* usable number is skipped.
|
|
639
|
+
*
|
|
640
|
+
* @param payload - the text after `data: ` on one frame.
|
|
641
|
+
* @returns the response id and credit, or undefined when this frame carries none.
|
|
642
|
+
*/
|
|
643
|
+
function readCreditFrame(payload) {
|
|
644
|
+
if (payload === "" || payload.startsWith("[DONE]")) return void 0;
|
|
645
|
+
let parsed;
|
|
646
|
+
try {
|
|
647
|
+
parsed = JSON.parse(payload);
|
|
648
|
+
} catch {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
652
|
+
const frame = parsed;
|
|
653
|
+
const id = typeof frame["id"] === "string" ? frame["id"] : "";
|
|
654
|
+
if (id === "") return void 0;
|
|
655
|
+
const usage = frame["usage"];
|
|
656
|
+
if (typeof usage !== "object" || usage === null || Array.isArray(usage)) return void 0;
|
|
657
|
+
const credit = usage["credit"];
|
|
658
|
+
if (typeof credit !== "number" || !Number.isFinite(credit) || credit < 0) return void 0;
|
|
659
|
+
return {
|
|
660
|
+
id,
|
|
661
|
+
credit
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Split a raw chunk into complete SSE `data:` payloads, carrying the trailing
|
|
666
|
+
* partial line into the next chunk.
|
|
667
|
+
*
|
|
668
|
+
* A TCP read boundary lands wherever it likes — mid-JSON, mid-line — so
|
|
669
|
+
* scanning each chunk on its own would parse a truncated object and drop the
|
|
670
|
+
* very frame that carries the cost. The remainder is returned for the caller to
|
|
671
|
+
* prepend.
|
|
672
|
+
*
|
|
673
|
+
* @param carry - bytes left over from the previous chunk.
|
|
674
|
+
* @param chunk - the bytes just read.
|
|
675
|
+
* @returns the complete payloads, and the new carry.
|
|
676
|
+
*/
|
|
677
|
+
function readCreditChunk(carry, chunk) {
|
|
678
|
+
const lines = (carry + chunk).split("\n");
|
|
679
|
+
const carryOver = lines.pop() ?? "";
|
|
680
|
+
const payloads = [];
|
|
681
|
+
for (const line of lines) {
|
|
682
|
+
const trimmed = line.trimEnd();
|
|
683
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
684
|
+
payloads.push(trimmed.slice(5).trim());
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
payloads,
|
|
688
|
+
carry: carryOver
|
|
689
|
+
};
|
|
690
|
+
}
|
|
607
691
|
/** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
|
|
608
692
|
function isJsonContentType(req) {
|
|
609
693
|
const type = req.headers["content-type"];
|
|
@@ -656,7 +740,7 @@ function readBody$1(req) {
|
|
|
656
740
|
* is the boundary, and the upstream credential comes from the store alone.
|
|
657
741
|
*/
|
|
658
742
|
function createWorkBuddyShim(options) {
|
|
659
|
-
const { store, client, catalog } = options;
|
|
743
|
+
const { store, client, catalog, observeCredit } = options;
|
|
660
744
|
const logger = options.logger;
|
|
661
745
|
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
662
746
|
/** Constant-time bearer check; absent or mismatched bearers are rejected. */
|
|
@@ -750,18 +834,50 @@ function createWorkBuddyShim(options) {
|
|
|
750
834
|
res.writeHead(200, {
|
|
751
835
|
"Content-Type": "text/event-stream",
|
|
752
836
|
"Cache-Control": "no-cache",
|
|
753
|
-
|
|
837
|
+
Connection: "keep-alive",
|
|
754
838
|
"X-Accel-Buffering": "no"
|
|
755
839
|
});
|
|
756
840
|
let sawDone = false;
|
|
841
|
+
/**
|
|
842
|
+
* Bytes of the frame currently being read.
|
|
843
|
+
*
|
|
844
|
+
* The cost arrives on the last usage frame, and that frame is exactly the
|
|
845
|
+
* one a TCP read boundary is most likely to split: parsing each chunk in
|
|
846
|
+
* isolation would drop it. Complete lines are accumulated into `carry` and
|
|
847
|
+
* only whole `data:` payloads are inspected.
|
|
848
|
+
*/
|
|
849
|
+
let carry = "";
|
|
850
|
+
/** The last cost observed for this response; reported once, at the end. */
|
|
851
|
+
let observed;
|
|
852
|
+
/**
|
|
853
|
+
* Decodes the byte stream without splitting a multi-byte character across
|
|
854
|
+
* chunks.
|
|
855
|
+
*
|
|
856
|
+
* `chunk.toString('utf8')` would turn a character straddling a read
|
|
857
|
+
* boundary into U+FFFD, and since answers routinely contain Chinese that is
|
|
858
|
+
* not hypothetical. A mangled frame fails `JSON.parse` and is dropped —
|
|
859
|
+
* which for the frame carrying the cost would mean a message silently
|
|
860
|
+
* losing its label.
|
|
861
|
+
*/
|
|
862
|
+
const decoder = new StringDecoder("utf8");
|
|
757
863
|
const body = Readable.fromWeb(result.response.body);
|
|
758
864
|
body.on("data", (chunk) => {
|
|
759
865
|
if (chunk.includes("[DONE]")) sawDone = true;
|
|
866
|
+
if (observeCredit === void 0) return;
|
|
867
|
+
const read = readCreditChunk(carry, decoder.write(chunk));
|
|
868
|
+
carry = read.carry;
|
|
869
|
+
for (const payload of read.payloads) {
|
|
870
|
+
const frame = readCreditFrame(payload);
|
|
871
|
+
if (frame !== void 0) observed = frame;
|
|
872
|
+
}
|
|
760
873
|
});
|
|
761
874
|
body.on("error", (error) => {
|
|
762
875
|
logger?.warn("dsh-workbuddy-bridge: upstream stream failed mid-flight", error);
|
|
763
876
|
if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
|
|
764
877
|
});
|
|
878
|
+
body.on("end", () => {
|
|
879
|
+
if (observeCredit !== void 0 && observed !== void 0) observeCredit(observed.id, observed.credit);
|
|
880
|
+
});
|
|
765
881
|
body.pipe(res);
|
|
766
882
|
}
|
|
767
883
|
return {
|
|
@@ -776,16 +892,226 @@ function createWorkBuddyShim(options) {
|
|
|
776
892
|
};
|
|
777
893
|
}
|
|
778
894
|
//#endregion
|
|
895
|
+
//#region src/llm/credit-log.ts
|
|
896
|
+
/**
|
|
897
|
+
* Per-message credit accounting, as two plain registries the Host half fills.
|
|
898
|
+
*
|
|
899
|
+
* WorkBuddy reports what a message actually cost as a `credit` field on the
|
|
900
|
+
* last usage frame of its OpenAI-compatible SSE stream. That number is
|
|
901
|
+
* **server-supplied**: the desktop app accumulates `cost.amount` stage
|
|
902
|
+
* increments and never multiplies tokens by the catalog's `x0.79` multiplier
|
|
903
|
+
* (the multiplier is display copy). So the plugin must not invent a formula —
|
|
904
|
+
* it observes the number where it exists.
|
|
905
|
+
*
|
|
906
|
+
* The field travels on the stream the shim already proxies, and the same
|
|
907
|
+
* stream carries the upstream response id (`cmb-…`) that DSH records on the
|
|
908
|
+
* assistant message as `source.replayState.response.responseId`. That pair is
|
|
909
|
+
* what makes attribution exact rather than guessed:
|
|
910
|
+
*
|
|
911
|
+
* ```text
|
|
912
|
+
* SSE id + SSE usage.credit ──► creditLog.record(id, credit)
|
|
913
|
+
* │
|
|
914
|
+
* session/event assistant/message ─────┴──► index.set(sessionId, messageId, credit)
|
|
915
|
+
* (messageId, responseId) │
|
|
916
|
+
* browser row fetches by messageId
|
|
917
|
+
* ```
|
|
918
|
+
*
|
|
919
|
+
* Both structures are pure data with no Host dependencies, so the matching
|
|
920
|
+
* rules are unit-testable without a Session, a socket, or an upstream.
|
|
921
|
+
*
|
|
922
|
+
* @module dsh-workbuddy-bridge/credit-log
|
|
923
|
+
*/
|
|
924
|
+
/** How many response ids one log remembers before dropping the oldest. */
|
|
925
|
+
const DEFAULT_LOG_LIMIT = 512;
|
|
926
|
+
/**
|
|
927
|
+
* Bounded `responseId → credit` registry, with one-shot waiters.
|
|
928
|
+
*
|
|
929
|
+
* The waiter exists because the two writes race in principle: the shim sees the
|
|
930
|
+
* credit when the last SSE frame arrives, while the Session event that names
|
|
931
|
+
* the response id is appended when the stream settles. Either order must
|
|
932
|
+
* produce the same attribution, so a lookup that misses registers interest and
|
|
933
|
+
* is answered by the next `record` instead of silently reporting "unknown".
|
|
934
|
+
*/
|
|
935
|
+
var WorkBuddyCreditLog = class {
|
|
936
|
+
limit;
|
|
937
|
+
/**
|
|
938
|
+
* Insertion-ordered, so eviction is FIFO. A `Map` is used rather than an
|
|
939
|
+
* object because the response id is attacker-influenced text: a plain object
|
|
940
|
+
* would let `__proto__` reach the prototype chain.
|
|
941
|
+
*/
|
|
942
|
+
credits = /* @__PURE__ */ new Map();
|
|
943
|
+
/** Pending attributions, keyed by response id; each resolves at most once. */
|
|
944
|
+
waiters = /* @__PURE__ */ new Map();
|
|
945
|
+
constructor(limit = DEFAULT_LOG_LIMIT) {
|
|
946
|
+
this.limit = limit;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Remember what one response cost.
|
|
950
|
+
*
|
|
951
|
+
* A non-finite or negative value is refused rather than stored: the field is
|
|
952
|
+
* remote input, and a `NaN` reaching the browser would render as `NaN` in the
|
|
953
|
+
* credit label instead of being reported as unknown.
|
|
954
|
+
*/
|
|
955
|
+
record(responseId, credit) {
|
|
956
|
+
if (responseId === "") return;
|
|
957
|
+
if (!Number.isFinite(credit) || credit < 0) return;
|
|
958
|
+
this.credits.delete(responseId);
|
|
959
|
+
this.credits.set(responseId, credit);
|
|
960
|
+
this.evict();
|
|
961
|
+
const waiter = this.waiters.get(responseId);
|
|
962
|
+
if (waiter !== void 0) {
|
|
963
|
+
this.waiters.delete(responseId);
|
|
964
|
+
waiter(credit);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** What one response cost, or undefined when it was never observed. */
|
|
968
|
+
lookup(responseId) {
|
|
969
|
+
return this.credits.get(responseId);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Call `onCredit` once, when this response's credit becomes known.
|
|
973
|
+
*
|
|
974
|
+
* @returns a canceller; calling it after the credit landed is a no-op.
|
|
975
|
+
*/
|
|
976
|
+
await(responseId, onCredit) {
|
|
977
|
+
const known = this.credits.get(responseId);
|
|
978
|
+
if (known !== void 0) {
|
|
979
|
+
onCredit(known);
|
|
980
|
+
return () => {};
|
|
981
|
+
}
|
|
982
|
+
this.waiters.set(responseId, onCredit);
|
|
983
|
+
return () => {
|
|
984
|
+
if (this.waiters.get(responseId) === onCredit) this.waiters.delete(responseId);
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
/** Drop the oldest entries until the log is inside its bound. */
|
|
988
|
+
evict() {
|
|
989
|
+
while (this.credits.size > this.limit) {
|
|
990
|
+
const oldest = this.credits.keys().next();
|
|
991
|
+
if (oldest.done === true) return;
|
|
992
|
+
this.credits.delete(oldest.value);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
/**
|
|
997
|
+
* `sessionId → messageId → credit`, the shape the browser row reads.
|
|
998
|
+
*
|
|
999
|
+
* Nested rather than a flat `messageId → credit` map so the route can answer
|
|
1000
|
+
* one Session without shipping every Session's accounting, and so a Session's
|
|
1001
|
+
* rows can be dropped as a unit.
|
|
1002
|
+
*/
|
|
1003
|
+
var WorkBuddySessionCreditIndex = class {
|
|
1004
|
+
perSessionLimit;
|
|
1005
|
+
/** Per-Session maps, each bounded independently. */
|
|
1006
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1007
|
+
constructor(perSessionLimit = DEFAULT_LOG_LIMIT) {
|
|
1008
|
+
this.perSessionLimit = perSessionLimit;
|
|
1009
|
+
}
|
|
1010
|
+
/** Attribute one message's cost, refreshing its position in its Session. */
|
|
1011
|
+
set(sessionId, messageId, credit) {
|
|
1012
|
+
if (sessionId === "" || messageId === "") return;
|
|
1013
|
+
if (!Number.isFinite(credit) || credit < 0) return;
|
|
1014
|
+
let messages = this.sessions.get(sessionId);
|
|
1015
|
+
if (messages === void 0) {
|
|
1016
|
+
messages = /* @__PURE__ */ new Map();
|
|
1017
|
+
this.sessions.set(sessionId, messages);
|
|
1018
|
+
}
|
|
1019
|
+
messages.delete(messageId);
|
|
1020
|
+
messages.set(messageId, credit);
|
|
1021
|
+
while (messages.size > this.perSessionLimit) {
|
|
1022
|
+
const oldest = messages.keys().next();
|
|
1023
|
+
if (oldest.done === true) break;
|
|
1024
|
+
messages.delete(oldest.value);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* One Session's accounting as a plain object, or an empty object when the
|
|
1029
|
+
* Session has none. Always an object, never undefined: the browser half then
|
|
1030
|
+
* needs no "no data yet" branch distinct from "no credits recorded".
|
|
1031
|
+
*/
|
|
1032
|
+
forSession(sessionId) {
|
|
1033
|
+
const messages = this.sessions.get(sessionId);
|
|
1034
|
+
if (messages === void 0) return {};
|
|
1035
|
+
return Object.fromEntries(messages);
|
|
1036
|
+
}
|
|
1037
|
+
/** Forget one Session's accounting, e.g. when it leaves the store. */
|
|
1038
|
+
delete(sessionId) {
|
|
1039
|
+
this.sessions.delete(sessionId);
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
/**
|
|
1043
|
+
* Read the upstream response id out of an assistant message's replay state.
|
|
1044
|
+
*
|
|
1045
|
+
* `replayState` is declared `unknown` on the LLM seam on purpose: it is
|
|
1046
|
+
* adapter-private state that only the owning adapter may interpret. This walks
|
|
1047
|
+
* it structurally and gives up quietly on anything unexpected, reading only the
|
|
1048
|
+
* pi-ai envelope's `response.responseId` and only when it is a non-empty string.
|
|
1049
|
+
*
|
|
1050
|
+
* A missing id is the normal case for a non-WorkBuddy provider and for a
|
|
1051
|
+
* message assembled from a stream that never carried one. It means "cannot
|
|
1052
|
+
* attribute", never "costs nothing" -- which is why every caller treats
|
|
1053
|
+
* `undefined` as a reason to do nothing rather than to record a zero.
|
|
1054
|
+
*
|
|
1055
|
+
* @param replayState - the message source's adapter-private state.
|
|
1056
|
+
* @returns the upstream response id, or undefined when there is none to read.
|
|
1057
|
+
*/
|
|
1058
|
+
function responseIdOf(replayState) {
|
|
1059
|
+
if (typeof replayState !== "object" || replayState === null) return void 0;
|
|
1060
|
+
const response = replayState["response"];
|
|
1061
|
+
if (typeof response !== "object" || response === null) return void 0;
|
|
1062
|
+
const responseId = response["responseId"];
|
|
1063
|
+
return typeof responseId === "string" && responseId !== "" ? responseId : void 0;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Attribute one observed response cost to the message that caused it.
|
|
1067
|
+
*
|
|
1068
|
+
* The two halves of the key are produced by different layers and in either
|
|
1069
|
+
* order, so this is a join rather than a straight read:
|
|
1070
|
+
*
|
|
1071
|
+
* - the **shim** sees `usage.credit` on the stream and knows the upstream
|
|
1072
|
+
* response id, but nothing about DSH's message ids;
|
|
1073
|
+
* - the **session log** records the assistant message with its DSH `messageId`
|
|
1074
|
+
* and, inside `source.replayState.response.responseId`, that same id.
|
|
1075
|
+
*
|
|
1076
|
+
* The event normally lands *after* the stream's final frame (the message is
|
|
1077
|
+
* appended once the stream settles), so the log usually answers immediately.
|
|
1078
|
+
* The waiter covers the opposite interleaving instead of leaving that message
|
|
1079
|
+
* permanently unlabelled.
|
|
1080
|
+
*
|
|
1081
|
+
* @param log - the variant's response-id to credit registry.
|
|
1082
|
+
* @param index - the shared session to message to credit index.
|
|
1083
|
+
* @param sessionId - the Session the event came from.
|
|
1084
|
+
* @param data - the event's data payload, read structurally.
|
|
1085
|
+
* @returns whether an attribution was made or armed.
|
|
1086
|
+
*/
|
|
1087
|
+
function attributeCredit(log, index, sessionId, data) {
|
|
1088
|
+
const message = data.message;
|
|
1089
|
+
const messageId = message?.id;
|
|
1090
|
+
if (typeof messageId !== "string" || messageId === "") return false;
|
|
1091
|
+
const responseId = responseIdOf(message?.source?.replayState);
|
|
1092
|
+
if (responseId === void 0) return false;
|
|
1093
|
+
const known = log.lookup(responseId);
|
|
1094
|
+
if (known !== void 0) {
|
|
1095
|
+
index.set(sessionId, messageId, known);
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
log.await(responseId, (credit) => {
|
|
1099
|
+
index.set(sessionId, messageId, credit);
|
|
1100
|
+
});
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
//#endregion
|
|
779
1104
|
//#region src/probe/store.ts
|
|
780
1105
|
/**
|
|
781
1106
|
* Local record of reasoning-effort probes.
|
|
782
1107
|
*
|
|
783
1108
|
* What this stores is an *observation*, never a claim about the upstream: a
|
|
784
1109
|
* model's row is only consulted when the catalog carries no explicit
|
|
785
|
-
* `supportedEfforts` set, and it always loses to a declared set.
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
1110
|
+
* `supportedEfforts` set, and it always loses to a declared set. Every record
|
|
1111
|
+
* carries a fingerprint of the fields the probe depended on, so a result is
|
|
1112
|
+
* invalidated whenever the model's catalog row changes — the precedence rules
|
|
1113
|
+
* are asserted by `tests/probe-store.spec.ts`, and the fingerprint's own input
|
|
1114
|
+
* key order is a compatibility contract (`docs/ARCHITECTURE.md`).
|
|
789
1115
|
*
|
|
790
1116
|
* The file lives beside the plugin's own credential copy under `$DSH_HOME`,
|
|
791
1117
|
* never in the desktop app's files, and carries no token, prompt, or response
|
|
@@ -980,17 +1306,6 @@ var WorkBuddyProbeStore = class {
|
|
|
980
1306
|
} catch {}
|
|
981
1307
|
}
|
|
982
1308
|
};
|
|
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
1309
|
//#endregion
|
|
995
1310
|
//#region src/probe/service.ts
|
|
996
1311
|
/**
|
|
@@ -1120,7 +1435,7 @@ var WorkBuddyProbeService = class {
|
|
|
1120
1435
|
function safeMessage(error) {
|
|
1121
1436
|
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
1437
|
}
|
|
1123
|
-
function json$
|
|
1438
|
+
function json$2(res, status, body) {
|
|
1124
1439
|
const payload = JSON.stringify(body);
|
|
1125
1440
|
res.writeHead(status, {
|
|
1126
1441
|
"Content-Type": "application/json",
|
|
@@ -1134,7 +1449,7 @@ function json$1(res, status, body) {
|
|
|
1134
1449
|
* DNS-rebinding pages (their Host is the attacker's domain, not loopback);
|
|
1135
1450
|
* the card's same-origin fetches carry no Origin and pass on Host alone.
|
|
1136
1451
|
*/
|
|
1137
|
-
function loopbackRequest(req) {
|
|
1452
|
+
function loopbackRequest$1(req) {
|
|
1138
1453
|
return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
|
|
1139
1454
|
}
|
|
1140
1455
|
/**
|
|
@@ -1219,17 +1534,17 @@ async function workBuddyWebStatus(deps) {
|
|
|
1219
1534
|
function workBuddyStatusHandler(deps) {
|
|
1220
1535
|
return async (req, res) => {
|
|
1221
1536
|
if (req.method !== "GET") {
|
|
1222
|
-
json$
|
|
1537
|
+
json$2(res, 405, { error: "method not allowed" });
|
|
1223
1538
|
return;
|
|
1224
1539
|
}
|
|
1225
|
-
if (!loopbackRequest(req)) {
|
|
1226
|
-
json$
|
|
1540
|
+
if (!loopbackRequest$1(req)) {
|
|
1541
|
+
json$2(res, 403, { error: "request-not-trusted" });
|
|
1227
1542
|
return;
|
|
1228
1543
|
}
|
|
1229
1544
|
try {
|
|
1230
|
-
json$
|
|
1545
|
+
json$2(res, 200, await workBuddyWebStatus(deps));
|
|
1231
1546
|
} catch (error) {
|
|
1232
|
-
json$
|
|
1547
|
+
json$2(res, 500, { error: safeMessage(error) });
|
|
1233
1548
|
}
|
|
1234
1549
|
};
|
|
1235
1550
|
}
|
|
@@ -1248,12 +1563,79 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1248
1563
|
}, "dsh-workbuddy-bridge: Web status route");
|
|
1249
1564
|
}
|
|
1250
1565
|
//#endregion
|
|
1566
|
+
//#region src/web/credit.ts
|
|
1567
|
+
/**
|
|
1568
|
+
* The request must be addressed to the loopback interface, and a
|
|
1569
|
+
* browser-attached Origin must be loopback too. Identical to the status route's
|
|
1570
|
+
* guard: the Host check drops DNS-rebinding pages, and the card's same-origin
|
|
1571
|
+
* fetches carry no Origin and pass on Host alone.
|
|
1572
|
+
*/
|
|
1573
|
+
function loopbackRequest(req) {
|
|
1574
|
+
return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
|
|
1575
|
+
}
|
|
1576
|
+
function json$1(res, status, body) {
|
|
1577
|
+
const payload = JSON.stringify(body);
|
|
1578
|
+
res.writeHead(status, {
|
|
1579
|
+
"Content-Type": "application/json",
|
|
1580
|
+
"Cache-Control": "no-store",
|
|
1581
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1582
|
+
});
|
|
1583
|
+
res.end(payload);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* One Session's accounting.
|
|
1587
|
+
*
|
|
1588
|
+
* A missing `sessionId` is answered with an empty document rather than an
|
|
1589
|
+
* error: the browser half asks on behalf of whatever Session it is rendering,
|
|
1590
|
+
* and "this Session has no recorded costs" is a normal, silent state — the row
|
|
1591
|
+
* simply shows no label.
|
|
1592
|
+
*
|
|
1593
|
+
* @param deps - the accounting index.
|
|
1594
|
+
* @param sessionId - the Session to answer for.
|
|
1595
|
+
* @returns the document the browser half renders from.
|
|
1596
|
+
*/
|
|
1597
|
+
function workBuddySessionCredits(deps, sessionId) {
|
|
1598
|
+
return {
|
|
1599
|
+
sessionId,
|
|
1600
|
+
credits: deps.credits.forSession(sessionId)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
/** The credit route's request handler, extracted so tests can mount it bare. */
|
|
1604
|
+
function workBuddyCreditHandler(deps) {
|
|
1605
|
+
return async (req, res) => {
|
|
1606
|
+
if (req.method !== "GET") {
|
|
1607
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (!loopbackRequest(req)) {
|
|
1611
|
+
json$1(res, 403, { error: "request-not-trusted" });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
json$1(res, 200, workBuddySessionCredits(deps, new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("sessionId") ?? ""));
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/** Mount the GET credit route on an optional webServer context. */
|
|
1618
|
+
function registerWorkBuddyCreditRoute(ctx, deps) {
|
|
1619
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-bridge/credit";
|
|
1620
|
+
ctx.effect(() => {
|
|
1621
|
+
const dispose = ctx.webServer.register({
|
|
1622
|
+
kind: "exact",
|
|
1623
|
+
path,
|
|
1624
|
+
handler: workBuddyCreditHandler(deps)
|
|
1625
|
+
});
|
|
1626
|
+
return () => {
|
|
1627
|
+
dispose();
|
|
1628
|
+
};
|
|
1629
|
+
}, "dsh-workbuddy-bridge: Web credit route");
|
|
1630
|
+
}
|
|
1631
|
+
//#endregion
|
|
1251
1632
|
//#region src/web/probe-route.ts
|
|
1252
1633
|
/**
|
|
1253
1634
|
* Probe control route: the only state-changing endpoint the plugin exposes.
|
|
1254
1635
|
*
|
|
1255
|
-
* Two guards, because they stop different things (
|
|
1256
|
-
*
|
|
1636
|
+
* Two guards, because they stop different things (each is pinned by its own
|
|
1637
|
+
* spec — `tests/loopback.spec.ts` for the first, `tests/probe-route.spec.ts`
|
|
1638
|
+
* for both together):
|
|
1257
1639
|
*
|
|
1258
1640
|
* 1. **Loopback Host + Origin**, shared with the status route. This drops
|
|
1259
1641
|
* DNS-rebinding pages, whose requests arrive addressed to the attacker's
|
|
@@ -1531,7 +1913,7 @@ function fallbackFor(variant) {
|
|
|
1531
1913
|
return variant.id === CN_VARIANT.id ? FALLBACK_WORKBUDDY_MODELS : FALLBACK_WORKBUDDY_AI_MODELS;
|
|
1532
1914
|
}
|
|
1533
1915
|
/** Build one variant's stores and probe state. */
|
|
1534
|
-
function createVariantRuntime(config, variant, identityOf, accountOf, keyProvider) {
|
|
1916
|
+
function createVariantRuntime(config, variant, identityOf, accountOf, keyProvider, creditIndex) {
|
|
1535
1917
|
const client = new WorkBuddyUpstreamClient();
|
|
1536
1918
|
const configured = configuredAuthFile(config, variant);
|
|
1537
1919
|
const store = new WorkBuddyCredentialStore({
|
|
@@ -1564,6 +1946,8 @@ function createVariantRuntime(config, variant, identityOf, accountOf, keyProvide
|
|
|
1564
1946
|
consent: () => config.probeConsent,
|
|
1565
1947
|
account: () => identityOf(variant.id)
|
|
1566
1948
|
}),
|
|
1949
|
+
creditLog: new WorkBuddyCreditLog(),
|
|
1950
|
+
creditIndex,
|
|
1567
1951
|
savedCatalogs,
|
|
1568
1952
|
visibilityStore,
|
|
1569
1953
|
account: () => accountOf(variant.id),
|
|
@@ -1589,41 +1973,57 @@ function catalogSection(runtime) {
|
|
|
1589
1973
|
};
|
|
1590
1974
|
}
|
|
1591
1975
|
/**
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1976
|
+
* One row per reasoning model, describing its thinking-level switch.
|
|
1977
|
+
*
|
|
1978
|
+
* The judgement comes from {@link resolveEfforts} — the *same* function the
|
|
1979
|
+
* adapter builds the model picker from — so the composer's bulb and the picker
|
|
1980
|
+
* cannot describe different models. That sharing is the fix for what used to be
|
|
1981
|
+
* two independent answers: a candidate list ("who could be probed") beside a
|
|
1982
|
+
* result list ("who has been probed"), which between them told the user nothing
|
|
1983
|
+
* about the only question they had, namely which models let them pick a level.
|
|
1984
|
+
*
|
|
1985
|
+
* Read through `recordFor` rather than straight from the store, so a record the
|
|
1986
|
+
* adapter already discounts — its catalog row changed, it aged past the TTL, or
|
|
1987
|
+
* the upstream has since declared a set — is discounted here too. A model the
|
|
1988
|
+
* upstream dropped leaves the catalog entirely and drops out of this list.
|
|
1600
1989
|
*/
|
|
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
1990
|
function probeSection(runtime, consent) {
|
|
1607
|
-
const models = runtime.catalog.current()
|
|
1608
|
-
|
|
1991
|
+
const models = runtime.catalog.current().flatMap((info) => {
|
|
1992
|
+
if (info.reasoning?.supports !== true) return [];
|
|
1609
1993
|
const record = runtime.probeService.recordFor(info.id);
|
|
1610
|
-
|
|
1994
|
+
const resolved = resolveEfforts(info, record);
|
|
1611
1995
|
return [{
|
|
1612
1996
|
id: info.id,
|
|
1613
1997
|
name: info.name,
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1998
|
+
efforts: resolved.efforts,
|
|
1999
|
+
source: resolved.source,
|
|
2000
|
+
detectable: resolved.detectable,
|
|
2001
|
+
...record === void 0 ? {} : {
|
|
2002
|
+
validation: record.validation,
|
|
2003
|
+
probedAt: record.probedAtMs
|
|
2004
|
+
}
|
|
1617
2005
|
}];
|
|
1618
2006
|
});
|
|
1619
2007
|
return {
|
|
1620
2008
|
consent,
|
|
1621
2009
|
running: runtime.probeService.isRunning(),
|
|
1622
|
-
|
|
1623
|
-
results: newestFirst(results)
|
|
2010
|
+
models: detectedFirst(models)
|
|
1624
2011
|
};
|
|
1625
2012
|
}
|
|
1626
2013
|
/**
|
|
2014
|
+
* Order the effort rows for display: most recently detected first, then the
|
|
2015
|
+
* catalog's own order.
|
|
2016
|
+
*
|
|
2017
|
+
* The list is no longer "the models you could probe" but "every model and what
|
|
2018
|
+
* its switch does", so it must stay stable as detections land — a row that
|
|
2019
|
+
* moves when its own result arrives is a row the user has to find again. Only
|
|
2020
|
+
* the detected rows are promoted, and ties keep catalog order (a stable sort),
|
|
2021
|
+
* which is what makes the movement predictable.
|
|
2022
|
+
*/
|
|
2023
|
+
function detectedFirst(rows) {
|
|
2024
|
+
return [...rows].sort((a, b) => (b.probedAt ?? -1) - (a.probedAt ?? -1));
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
1627
2027
|
* Start one variant: its loopback endpoint, provider registration, and
|
|
1628
2028
|
* configuration-card wiring.
|
|
1629
2029
|
*
|
|
@@ -1634,12 +2034,15 @@ function probeSection(runtime, consent) {
|
|
|
1634
2034
|
* @returns whether the provider registered.
|
|
1635
2035
|
*/
|
|
1636
2036
|
async function startVariant(ctx, runtime) {
|
|
1637
|
-
const { variant, store, client, catalog, probeService } = runtime;
|
|
2037
|
+
const { variant, store, client, catalog, probeService, creditLog } = runtime;
|
|
1638
2038
|
const shim = createWorkBuddyShim({
|
|
1639
2039
|
store,
|
|
1640
2040
|
client,
|
|
1641
2041
|
catalog,
|
|
1642
|
-
logger: ctx.logger
|
|
2042
|
+
logger: ctx.logger,
|
|
2043
|
+
observeCredit: (responseId, credit) => {
|
|
2044
|
+
creditLog.record(responseId, credit);
|
|
2045
|
+
}
|
|
1643
2046
|
});
|
|
1644
2047
|
try {
|
|
1645
2048
|
await shim.ready;
|
|
@@ -1719,7 +2122,8 @@ function apply(ctx, refs) {
|
|
|
1719
2122
|
*/
|
|
1720
2123
|
const lastAccounts = /* @__PURE__ */ new Map();
|
|
1721
2124
|
const atRestKeysFor = (variant) => new WorkBuddyAtRestKeyProvider({ discovery: variant.id === CN_VARIANT.id ? cnAppDiscovery() : "none" });
|
|
1722
|
-
const
|
|
2125
|
+
const creditIndex = new WorkBuddySessionCreditIndex();
|
|
2126
|
+
const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config(), variant, (id) => lastIdentities.get(id), (id) => lastAccounts.get(id), atRestKeysFor(variant), creditIndex));
|
|
1723
2127
|
const probeKey = createProbeKey();
|
|
1724
2128
|
/**
|
|
1725
2129
|
* Point a variant at an account identity, invalidating whatever the previous
|
|
@@ -1783,6 +2187,7 @@ function apply(ctx, refs) {
|
|
|
1783
2187
|
runtime.invalidate();
|
|
1784
2188
|
};
|
|
1785
2189
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2190
|
+
registerWorkBuddyCreditRoute(webCtx, { credits: creditIndex });
|
|
1786
2191
|
for (const runtime of runtimes) {
|
|
1787
2192
|
registerWorkBuddyStatusRoute(webCtx, {
|
|
1788
2193
|
path: runtime.variant.statusPath,
|
|
@@ -1865,6 +2270,39 @@ function apply(ctx, refs) {
|
|
|
1865
2270
|
}, probeKey);
|
|
1866
2271
|
}
|
|
1867
2272
|
});
|
|
2273
|
+
/**
|
|
2274
|
+
* Attribute an observed response cost to the message that caused it.
|
|
2275
|
+
*
|
|
2276
|
+
* The two halves of the key are produced by different layers and in either
|
|
2277
|
+
* order, so this is deliberately a join rather than a straight read:
|
|
2278
|
+
*
|
|
2279
|
+
* - the **shim** sees `usage.credit` on the stream and knows the upstream
|
|
2280
|
+
* response id (`cmb-…`), but nothing about DSH's message ids;
|
|
2281
|
+
* - the **session log** records the assistant message with its DSH
|
|
2282
|
+
* `messageId` and, inside `source.replayState.response.responseId`, that
|
|
2283
|
+
* same upstream id.
|
|
2284
|
+
*
|
|
2285
|
+
* The session event normally lands *after* the stream's final frame (the
|
|
2286
|
+
* message is appended once the stream settles), so `creditLog.lookup` usually
|
|
2287
|
+
* hits. `creditLog.await` covers the opposite interleaving — a response whose
|
|
2288
|
+
* final frame arrives after its message was appended — instead of leaving
|
|
2289
|
+
* that message permanently unlabelled.
|
|
2290
|
+
*
|
|
2291
|
+
* Only variants' own provider routes are considered: the response id is
|
|
2292
|
+
* matched against each variant's own log, so a DeepSeek-account message can
|
|
2293
|
+
* never be charged a WorkBuddy cost.
|
|
2294
|
+
*/
|
|
2295
|
+
ctx.inject(["sessions"], (sessionCtx) => {
|
|
2296
|
+
sessionCtx.on("session/event", (session, event) => {
|
|
2297
|
+
if (event.type !== "assistant/message") return;
|
|
2298
|
+
const data = event.data;
|
|
2299
|
+
const provider = data.message?.source?.provider;
|
|
2300
|
+
const runtime = typeof provider === "string" ? runtimes.find((candidate) => candidate.variant.id === provider) : void 0;
|
|
2301
|
+
if (runtime === void 0) return;
|
|
2302
|
+
const sessionId = session.id;
|
|
2303
|
+
attributeCredit(runtime.creditLog, runtime.creditIndex, sessionId, data);
|
|
2304
|
+
});
|
|
2305
|
+
});
|
|
1868
2306
|
ctx.effect(() => () => {
|
|
1869
2307
|
stopped = true;
|
|
1870
2308
|
for (const timer of timers) clearInterval(timer);
|
|
@@ -1896,8 +2334,7 @@ function apply(ctx, refs) {
|
|
|
1896
2334
|
if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
|
|
1897
2335
|
inflight?.controller.abort();
|
|
1898
2336
|
const controller = new AbortController();
|
|
1899
|
-
|
|
1900
|
-
run = (async () => {
|
|
2337
|
+
const run = (async () => {
|
|
1901
2338
|
let models;
|
|
1902
2339
|
try {
|
|
1903
2340
|
const credential = await runtime.store.resolve();
|
|
@@ -1963,9 +2400,7 @@ function apply(ctx, refs) {
|
|
|
1963
2400
|
*/
|
|
1964
2401
|
const syncVariant = async (runtime) => {
|
|
1965
2402
|
if (stopped || !runtime.registered) return;
|
|
1966
|
-
const credential = await runtime
|
|
1967
|
-
ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} credential read failed`, error);
|
|
1968
|
-
});
|
|
2403
|
+
const credential = await readCredential(runtime);
|
|
1969
2404
|
if (stopped) return;
|
|
1970
2405
|
if (credential === void 0) {
|
|
1971
2406
|
adoptIdentity(runtime, void 0, void 0);
|
|
@@ -1981,11 +2416,70 @@ function apply(ctx, refs) {
|
|
|
1981
2416
|
adoptIdentity(runtime, identity, visibilityAccountOf(credential));
|
|
1982
2417
|
await fetchCatalog(runtime, identity);
|
|
1983
2418
|
};
|
|
2419
|
+
/**
|
|
2420
|
+
* Read a variant's credential, reporting a refusal instead of swallowing it.
|
|
2421
|
+
*
|
|
2422
|
+
* A region mismatch or an unreadable file is *reported*, not treated as
|
|
2423
|
+
* "signed out": the user needs to know which file to fix, and the difference
|
|
2424
|
+
* between "no credential" and "a credential I refused" is exactly what the
|
|
2425
|
+
* card's reason codes are for.
|
|
2426
|
+
*
|
|
2427
|
+
* @returns the credential, or `undefined` when there is none to use.
|
|
2428
|
+
*/
|
|
2429
|
+
const readCredential = async (runtime) => {
|
|
2430
|
+
try {
|
|
2431
|
+
return await runtime.store.current();
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
ctx.logger.warn(`dsh-workbuddy-bridge: ${runtime.variant.displayName} credential read failed`, error);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
};
|
|
2437
|
+
/**
|
|
2438
|
+
* Adopt a variant's account **before its provider registers**.
|
|
2439
|
+
*
|
|
2440
|
+
* Registration and revelation used to happen in the other order: the adapter
|
|
2441
|
+
* registered first (reading a catalog that was still hidden, so it built
|
|
2442
|
+
* *zero* models), and the first credential sweep revealed the group afterwards
|
|
2443
|
+
* in a fire-and-forget `syncAll()`. Between those two moments
|
|
2444
|
+
* `ctx.llm.listProviders()` named the provider while `resolveModelInfo`
|
|
2445
|
+
* answered `UNKNOWN_MODEL` for every one of its models — invisible in the
|
|
2446
|
+
* picker, but a session that already had one of those models selected (a
|
|
2447
|
+
* restored conversation, or `agent-default-model`) failed on its first
|
|
2448
|
+
* message. The window is tens of milliseconds here and much longer on a real
|
|
2449
|
+
* machine: macOS unlocks the credential through Spotlight and the app's
|
|
2450
|
+
* Electron, Windows goes through `reg query` and at-rest decryption, and
|
|
2451
|
+
* either can sit on a slow or networked drive.
|
|
2452
|
+
*
|
|
2453
|
+
* Adopting first closes it: when the provider appears, its catalog is already
|
|
2454
|
+
* visible, so the models exist from the first read. Nothing is lost when the
|
|
2455
|
+
* read fails or nobody is signed in — the variant registers hidden exactly as
|
|
2456
|
+
* it does today, and the poll reveals it later.
|
|
2457
|
+
*
|
|
2458
|
+
* @see .agents/notes/2026-10-02-startup-window-fix-direction.md
|
|
2459
|
+
*/
|
|
2460
|
+
const adoptBeforeRegistering = async (runtime) => {
|
|
2461
|
+
const credential = await readCredential(runtime);
|
|
2462
|
+
if (stopped) return;
|
|
2463
|
+
adoptIdentity(runtime, credential === void 0 ? void 0 : credentialIdentity(credential), credential === void 0 ? void 0 : visibilityAccountOf(credential));
|
|
2464
|
+
};
|
|
1984
2465
|
/** Run one reconcile sweep across both variants. */
|
|
1985
2466
|
const syncAll = async () => {
|
|
1986
2467
|
for (const runtime of runtimes) await syncVariant(runtime);
|
|
1987
2468
|
};
|
|
1988
|
-
|
|
2469
|
+
/**
|
|
2470
|
+
* Bring both variants up, then start the credential sweep.
|
|
2471
|
+
*
|
|
2472
|
+
* The phases are ordered and deliberate: adopting an identity must finish
|
|
2473
|
+
* before the matching provider registers (see
|
|
2474
|
+
* {@link adoptBeforeRegistering}), and the catalogs are fetched after
|
|
2475
|
+
* registration so a fetch failure lands on an already-usable group rather than
|
|
2476
|
+
* on nothing. The two variants never wait on each other within a phase — one
|
|
2477
|
+
* slow credential read must not hold the other product's models back.
|
|
2478
|
+
*/
|
|
2479
|
+
const startAll = async () => {
|
|
2480
|
+
await Promise.all(runtimes.map((runtime) => adoptBeforeRegistering(runtime)));
|
|
2481
|
+
if (stopped) return;
|
|
2482
|
+
await Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime)));
|
|
1989
2483
|
if (stopped) return;
|
|
1990
2484
|
if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
|
|
1991
2485
|
syncAll();
|
|
@@ -1994,7 +2488,8 @@ function apply(ctx, refs) {
|
|
|
1994
2488
|
}, credentialPollMs());
|
|
1995
2489
|
timer.unref?.();
|
|
1996
2490
|
timers.push(timer);
|
|
1997
|
-
}
|
|
2491
|
+
};
|
|
2492
|
+
startAll();
|
|
1998
2493
|
}
|
|
1999
2494
|
//#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 };
|
|
2495
|
+
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_CREDIT_PATH, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WORKBUDDY_VISIBILITY_FILENAME, WorkBuddyCatalog, WorkBuddyCatalogStore, WorkBuddyCredentialStore, WorkBuddyCreditLog, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddySessionCreditIndex, 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, registerWorkBuddyCreditRoute, resolveAppVersion, resolveChatIdentity, validAppVersion, validCliVersion, variantFor, visibilityAccountOf, workBuddyCreditHandler, workBuddySessionCredits, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath, workbuddyVisibilityPath };
|