dsh-connect-workbuddy 5.0.0 → 5.2.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 { $ as REFUSES_OFF_MODEL_IDS, A as workbuddyAccountId, At as withFileLock, B as isCredentialRejectedError, C as defaultDesktopAuthDirs, Ct as macosBundleExecutable, D as isVaultAccountId, Dt as workbuddyAppExecutableCandidates, E as isEncryptedCredentialError, Et as readAtRestKey, F as WorkBuddyUpstreamClient, G as parseCreditMultiplier, H as knownRegionOf, I as classifyUpstreamError, J as perturbChannelFingerprints, K as parseReasoning, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as busyDisplaySuffix, R as forceNeutralHead, S as defaultDesktopAuthCandidates, St as isWorkbuddyBundle, T as hasEncryptedCredentialFields, Tt as openEncryptedField, U as modelIdOfBody, V as isFirstMessageGateBody, W as openingEvidenceOfBody, X as regionOf, Y as prepareChatBody, Z as attachBusyLevels, _ as WORKBUDDY_AUTH_FILENAME, _t as deriveAtRestKeyId, a as readHostHeartbeat, at as withEffectiveOff, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as pickImageModel, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as NATIVE_MODALITY_BY_MODEL_ID, et as defaultOffModelIds, f as WorkBuddyCatalog, ft as imageDefaultFor, g as ENCRYPTED_CREDENTIAL_CODE, gt as deriveAtRestKey, h as fallbackModelsFor, ht as clearAtRestKeyCache, i as processStartTimeMs, it as recordRefusal, j as workbuddyOwnAuthPath, jt as writeFileAtomic, k as parseWorkBuddyAuth, kt as resolveDshHome, l as piAiRuntimeInfo, lt as parseQueueAnswer, m as deriveCatalog, mt as WORKBUDDY_APP_EXECUTABLE_ENV, n as clearHostHeartbeat, nt as offDefaultFor, o as workbuddyHostHeartbeatPath, ot as DEFAULT_IMAGE_SIZE, p as applyContextBudgets, pt as nativeModalityOf, q as parseUpstreamModel, r as isHeartbeatProcessAlive, rt as offRefused, s as writeHostHeartbeat, st as WorkBuddyImageError, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as lastRefusalFor, u as FALLBACK_WORKBUDDY_MODELS, ut as queueNoticeText, v as WORKBUDDY_AUTH_FILE_ENV, vt as fetchAtRestKeyPayload, w as defaultDesktopAuthPath, wt as macosNestedAppBundles, x as authFileName, xt as isEncryptedFieldWrapper, y as WorkBuddyCredentialStore, yt as findWorkbuddyAppExecutable, z as isConversationIdGateBody } from "./host-heartbeat-D313ENPV.js";
1
+ import { $ as regionOf, A as parseWorkBuddyAuth, At as workbuddyAppExecutableCandidates, B as forceNeutralHead, C as defaultDesktopAuthCandidates, D as isEncryptedCredentialError, Dt as macosNestedAppBundles, E as hasEncryptedCredentialFields, Et as macosBundleExecutable, F as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, G as knownRegionOf, H as isConversationIdGateBody, I as WorkBuddyCredentialRejectedError, J as parseCreditMultiplier, K as modelIdOfBody, L as WorkBuddyUpstreamClient, M as workbuddyOwnAuthPath, Mt as resolveDshHome, N as CONTEXT_OVERFLOW_MARKER, Nt as withFileLock, O as isVaultAccountId, Ot as openEncryptedField, P as CREDENTIAL_REJECTED_CODE, Pt as writeFileAtomic, Q as prepareChatBody, R as classifyUpstreamError, S as authFileName, St as findWorkbuddyAppExecutable, T as defaultDesktopAuthPath, Tt as isWorkbuddyBundle, U as isCredentialRejectedError, V as isContextTooLongBody, W as isFirstMessageGateBody, X as parseUpstreamModel, Y as parseReasoning, Z as perturbChannelFingerprints, _ as ENCRYPTED_CREDENTIAL_CODE, _t as WORKBUDDY_APP_EXECUTABLE_ENV, a as readHostHeartbeat, at as offDefaultFor, b as WorkBuddyCredentialStore, bt as deriveAtRestKeyId, c as WORKBUDDY_CONNECT_VERSION, ct as withEffectiveOff, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, dt as pickImageModel, et as attachBusyLevels, f as WorkBuddyCatalog, ft as parseQueueAnswer, g as withoutRouters, gt as nativeModalityOf, h as fallbackModelsFor, ht as imageDefaultFor, i as processStartTimeMs, it as lastRefusalFor, j as workbuddyAccountId, k as legacyWorkbuddyOwnAuthPath, kt as readAtRestKey, l as piAiRuntimeInfo, lt as DEFAULT_IMAGE_SIZE, m as deriveCatalog, mt as NATIVE_MODALITY_BY_MODEL_ID, n as clearHostHeartbeat, nt as REFUSES_OFF_MODEL_IDS, o as workbuddyHostHeartbeatPath, ot as offRefused, p as applyContextBudgets, pt as queueNoticeText, q as openingEvidenceOfBody, r as isHeartbeatProcessAlive, rt as defaultOffModelIds, s as writeHostHeartbeat, st as recordRefusal, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as busyDisplaySuffix, u as FALLBACK_WORKBUDDY_MODELS, ut as WorkBuddyImageError, v as WORKBUDDY_AUTH_FILENAME, vt as clearAtRestKeyCache, w as defaultDesktopAuthDirs, wt as isEncryptedFieldWrapper, x as WorkBuddyEncryptedCredentialError, xt as fetchAtRestKeyPayload, y as WORKBUDDY_AUTH_FILE_ENV, yt as deriveAtRestKey, z as declaredTools } from "./host-heartbeat-Icz67WtG.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -589,16 +589,80 @@ function foldModernContext(context) {
589
589
  function transcript(context) {
590
590
  return context;
591
591
  }
592
- function withLegacyContext(api, generation) {
592
+ /**
593
+ * Rewrite every historical assistant message to the identity this adapter stamps.
594
+ *
595
+ * ISSUE #102. pi-ai replays a thinking block as `thinking` only when the message
596
+ * that produced it names the same `provider` / `api` / `model` as the request in
597
+ * flight (`utils/transform-messages.js`, the `isSameModel` branch); everything
598
+ * else is flattened to `{type:'text', text: thinking}`.
599
+ *
600
+ * The durable transcript the HOST hands back does not carry the identity. The
601
+ * agent loop writes `source.provider = request.provider`, and `request.provider`
602
+ * is the ROUTE id (`workbuddy` / `workbuddy-global`), while this adapter stamps
603
+ * `provider: WORKBUDDY_MODEL_IDENTITY` on the model. So:
604
+ *
605
+ * - CN sessions survive by COINCIDENCE: its route id happens to equal the
606
+ * identity (`workbuddy` === `workbuddy`).
607
+ * - GLOBAL sessions never match (`workbuddy-global` !== `workbuddy`), so every
608
+ * historical thinking block is downgraded on EVERY turn — not only when the
609
+ * user switches regions, which is what #59's write-up assumed.
610
+ *
611
+ * The model then sees a context in which all of its own past reasoning was
612
+ * plain assistant text, and writes its new reasoning into `text` to match. That
613
+ * is the reported symptom: reasoning collapses to a character or two while the
614
+ * answer channel fills with working notes.
615
+ *
616
+ * WHY REWRITING IS SAFE. Every message here was produced by THIS plugin, in THIS
617
+ * region — the transcript never contains another provider's assistant turns
618
+ * (`dsh-llm` splits history by provider before the API sees it). The identity is
619
+ * already the field this adapter stamps on the models it serves, and it resolves
620
+ * as a registry key in both regions (issue #75). Nothing about routing, billing
621
+ * or the destination changes: `baseUrl` comes from the model, not from the
622
+ * message.
623
+ *
624
+ * Only `provider` is rewritten. `api` is left alone on purpose: a legacy
625
+ * (`dsh-foreign`) message carries an api this plugin never sends, and claiming
626
+ * `openai-completions` for it would assert a wire shape that message was never
627
+ * built for. The `provider` mismatch is what decides the downgrade, and fixing
628
+ * it is enough — `isSameModel` still requires the model id and api to match, so
629
+ * a genuinely different model keeps degrading (covered by a test).
630
+ *
631
+ * Returns the same object when nothing changed, so the common case (an
632
+ * already-normalized transcript) allocates nothing.
633
+ */
634
+ function alignAssistantIdentities(context, identity = WORKBUDDY_MODEL_IDENTITY) {
635
+ const messages = context.messages;
636
+ if (!Array.isArray(messages) || messages.length === 0) return context;
637
+ let changed = false;
638
+ const next = messages.map((message) => {
639
+ if (typeof message !== "object" || message === null) return message;
640
+ const record = message;
641
+ if (record["role"] !== "assistant") return message;
642
+ if (record["provider"] === identity) return message;
643
+ changed = true;
644
+ return {
645
+ ...record,
646
+ provider: identity
647
+ };
648
+ });
649
+ return changed ? {
650
+ ...context,
651
+ messages: next
652
+ } : context;
653
+ }
654
+ function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY) {
655
+ /** One place so both branches and both entry points agree on the order. */
656
+ const prepare = (context) => alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity);
593
657
  if (generation === "modern") return {
594
658
  ...api,
595
- stream: (model, context, options) => api.stream(model, transcript(foldModernContext(context)), options),
596
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(foldModernContext(context)), options)
659
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
660
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
597
661
  };
598
662
  return {
599
663
  ...api,
600
- stream: (model, context, options) => api.stream(model, transcript(adaptLegacyPiAiContext(context)), options),
601
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(adaptLegacyPiAiContext(context)), options)
664
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
665
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
602
666
  };
603
667
  }
604
668
  /**
@@ -673,7 +737,10 @@ const NO_REASONING_EFFORTS = { efforts: [{
673
737
  * catalog has six of them (`default`, `glm-5.0`, `glm-4.7`, `glm-4.6`,
674
738
  * `hunyuan-chat`, `hunyuan-image-alpha`), the live global one two
675
739
  * (`default-model`, `deep-model`), and EVERY entry of both static fallback lists
676
- * — the inherited `high` is not a tier that model has. `resolveReasoningLevel`
740
+ * — the inherited `high` is not a tier that model has. (`default-model` is the
741
+ * international `auto` router and is not offered as a row since 5.0.1; the
742
+ * measurement is kept verbatim because the defect it documents was observed
743
+ * against the full roster.) `resolveReasoningLevel`
677
744
  * then throws `UNSUPPORTED_REASONING_EFFORT` before the request is even built,
678
745
  * so the model is unusable locally: no wire call, no upstream error, nothing to
679
746
  * retry. Introduced by v4.11.0, which made the profile default explicit; before
@@ -773,7 +840,7 @@ function createWorkBuddyAdapter(options) {
773
840
  }
774
841
  } },
775
842
  models: buildModels(),
776
- api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation), options.resolveContextRescue)
843
+ api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation, modelIdentity), options.resolveContextRescue)
777
844
  }),
778
845
  getModels: () => buildModels()
779
846
  };
@@ -3190,6 +3257,34 @@ function createWorkBuddyShim(options) {
3190
3257
  const onRequestBody = options.onRequestBody;
3191
3258
  const onUpstreamUsage = options.onUpstreamUsage;
3192
3259
  const SHARED_SECRET = randomBytes(32).toString("base64url");
3260
+ /**
3261
+ * Whether this process's prompts have been PROVEN to need the invisible
3262
+ * perturbation before the gateway will accept them.
3263
+ *
3264
+ * WHY THIS EXISTS (measured 2026-10-22). The retry ladder below is
3265
+ * discover-by-failing: a request goes out unmodified, is refused with `11128`,
3266
+ * and the ladder re-sends a perturbed copy. That is correct the first time and
3267
+ * wasteful every time after — the prompt SHAPE that carries a fingerprinted
3268
+ * substring does not change between turns of a session, so the plugin was
3269
+ * paying one refused request (~209ms measured) on EVERY request, forever.
3270
+ *
3271
+ * So once the perturbation is what made a request succeed, the next request
3272
+ * starts from the perturbed form and skips the doomed first attempt. The
3273
+ * saving is one round trip plus one refused request per turn.
3274
+ *
3275
+ * WHY A PROCESS-LOCAL FLAG AND NOT A CONFIG. The gateway's fingerprint list is
3276
+ * a snapshot the upstream can change at any time, in either direction: a rule
3277
+ * can be withdrawn as easily as added. A persisted flag would therefore
3278
+ * outlive the condition that justified it and could keep perturbing prompts a
3279
+ * gateway no longer objects to. In memory it is forgotten at restart, which is
3280
+ * the natural expiry for "what this gateway refused recently".
3281
+ *
3282
+ * AND IT SELF-CORRECTS. If a perturbed body is EVER refused with the channel
3283
+ * gate, the flag is cleared and the request falls back to the plain ladder —
3284
+ * so a withdrawn rule, or a different model behind the same gateway, cannot
3285
+ * leave the plugin stuck sending a shape that no longer helps.
3286
+ */
3287
+ let channelNeedsPerturbation = false;
3193
3288
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
3194
3289
  function bearerOk(req) {
3195
3290
  const header = req.headers.authorization;
@@ -3323,7 +3418,25 @@ function createWorkBuddyShim(options) {
3323
3418
  }
3324
3419
  const controller = new AbortController();
3325
3420
  res.on("close", () => controller.abort());
3326
- let result = await client.chatStream(credential, prepared, controller.signal);
3421
+ /**
3422
+ * The body to send FIRST, which is the perturbed form once this process has
3423
+ * proven the gateway demands it.
3424
+ *
3425
+ * `perturbChannelFingerprints` is consulted only when the flag is set, so a
3426
+ * fresh process still pays the plain attempt — that is what discovers
3427
+ * whether the gate is on at all (see `channelNeedsPerturbation`).
3428
+ *
3429
+ * The perturbed form is a purely additive transform: it inserts zero-width
3430
+ * spaces and changes no word the model reads, so starting from it is not a
3431
+ * behaviour change, only a saved round trip.
3432
+ */
3433
+ const preferred = channelNeedsPerturbation ? perturbChannelFingerprints(prepared) ?? prepared : prepared;
3434
+ let result = await client.chatStream(credential, preferred, controller.signal);
3435
+ if (channelNeedsPerturbation && !result.ok && result.kind === "channel_reject") {
3436
+ channelNeedsPerturbation = false;
3437
+ logger?.warn("dsh-connect-workbuddy: a perturbed body was refused too; clearing the learned preference and retrying from the plain form");
3438
+ result = await client.chatStream(credential, prepared, controller.signal);
3439
+ }
3327
3440
  /**
3328
3441
  * The retry ladder that does not depend on knowing the fingerprint
3329
3442
  * (issue #98, both halves).
@@ -3371,6 +3484,7 @@ function createWorkBuddyShim(options) {
3371
3484
  logger?.warn("dsh-connect-workbuddy: the channel gate refused the body again; retrying once with an invisible perturbation of the scanned text");
3372
3485
  result = await client.chatStream(credential, rescued, controller.signal);
3373
3486
  retriedWithPerturbation = true;
3487
+ if (result.ok) channelNeedsPerturbation = true;
3374
3488
  }
3375
3489
  }
3376
3490
  }
@@ -3503,7 +3617,8 @@ function createWorkBuddyShim(options) {
3503
3617
  const refusedTier = reasoningTierRefusal(result);
3504
3618
  const receipt = refusedTier ?? detail?.tips ?? detail?.displayMsg ?? clippedUpstreamText(result.message, 400);
3505
3619
  const advice = refusedTier !== void 0 ? "这不是账号或网络问题:该模型实测拒绝「off」推理档位,请到模型卡片里取消该行的「可关闭思考」勾选后重试" : result.kind === "client" ? "该请求被上游按参数拒绝:与网络、账号、额度都无关,换账号也不会改变结果,请按上面的提示调整后重试" : shortfall === void 0 ? "" : "建议换一个模型继续,或稍后等待该账号的额度恢复";
3506
- const body = siblingGate !== void 0 ? siblingGate : advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`;
3620
+ const overflow = isContextTooLongBody(result.message) ? `${CONTEXT_OVERFLOW_MARKER}。` : "";
3621
+ const body = siblingGate !== void 0 ? siblingGate : `${overflow}${advice === "" ? receipt : `${receipt.replace(/[。.]$/u, "")}。${advice}`}`;
3507
3622
  writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy 上游失败:${failoverReasonText(result)}(http ${result.status})${note}${why}${stamp}。${body}`, hostErrorCode(result.kind));
3508
3623
  return;
3509
3624
  }
@@ -4203,6 +4318,199 @@ async function probeModel(input) {
4203
4318
  };
4204
4319
  }
4205
4320
  /**
4321
+ * Smallest spacing between content chunks, in milliseconds, that still counts
4322
+ * as generation.
4323
+ *
4324
+ * Guards the divide-by-zero case where a fast model delivers every token in a
4325
+ * single burst: `chunks === 1` means the stream told us nothing about the RATE,
4326
+ * and the run reports no speed rather than an invented one. The same applies
4327
+ * when the first and last content chunk land in the same millisecond.
4328
+ */
4329
+ const MIN_GENERATION_MS = 1;
4330
+ /** Build the request body for one speed run. */
4331
+ function tpsProbeRequestBody(modelId) {
4332
+ return JSON.stringify({
4333
+ model: modelId,
4334
+ messages: [{
4335
+ role: "system",
4336
+ content: "You are a throughput benchmark. Output a long, uninterrupted sequence of short words separated by spaces. No preamble, no explanation, no code block."
4337
+ }, {
4338
+ role: "user",
4339
+ content: "List the numbers from 1 to 400 in words, separated by single spaces."
4340
+ }],
4341
+ max_tokens: 256,
4342
+ ...OFF_REASONING_FIELDS,
4343
+ stream: true
4344
+ });
4345
+ }
4346
+ /**
4347
+ * Ask for the lowest reasoning effort available.
4348
+ *
4349
+ * A reasoning model asked to count spends its `max_tokens` inside the reasoning
4350
+ * channel; the stream then carries thinking deltas and little or no content, and
4351
+ * timing it would measure the wrong thing. `none` is the widest supported
4352
+ * spelling; gateways that do not know it ignore the field (measured: the CN
4353
+ * gateway answers 200 with it).
4354
+ */
4355
+ const OFF_REASONING_FIELDS = { reasoning_effort: "none" };
4356
+ /**
4357
+ * Read one SSE stream INCREMENTALLY, timing each content deltas arrival.
4358
+ *
4359
+ * The incremental read is the measurement. `response.text()` buffers the whole
4360
+ * body and hands it over once, which erases the only signal a throughput number
4361
+ * is made of: the interval between the first and last content chunk. A version of
4362
+ * this file that parsed a buffered string reported the PARSING time, not the
4363
+ * generation time, and looked entirely plausible — which is why the reader takes
4364
+ * the response body and not a string.
4365
+ *
4366
+ * Only `choices[0].delta.content` is timed. A reasoning delta is not output:
4367
+ * counting it would report a reasoning model as fast because it produced a lot of
4368
+ * private thinking, and reasoning models are precisely the ones where the
4369
+ * distinction matters.
4370
+ *
4371
+ * `startedAt` is the caller's clock reading taken BEFORE the request went out.
4372
+ * Defaulting it to "now" would turn time-to-first-token into "time to start
4373
+ * reading" — a plausible-looking number wrong by the whole connect+TTFB.
4374
+ *
4375
+ * Never throws on a malformed frame: the upstream is allowed to send comments,
4376
+ * blank lines and `[DONE]`, and a stream that ends early still reports what it
4377
+ * timed.
4378
+ */
4379
+ async function readTimedStream(body, startedAt) {
4380
+ const reader = body.getReader();
4381
+ const decoder = new TextDecoder();
4382
+ let pending = "";
4383
+ let firstAt;
4384
+ let lastAt;
4385
+ let usage;
4386
+ try {
4387
+ while (true) {
4388
+ const { done, value } = await reader.read();
4389
+ if (done) break;
4390
+ const arrival = Date.now();
4391
+ pending += decoder.decode(value, { stream: true });
4392
+ const lines = pending.split("\n");
4393
+ pending = lines.pop() ?? "";
4394
+ for (const line of lines) {
4395
+ const reading = timingsOfLine(line, arrival);
4396
+ if (reading.usage !== void 0) usage = reading.usage;
4397
+ if (reading.content !== void 0) {
4398
+ if (firstAt === void 0) firstAt = arrival;
4399
+ lastAt = arrival;
4400
+ }
4401
+ }
4402
+ }
4403
+ } catch {} finally {
4404
+ reader.cancel().catch(() => {});
4405
+ }
4406
+ const outputTokens = typeof usage?.["completion_tokens"] === "number" ? usage["completion_tokens"] : void 0;
4407
+ const credit = typeof usage?.["credit"] === "number" ? usage["credit"] : void 0;
4408
+ return {
4409
+ ...outputTokens === void 0 ? {} : { outputTokens },
4410
+ ...firstAt === void 0 ? {} : { firstContentMs: firstAt - startedAt },
4411
+ ...firstAt === void 0 || lastAt === void 0 ? {} : { generationMs: lastAt - firstAt },
4412
+ ...credit === void 0 ? {} : { credit }
4413
+ };
4414
+ }
4415
+ /**
4416
+ * What one SSE line says: whether it carried content, and any usage frame.
4417
+ *
4418
+ * Split out so the frame grammar is testable without a stream. The `arrival`
4419
+ * timestamp is threaded in by the reader, because a parser that read the clock
4420
+ * itself would be timing the parse, not the arrival.
4421
+ */
4422
+ function timingsOfLine(line, _arrival) {
4423
+ if (!line.startsWith("data:")) return {};
4424
+ const payload = line.slice(5).trim();
4425
+ if (payload === "" || payload === "[DONE]") return {};
4426
+ let frame;
4427
+ try {
4428
+ frame = JSON.parse(payload);
4429
+ } catch {
4430
+ return {};
4431
+ }
4432
+ if (typeof frame !== "object" || frame === null || Array.isArray(frame)) return {};
4433
+ const record = frame;
4434
+ const usageField = record["usage"];
4435
+ const usage = typeof usageField === "object" && usageField !== null && !Array.isArray(usageField) ? usageField : void 0;
4436
+ const choices = record["choices"];
4437
+ const first = Array.isArray(choices) ? choices[0] : void 0;
4438
+ const delta = typeof first === "object" && first !== null ? first["delta"] : void 0;
4439
+ const content = typeof delta === "object" && delta !== null ? delta["content"] : void 0;
4440
+ return {
4441
+ ...typeof content === "string" && content !== "" ? { content } : {},
4442
+ ...usage === void 0 ? {} : { usage }
4443
+ };
4444
+ }
4445
+ /** The middle value; undefined for an empty list. */
4446
+ function median(values) {
4447
+ if (values.length === 0) return void 0;
4448
+ const sorted = [...values].sort((a, b) => a - b);
4449
+ const middle = Math.floor(sorted.length / 2);
4450
+ return sorted.length % 2 === 1 ? sorted[middle] : Math.round((sorted[middle - 1] + sorted[middle]) / 2);
4451
+ }
4452
+ /**
4453
+ * Reduce several run readings to one sample.
4454
+ *
4455
+ * The median per quantity is taken SEPARATELY rather than picking one "winner"
4456
+ * run: a run whose stream was truncated may still have timed its first chunk
4457
+ * honestly, and throwing that away would lose information for no reason. A
4458
+ * quantity with no usable reading is omitted, never filled in.
4459
+ */
4460
+ function summarizeTps(modelId, runs, attempted) {
4461
+ const complete = runs.length === attempted && attempted > 0;
4462
+ const speeds = [];
4463
+ const firsts = [];
4464
+ let outputTokens;
4465
+ let credit;
4466
+ for (const run of runs) {
4467
+ if (run.firstContentMs !== void 0) firsts.push(run.firstContentMs);
4468
+ if (run.generationMs !== void 0 && run.generationMs >= MIN_GENERATION_MS && run.outputTokens !== void 0 && run.outputTokens > 0) speeds.push(run.outputTokens / (run.generationMs / 1e3));
4469
+ if (run.outputTokens !== void 0) outputTokens = run.outputTokens;
4470
+ if (run.credit !== void 0) credit = (credit ?? 0) + run.credit;
4471
+ }
4472
+ const tokensPerSecond = median(speeds.map((speed) => Math.round(speed)));
4473
+ const reason = tokensPerSecond !== void 0 ? void 0 : runs.length === 0 ? "all-failed" : outputTokens === void 0 ? "no-usage" : "few-chunks";
4474
+ return {
4475
+ modelId,
4476
+ ...tokensPerSecond === void 0 ? {} : { tokensPerSecond },
4477
+ ...outputTokens === void 0 ? {} : { outputTokens },
4478
+ ...median(firsts) === void 0 ? {} : { timeToFirstTokenMs: median(firsts) },
4479
+ samples: runs.length,
4480
+ ...credit === void 0 ? {} : { credit: Math.round(credit * 100) / 100 },
4481
+ complete,
4482
+ ...reason === void 0 ? {} : { reason }
4483
+ };
4484
+ }
4485
+ /**
4486
+ * Measure one model's output speed.
4487
+ *
4488
+ * Runs {@link TPS_PROBE_RUNS} times and takes per-quantity medians (see
4489
+ * {@link summarizeTps} for why a median and not a mean). A failed run is dropped
4490
+ * rather than counted as zero — a model that answered twice at 400 tok/s did not
4491
+ * get slower because the third request timed out — but the sample count records
4492
+ * the shortfall so the card can say "n/3".
4493
+ *
4494
+ * Never throws: this is a diagnostic the user asked for, and a dead model must
4495
+ * produce a result row, not an exception.
4496
+ */
4497
+ async function measureTps(input) {
4498
+ const runs = input.runs ?? 3;
4499
+ const readings = [];
4500
+ for (let index = 0; index < runs; index++) {
4501
+ if (input.signal?.aborted === true) break;
4502
+ const startedAt = Date.now();
4503
+ try {
4504
+ const answer = await input.client.probeChat(input.credential, tpsProbeRequestBody(input.modelId), input.signal);
4505
+ if (!answer.ok || answer.response === void 0) continue;
4506
+ readings.push(await readTimedStream(answer.response.body, startedAt));
4507
+ } catch {
4508
+ continue;
4509
+ }
4510
+ }
4511
+ return summarizeTps(input.modelId, readings, runs);
4512
+ }
4513
+ /**
4206
4514
  * Check in every account, one at a time.
4207
4515
  *
4208
4516
  * Idempotent by design (see the module note): an account already checked in
@@ -5369,6 +5677,17 @@ const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
5369
5677
  */
5370
5678
  const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
5371
5679
  /**
5680
+ * Plugin-owned OUTPUT-SPEED measurement endpoint (issue #92).
5681
+ *
5682
+ * Separate from {@link WORKBUDDY_PROBE_PATH} because the two ask opposite
5683
+ * questions and cost different amounts: the availability probe sends 25,000
5684
+ * input tokens and caps output at one (measured `credit 0.72`), while this one
5685
+ * sends ~35 input tokens and caps output at 256 (measured `credit 0.01`–`0.15`).
5686
+ * Sharing the route would mean sharing its one-model-per-request cap and its
5687
+ * cost wording, and the two would then be wrong for each other.
5688
+ */
5689
+ const WORKBUDDY_TPS_PATH = "/plugins/dsh-connect-workbuddy/tps";
5690
+ /**
5372
5691
  * Plugin-owned account-pool endpoint: one batch action per call.
5373
5692
  *
5374
5693
  * `?action=checkin` claims the daily reward for every account of a region;
@@ -6224,6 +6543,28 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
6224
6543
  }
6225
6544
  }
6226
6545
  });
6546
+ const disposeTps = ctx.webServer.register({
6547
+ kind: "exact",
6548
+ path: WORKBUDDY_TPS_PATH,
6549
+ handler: async (req, res) => {
6550
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6551
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6552
+ if (deps.measureTps === void 0) return json(res, 503, { error: "tps measurement unavailable" });
6553
+ const region = requestRegion(req, res);
6554
+ if (region === void 0) return;
6555
+ try {
6556
+ const body = await readJsonBody(req);
6557
+ const modelIds = Array.isArray(body.modelIds) ? body.modelIds.filter((id) => typeof id === "string" && id !== "") : [];
6558
+ if (modelIds.length === 0) return json(res, 400, { error: "modelIds must be a non-empty array of strings" });
6559
+ if (modelIds.length > 1) return json(res, 400, { error: "a tps measurement accepts exactly one model" });
6560
+ const results = [];
6561
+ for (const modelId of modelIds) results.push(...await deps.measureTps(region, [modelId]));
6562
+ json(res, 200, { results });
6563
+ } catch (error) {
6564
+ json(res, 500, { error: safeMessage(error) });
6565
+ }
6566
+ }
6567
+ });
6227
6568
  const disposePool = ctx.webServer.register({
6228
6569
  kind: "exact",
6229
6570
  path: WORKBUDDY_POOL_PATH,
@@ -6499,6 +6840,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
6499
6840
  disposeUsage();
6500
6841
  disposeAccountCredits();
6501
6842
  disposeProbe();
6843
+ disposeTps();
6502
6844
  disposePool();
6503
6845
  disposeOAuth();
6504
6846
  disposeTransfer();
@@ -6889,7 +7231,7 @@ function apply(ctx, config) {
6889
7231
  }));
6890
7232
  const configuredModels = (value, region, liveModels = []) => {
6891
7233
  const state = regionStateOf(value, region);
6892
- const roster = state.lastCatalog?.length ? state.lastCatalog : liveModels.length ? liveModels : fallbackModelsFor(region);
7234
+ const roster = state.lastCatalog?.length ? withoutRouters(state.lastCatalog) : liveModels.length ? liveModels : fallbackModelsFor(region);
6893
7235
  const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), new Set(state.imageModelIds ?? []));
6894
7236
  return withEffectiveOff(declared, new Set(savedOffModelIds(state, declared))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
6895
7237
  ...model,
@@ -6911,7 +7253,7 @@ function apply(ctx, config) {
6911
7253
  const savedOffModelIds = (state, roster) => state.offModelIds ?? defaultOffModelIds(roster);
6912
7254
  const displayModels = (value, region) => {
6913
7255
  const state = regionStateOf(value, region);
6914
- if (state.lastCatalog?.length) return state.lastCatalog;
7256
+ if (state.lastCatalog?.length) return withoutRouters(state.lastCatalog);
6915
7257
  const live = stacks[region]?.catalog?.current();
6916
7258
  return live?.length ? live : fallbackModelsFor(region);
6917
7259
  };
@@ -7149,6 +7491,51 @@ function apply(ctx, config) {
7149
7491
  }));
7150
7492
  return results;
7151
7493
  },
7494
+ /**
7495
+ * Output-speed measurement (issue #92), region-scoped like every other
7496
+ * spending path.
7497
+ *
7498
+ * Deliberately NOT routed through the probe's failover machinery: a speed
7499
+ * number describes the MODEL, and having the pool silently serve the run
7500
+ * from a different account would attach one account's rate limit (6004) to
7501
+ * another's measurement. The selected credential runs all three samples, so
7502
+ * "it failed" means this account could not measure it, which is the honest
7503
+ * answer.
7504
+ */
7505
+ async measureTps(region, modelIds, options) {
7506
+ let credential;
7507
+ try {
7508
+ credential = await stacks[region].store.resolve();
7509
+ } catch (error) {
7510
+ const message = error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300);
7511
+ return modelIds.map((modelId) => ({
7512
+ modelId,
7513
+ outcome: "credential-rejected",
7514
+ samples: 0,
7515
+ message
7516
+ }));
7517
+ }
7518
+ const results = [];
7519
+ for (const modelId of modelIds) {
7520
+ const sample = await measureTps({
7521
+ client,
7522
+ credential,
7523
+ modelId,
7524
+ ...options?.signal === void 0 ? {} : { signal: options.signal }
7525
+ });
7526
+ results.push({
7527
+ modelId: sample.modelId,
7528
+ outcome: sample.tokensPerSecond === void 0 ? "failed" : "ok",
7529
+ ...sample.tokensPerSecond === void 0 ? {} : { tokensPerSecond: sample.tokensPerSecond },
7530
+ ...sample.timeToFirstTokenMs === void 0 ? {} : { timeToFirstTokenMs: sample.timeToFirstTokenMs },
7531
+ ...sample.outputTokens === void 0 ? {} : { outputTokens: sample.outputTokens },
7532
+ samples: sample.samples,
7533
+ ...sample.reason === void 0 ? {} : { reason: sample.reason },
7534
+ ...sample.credit === void 0 ? {} : { credit: sample.credit }
7535
+ });
7536
+ }
7537
+ return results;
7538
+ },
7152
7539
  regionUsable(region, usable) {
7153
7540
  if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
7154
7541
  },
@@ -8014,4 +8401,4 @@ function apply(ctx, config) {
8014
8401
  });
8015
8402
  }
8016
8403
  //#endregion
8017
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, 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, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, 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, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
8404
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, 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_TPS_PATH, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, 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, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "WorkBuddy Model Access",
3
+ "title": "dsh-connect-workbuddy",
4
4
  "description": "Connects locally signed-in WorkBuddy models, CN and international alike, to DSH, reading each model's credit multiplier from the upstream catalog on every refresh, with per-model image input toggles, account switching backed by an auto-failing-over pool, manual daily check-in, and read-only credit overviews shown as compact pills on the card and in the composer."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "WorkBuddy 模型接入",
3
+ "title": "dsh-connect-workbuddy",
4
4
  "description": "将本机登录的 WorkBuddy 模型(国内版与国际版)接入 DSH,每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。"
5
5
  }
6
6
  }
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": "5.0.0",
5
+ "version": "5.2.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",