dsh-connect-workbuddy 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-Dy8fW3G5.js";
1
+ import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-CDZRWbFv.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { createProvider } from "@earendil-works/pi-ai";
@@ -492,6 +492,304 @@ function createWorkBuddyShim(options) {
492
492
  };
493
493
  }
494
494
  //#endregion
495
+ //#region src/probe.ts
496
+ /**
497
+ * The system message every probe carries.
498
+ *
499
+ * Not decoration: the international gateway rejects a conversation that does not
500
+ * start with a system message (see the module note). A probe that omitted it
501
+ * would report every global model as broken.
502
+ */
503
+ const PROBE_SYSTEM_PROMPT = "You are a connectivity check. Reply with a single word.";
504
+ /**
505
+ * Output cap for a probe. One token is enough to prove the model answers.
506
+ *
507
+ * The probe's cost comes from its INPUT, not its output (see
508
+ * {@link PROBE_INPUT_TOKENS}), so paying for a long completion would add money
509
+ * without adding signal.
510
+ */
511
+ const PROBE_MAX_TOKENS = 1;
512
+ /**
513
+ * How much input a probe sends, in tokens.
514
+ *
515
+ * This is the whole point of the probe, and it is NOT a free choice. The
516
+ * upstream's rate limit (business code 6004) fires on request SIZE, measured on
517
+ * 2026-09-29 by varying only the input against one account and one model:
518
+ *
519
+ * | input | prompt_tokens the upstream counted | result |
520
+ * | --- | --- | --- |
521
+ * | ~10 | 38 | ok |
522
+ * | ~1,000 | 1,138 | ok |
523
+ * | ~10,000 | 11,138 | ok |
524
+ * | ~18,000 | 20,018 | ok |
525
+ * | ~25,000 | 25,023 | ok |
526
+ * | ~30,000 | ~32k | **429 / 6004** |
527
+ *
528
+ * So the threshold sits between 20k and 30k, a SINGLE large request is enough to
529
+ * trip it (it is not a running total), and each account reports its own reset
530
+ * time (two accounts at the same moment: `22:20:33` and `00:32:51`).
531
+ *
532
+ * A probe that sent a handful of tokens answered "usable" while every real
533
+ * request in a long conversation was refused — the probe was asking too small a
534
+ * question. 25k is sized to be a realistic long-conversation payload.
535
+ */
536
+ const PROBE_INPUT_TOKENS = 25e3;
537
+ /**
538
+ * Characters per token for the filler text, measured.
539
+ *
540
+ * Filling with a fixed English sentence, the upstream counted about 4.5
541
+ * characters per token. An estimate rather than a bundled tokenizer: the body
542
+ * only needs to be the right SIZE CLASS, and the target has ample margin over
543
+ * the measured threshold, so estimate error cannot change the verdict.
544
+ */
545
+ const CHARS_PER_TOKEN = 4.5;
546
+ /** Neutral filler; carries no user content, only volume. */
547
+ const FILLER_LINE = "The quick brown fox jumps over the lazy dog. ";
548
+ /**
549
+ * Build the request body for one probe.
550
+ *
551
+ * `stream` is forced because the upstream rejects non-streaming chat requests;
552
+ * `prepareChatBody` enforces that too, but sending it explicitly keeps this
553
+ * function's output valid on its own.
554
+ *
555
+ * Measured on the built artifact: 112,713 bytes of body, and the upstream
556
+ * counted `prompt_tokens: 25023` at a cost of `credit: 0.72`.
557
+ */
558
+ function probeRequestBody(modelId) {
559
+ const targetChars = Math.ceil(PROBE_INPUT_TOKENS * CHARS_PER_TOKEN);
560
+ const filler = FILLER_LINE.repeat(Math.ceil(targetChars / 45));
561
+ return JSON.stringify({
562
+ model: modelId,
563
+ messages: [{
564
+ role: "system",
565
+ content: PROBE_SYSTEM_PROMPT
566
+ }, {
567
+ role: "user",
568
+ content: `${filler}Reply with a single word.`
569
+ }],
570
+ max_tokens: 1,
571
+ stream: true
572
+ });
573
+ }
574
+ /**
575
+ * Parse a `Retry-After` header value into an absolute time.
576
+ *
577
+ * Both RFC 9110 forms are accepted: delay-seconds (`120`) and an HTTP-date
578
+ * (`Wed, 21 Oct 2026 07:28:00 GMT`). Returns undefined for anything else,
579
+ * including the negative/zero delays some gateways emit — a "retry now" is not
580
+ * a cooldown and reporting it as one would be worse than saying nothing.
581
+ *
582
+ * `nowMs` is injected so the delay-seconds branch is testable without a clock.
583
+ */
584
+ function parseRetryAfter(value, nowMs) {
585
+ if (value === null) return void 0;
586
+ const trimmed = value.trim();
587
+ if (trimmed === "") return void 0;
588
+ if (/^\d+$/u.test(trimmed)) {
589
+ const seconds = Number(trimmed);
590
+ if (!Number.isFinite(seconds) || seconds <= 0) return void 0;
591
+ return nowMs + seconds * 1e3;
592
+ }
593
+ const parsed = Date.parse(trimmed);
594
+ if (Number.isNaN(parsed)) return void 0;
595
+ if (parsed <= nowMs) return void 0;
596
+ return parsed;
597
+ }
598
+ /**
599
+ * The upstream's reset sentence, as its Chinese gateway writes it.
600
+ *
601
+ * Measured on a live 429 (code 6004), verbatim:
602
+ *
603
+ * `您的使用量已超出频率限制,将在 2026-09-30 02:30:30 UTC+8 重置,您也可以切换其他模型继续使用。`
604
+ *
605
+ * The two halves are captured separately because the second one is the whole
606
+ * reason this parser exists: the upstream DOES name a time, and the plugin was
607
+ * showing "the upstream gave no time" while the answer was sitting right there
608
+ * in the body. The earlier note in this module ("the upstream provides no
609
+ * rate-limit metadata") was drawn from the RESPONSE HEADERS alone — true as far
610
+ * as it went, and wrong as a conclusion, because the time is in the body text.
611
+ *
612
+ * Deliberately loose about the surrounding wording (`[\s\S]*?` on both sides)
613
+ * and strict about the parts that must not be guessed: the keyword 重置, the
614
+ * timestamp shape, and an explicit UTC offset. A message that merely mentions
615
+ * 重置 without a parsable offset yields no time, which is the honest answer.
616
+ */
617
+ const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?[\s\S]*?重置/u;
618
+ /**
619
+ * Parse the reset time out of an upstream failure message.
620
+ *
621
+ * Returns epoch ms, or undefined when the message names no parsable time — the
622
+ * caller must then say "the upstream gave no time", never invent one.
623
+ *
624
+ * THE OFFSET IS HONOURED, NOT ASSUMED. `2026-09-30 02:30:30 UTC+8` is 18:30:30
625
+ * UTC the previous day, and `Date.parse` on the bare string would read it as
626
+ * LOCAL time — on a machine set to UTC+8 that happens to be right, and on any
627
+ * other machine it is silently wrong by the offset. So the components are
628
+ * assembled with `Date.UTC` and the stated offset subtracted, which is correct
629
+ * on every host regardless of its own zone.
630
+ *
631
+ * A whole-second resolution is what the upstream prints; sub-second precision
632
+ * would be fiction in the other direction.
633
+ */
634
+ function parseUpstreamResetAt(message) {
635
+ const match = UPSTREAM_RESET_PATTERN.exec(message);
636
+ if (match === null) return void 0;
637
+ const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
638
+ const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
639
+ const utcMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
640
+ const parsed = new Date(utcMs);
641
+ if (parsed.getUTCFullYear() !== Number(year) || parsed.getUTCMonth() !== Number(month) - 1 || parsed.getUTCDate() !== Number(day) || parsed.getUTCHours() !== Number(hour) || parsed.getUTCMinutes() !== Number(minute) || parsed.getUTCSeconds() !== Number(second)) return;
642
+ if (Math.abs(offsetTotalMinutes) > 840) return void 0;
643
+ return utcMs - offsetTotalMinutes * 6e4;
644
+ }
645
+ /**
646
+ * The cooldown the upstream stated, if it stated one.
647
+ *
648
+ * Three sources, in order of authority:
649
+ *
650
+ * 1. `Retry-After` on the failure response. This is the upstream naming a time
651
+ * for THIS request, so it wins.
652
+ * 2. The reset time the upstream writes into its own failure body (see
653
+ * {@link parseUpstreamResetAt}). This is the case that actually fires on
654
+ * this service: a 429 from the Chinese gateway carries code 6004 and a
655
+ * `将在 … 重置` sentence, with no `Retry-After` header at all.
656
+ * 3. The region's monthly quota refresh point, but ONLY for an out-of-credit
657
+ * outcome. A quota that resets at a known time is the one case where "when
658
+ * can I use this again" has a real answer even without a header — and it is
659
+ * the answer for the most common real limit on this service.
660
+ *
661
+ * A limited outcome with none of the three returns `{}`, which the card renders
662
+ * as "the upstream did not say when". That remains the honest answer for a
663
+ * genuinely timeless failure; inventing a number here would be pure fiction.
664
+ */
665
+ function cooldownOf(input) {
666
+ const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
667
+ if (fromHeader !== void 0) return {
668
+ retryAtMs: fromHeader,
669
+ retrySource: "retry-after"
670
+ };
671
+ const fromBody = input.body === void 0 ? void 0 : parseUpstreamResetAt(input.body);
672
+ if (fromBody !== void 0) return {
673
+ retryAtMs: fromBody,
674
+ retrySource: "upstream-message"
675
+ };
676
+ if (input.outcome === "out-of-credit" && input.quotaRefreshAtMs !== void 0) return {
677
+ retryAtMs: input.quotaRefreshAtMs,
678
+ retrySource: "quota-refresh"
679
+ };
680
+ return {};
681
+ }
682
+ /**
683
+ * Classify one failed probe into the card's outcome vocabulary.
684
+ *
685
+ * Built on `classifyUpstreamError` rather than re-testing status codes, so a
686
+ * probe and a real chat request can never disagree about what the same upstream
687
+ * answer means. The extra split here is `credential-rejected`, which the
688
+ * upstream signals with a non-JSON 401/403 edge page and which needs completely
689
+ * different advice (re-auth, not "wait").
690
+ */
691
+ function outcomeOfFailure(status, body) {
692
+ if (status === 401 || status === 403) return "credential-rejected";
693
+ switch (classifyUpstreamError(status, body)) {
694
+ case "soft_rate": return "rate-limited";
695
+ case "hard_credit": return "out-of-credit";
696
+ case "session_dead": return "credential-rejected";
697
+ case "not_found": return "not-found";
698
+ default: return status === 0 || status >= 500 ? "unavailable" : "failed";
699
+ }
700
+ }
701
+ /**
702
+ * Read the credit this probe consumed out of an SSE stream, when the upstream
703
+ * reports it.
704
+ *
705
+ * Purely informational — the card shows it so a user can see how little a probe
706
+ * actually costs rather than having to trust a claim. Absent when the stream
707
+ * carried no `usage` block.
708
+ */
709
+ function creditOfStream(text) {
710
+ const match = /"credit"\s*:\s*(-?[0-9]*\.?[0-9]+)/u.exec(text);
711
+ if (match === null) return void 0;
712
+ const value = Number(match[1]);
713
+ return Number.isFinite(value) ? value : void 0;
714
+ }
715
+ /** Whether a probe outcome means the model answered. */
716
+ function probeSucceeded(outcome) {
717
+ return outcome === "ok";
718
+ }
719
+ /** Max characters of upstream text kept for display, after redaction. */
720
+ const PROBE_MESSAGE_LIMIT = 300;
721
+ /**
722
+ * Redact token-shaped content out of upstream text before it is stored or sent.
723
+ *
724
+ * Duplicated from the route layer on purpose rather than imported: this module
725
+ * is the one that reads raw failure bodies, so the redaction belongs at the
726
+ * point of capture. A probe failure body is the one place a raw upstream string
727
+ * from an arbitrary endpoint enters the plugin's data flow.
728
+ */
729
+ function redact(text) {
730
+ return text.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, PROBE_MESSAGE_LIMIT);
731
+ }
732
+ /**
733
+ * Run one probe for one model and reduce it to the card's row.
734
+ *
735
+ * The successful path drains the SSE stream purely to read the `credit` figure
736
+ * the upstream reports, then discards the rest: the question is whether this
737
+ * model answers a real-sized request, and `max_tokens: 1` means there is nothing
738
+ * else in the stream worth keeping.
739
+ *
740
+ * Never throws. A probe is a diagnostic the user asked for, so every failure is
741
+ * a RESULT — one dead model must not abort the batch and hide the other rows.
742
+ */
743
+ async function probeModel(input) {
744
+ let answer;
745
+ const startedAt = Date.now();
746
+ try {
747
+ answer = await input.client.probeChat(input.credential, probeRequestBody(input.modelId), input.signal);
748
+ } catch (error) {
749
+ return {
750
+ modelId: input.modelId,
751
+ outcome: "unavailable",
752
+ message: redact(error instanceof Error ? error.message : String(error))
753
+ };
754
+ }
755
+ const elapsedMs = Date.now() - startedAt;
756
+ if (answer.ok) {
757
+ let credit;
758
+ if (answer.response !== void 0) try {
759
+ credit = creditOfStream(await answer.response.text());
760
+ } catch {}
761
+ return {
762
+ modelId: input.modelId,
763
+ outcome: "ok",
764
+ elapsedMs: Date.now() - startedAt,
765
+ status: answer.status,
766
+ ...credit === void 0 ? {} : { message: `credit ${credit}` },
767
+ ...cooldownOf({
768
+ outcome: "ok",
769
+ retryAfter: answer.retryAfter,
770
+ nowMs: input.nowMs,
771
+ ...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
772
+ })
773
+ };
774
+ }
775
+ const body = answer.body ?? "";
776
+ const outcome = outcomeOfFailure(answer.status, body);
777
+ return {
778
+ modelId: input.modelId,
779
+ outcome,
780
+ elapsedMs,
781
+ status: answer.status,
782
+ ...body === "" ? {} : { message: redact(body) },
783
+ ...cooldownOf({
784
+ outcome,
785
+ retryAfter: answer.retryAfter,
786
+ nowMs: input.nowMs,
787
+ body,
788
+ ...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
789
+ })
790
+ };
791
+ }
792
+ //#endregion
495
793
  //#region src/status-paths.ts
496
794
  /**
497
795
  * Node-free constants and types shared by the Host and browser halves.
@@ -514,6 +812,15 @@ const WORKBUDDY_MODELS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/models/ref
514
812
  const WORKBUDDY_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-workbuddy/accounts/refresh";
515
813
  /** Plugin-owned daily check-in action endpoint. */
516
814
  const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
815
+ /**
816
+ * Plugin-owned model probe endpoint.
817
+ *
818
+ * Sends one minimal chat request per named model so the card can answer "is
819
+ * this model usable right now, and if it is limited, when can it be used
820
+ * again?". Lives on the Host because the credential must never reach the
821
+ * browser half.
822
+ */
823
+ const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
517
824
  /** Query parameter naming the region a card request addresses. */
518
825
  const WORKBUDDY_REGION_PARAM = "region";
519
826
  /** Every region, in card tab order. */
@@ -684,6 +991,43 @@ function json(res, status, body) {
684
991
  });
685
992
  res.end(payload);
686
993
  }
994
+ /** Request-body cap for the probe route: model ids only, never content. */
995
+ const PROBE_BODY_LIMIT = 65536;
996
+ /**
997
+ * Read a small JSON request body.
998
+ *
999
+ * Bounded, unlike the settings route's reader: this endpoint takes a list of
1000
+ * model ids and has no legitimate use for a large payload, so an oversized body
1001
+ * is refused rather than buffered.
1002
+ */
1003
+ function readJsonBody(req) {
1004
+ return new Promise((resolve, reject) => {
1005
+ const chunks = [];
1006
+ let size = 0;
1007
+ req.on("data", (chunk) => {
1008
+ size += chunk.length;
1009
+ if (size > PROBE_BODY_LIMIT) {
1010
+ reject(/* @__PURE__ */ new Error("request body too large"));
1011
+ req.destroy();
1012
+ return;
1013
+ }
1014
+ chunks.push(chunk);
1015
+ });
1016
+ req.on("end", () => {
1017
+ try {
1018
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1019
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1020
+ reject(/* @__PURE__ */ new Error("request body must be a JSON object"));
1021
+ return;
1022
+ }
1023
+ resolve(parsed);
1024
+ } catch (error) {
1025
+ reject(error instanceof Error ? error : new Error(String(error)));
1026
+ }
1027
+ });
1028
+ req.on("error", reject);
1029
+ });
1030
+ }
687
1031
  /** Loopback browser origins only; other devices are refused until trusted origins exist. */
688
1032
  function loopbackOrigin(req) {
689
1033
  const origin = req.headers.origin;
@@ -947,6 +1291,28 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
947
1291
  }
948
1292
  }
949
1293
  });
1294
+ const disposeProbe = ctx.webServer.register({
1295
+ kind: "exact",
1296
+ path: WORKBUDDY_PROBE_PATH,
1297
+ handler: async (req, res) => {
1298
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
1299
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
1300
+ if (deps.probeModels === void 0) return json(res, 503, { error: "model probe unavailable" });
1301
+ const region = requestRegion(req, res);
1302
+ if (region === void 0) return;
1303
+ try {
1304
+ const body = await readJsonBody(req);
1305
+ const modelIds = Array.isArray(body.modelIds) ? body.modelIds.filter((id) => typeof id === "string" && id !== "") : [];
1306
+ if (modelIds.length === 0) return json(res, 400, { error: "modelIds must be a non-empty array of strings" });
1307
+ if (modelIds.length > 1) return json(res, 400, { error: "a probe accepts exactly one model" });
1308
+ const results = [];
1309
+ for (const modelId of modelIds) results.push(...await deps.probeModels(region, [modelId]));
1310
+ json(res, 200, { results });
1311
+ } catch (error) {
1312
+ json(res, 500, { error: safeMessage(error) });
1313
+ }
1314
+ }
1315
+ });
950
1316
  const disposeDiagWrite = ctx.webServer.register({
951
1317
  kind: "exact",
952
1318
  path: "/plugins/dsh-connect-workbuddy/__save",
@@ -1002,6 +1368,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
1002
1368
  disposeCheckin();
1003
1369
  disposeAccounts();
1004
1370
  disposeUsage();
1371
+ disposeProbe();
1005
1372
  disposeDiagWrite();
1006
1373
  };
1007
1374
  }, "dsh-connect-workbuddy: Web status route");
@@ -1393,10 +1760,63 @@ function apply(ctx, config) {
1393
1760
  contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
1394
1761
  discoverModels,
1395
1762
  regionEnabled: (region) => regionEnabled(current(), region),
1763
+ /**
1764
+ * One minimal request per named model, through the region's own credential.
1765
+ *
1766
+ * Resolved here rather than in the route so the browser half never touches a
1767
+ * token: the route passes only model ids and receives only outcomes.
1768
+ *
1769
+ * `quotaRefreshAtMs` is the region's nearest monthly refresh point, read from
1770
+ * the SAME credit answer the card displays. It is the only cooldown this
1771
+ * service ever states — an exhausted monthly resource that resets at a known
1772
+ * time — so it is what makes "when can I use this again" answerable for an
1773
+ * out-of-credit result. Every other limited outcome has no time anywhere, and
1774
+ * the probe reports exactly that instead of inventing one.
1775
+ */
1776
+ async probeModels(region, modelIds, options) {
1777
+ let credential;
1778
+ try {
1779
+ credential = await stacks[region].store.resolve();
1780
+ } catch (error) {
1781
+ return modelIds.map((modelId) => ({
1782
+ modelId,
1783
+ outcome: "credential-rejected",
1784
+ message: error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300)
1785
+ }));
1786
+ }
1787
+ const quotaRefreshAtMs = await quotaRefreshOf(region, credential);
1788
+ const results = [];
1789
+ for (const modelId of modelIds) results.push(await probeModel({
1790
+ client,
1791
+ credential,
1792
+ modelId,
1793
+ nowMs: Date.now(),
1794
+ ...quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs },
1795
+ ...options?.signal === void 0 ? {} : { signal: options.signal }
1796
+ }));
1797
+ return results;
1798
+ },
1396
1799
  regionUsable(region, usable) {
1397
1800
  if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
1398
1801
  }
1399
1802
  }));
1803
+ /**
1804
+ * The region's nearest monthly quota refresh, or undefined when unknown.
1805
+ *
1806
+ * The monthly resource (`CapacityType 4`) is the one package that never
1807
+ * expires and resets on a cycle, so its `refreshAtMs` is a real answer to
1808
+ * "when can I use this again" after the quota runs out. The probe passes it
1809
+ * through; a failure to read it is not an error, because a probe without a
1810
+ * cooldown is still a useful probe.
1811
+ */
1812
+ const quotaRefreshOf = async (region, credential) => {
1813
+ try {
1814
+ const refreshes = (await client.fetchCredits(credential)).packages.filter((pack) => pack.monthly && pack.refreshAtMs !== void 0).map((pack) => pack.refreshAtMs);
1815
+ return refreshes.length === 0 ? void 0 : Math.min(...refreshes);
1816
+ } catch {
1817
+ return;
1818
+ }
1819
+ };
1400
1820
  ctx.inject(["settings"], (sctx) => {
1401
1821
  const settings = sctx.settings;
1402
1822
  ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
@@ -1513,4 +1933,4 @@ function apply(ctx, config) {
1513
1933
  });
1514
1934
  }
1515
1935
  //#endregion
1516
- export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, openEncryptedField, parseCreditMultiplier, parseReasoning, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
1936
+ export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKENS, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "2.2.1",
5
+ "version": "2.3.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",