dsh-connect-workbuddy 4.4.5 → 4.5.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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-Bj7JvSYJ.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-DvcOd-3C.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { readFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { join } from "node:path";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
@@ -1776,6 +1776,7 @@ function createWorkBuddyShim(options) {
1776
1776
  const failoverAccount = options.failoverAccount;
1777
1777
  const prepareAccount = options.prepareAccount;
1778
1778
  const onAccountFailure = options.onAccountFailure;
1779
+ const onRequestBody = options.onRequestBody;
1779
1780
  const SHARED_SECRET = randomBytes(32).toString("base64url");
1780
1781
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
1781
1782
  function bearerOk(req) {
@@ -1886,6 +1887,12 @@ function createWorkBuddyShim(options) {
1886
1887
  }
1887
1888
  const raw = (await readBody(req)).toString("utf8");
1888
1889
  const prepared = prepareChatBody(raw);
1890
+ const requestModelId = modelIdOfBody(prepared);
1891
+ if (onRequestBody !== void 0) try {
1892
+ onRequestBody(prepared);
1893
+ } catch (error) {
1894
+ logger?.warn("dsh-connect-workbuddy: body report failed", error);
1895
+ }
1889
1896
  const controller = new AbortController();
1890
1897
  res.on("close", () => controller.abort());
1891
1898
  let result = await client.chatStream(credential, prepared, controller.signal);
@@ -1929,7 +1936,8 @@ function createWorkBuddyShim(options) {
1929
1936
  onAccountFailure(entry.accountId, {
1930
1937
  status: entry.failure.status,
1931
1938
  kind: entry.failure.kind,
1932
- message: entry.failure.message
1939
+ message: entry.failure.message,
1940
+ ...requestModelId === void 0 ? {} : { modelId: requestModelId }
1933
1941
  });
1934
1942
  } catch (error) {
1935
1943
  logger?.warn("dsh-connect-workbuddy: recording an account failure failed", error);
@@ -2042,6 +2050,246 @@ function createWorkBuddyShim(options) {
2042
2050
  };
2043
2051
  }
2044
2052
  //#endregion
2053
+ //#region src/body-report.ts
2054
+ /**
2055
+ * Body-composition report: WHAT was sent upstream, in counts (issue #59).
2056
+ *
2057
+ * WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
2058
+ * ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
2059
+ * which turns a region switch into an instant `code 11115 prompt is too long`.
2060
+ * Two worlds explain that, and they have opposite owners:
2061
+ *
2062
+ * - the gateways count identical bytes differently (upstream's business); or
2063
+ * - the two requests did not carry the same messages (ours to fix).
2064
+ *
2065
+ * The second world was left standing by two independent measurements. The
2066
+ * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
2067
+ * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
2068
+ * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
2069
+ * identically. And a code trace showed historical `reasoning` never reaches the
2070
+ * wire at all: pi-ai only forwards `thinking` blocks as text when
2071
+ * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
2072
+ * `reasoning_details` route needs a signature the stream parser never writes.
2073
+ * With the counting difference excluded and reasoning excluded, the remaining
2074
+ * candidate is that the two bodies differ in the first place.
2075
+ *
2076
+ * This module exists to settle exactly that, and it can do it WITHOUT either
2077
+ * region having credit: the body is assembled locally, before the upstream ever
2078
+ * gets a chance to answer `14018 Credits exhausted`. Capture one composition per
2079
+ * region in the same session and diff them.
2080
+ *
2081
+ * It records COUNTS ONLY — never a byte of message text, no account name, no
2082
+ * token. That is a hard rule, not a default: the point is to compare shape
2083
+ * (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
2084
+ * and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
2085
+ * asserts the report cannot leak a fixture's text.
2086
+ */
2087
+ /**
2088
+ * Environment variable that enables the report.
2089
+ *
2090
+ * Unset or blank means OFF — a diagnostic that writes files must be opt-in.
2091
+ * `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
2092
+ * directory path, so a user can point it at an existing folder (and, on
2093
+ * Windows, one with spaces).
2094
+ */
2095
+ const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
2096
+ /**
2097
+ * Character ranges that tokenize (and therefore bill) like CJK.
2098
+ *
2099
+ * Deliberately generous: the caller's question is "how much of this is the ~3×
2100
+ * kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
2101
+ * class that is borderline costs far less than mis-attributing a whole bucket.
2102
+ * Ranges follow Unicode blocks, plus the compatibility forms and the two
2103
+ * extension planes where rare Han characters live.
2104
+ */
2105
+ const CJK_RANGES = [
2106
+ [11904, 12031],
2107
+ [12288, 12351],
2108
+ [12352, 12543],
2109
+ [13312, 19903],
2110
+ [19968, 40959],
2111
+ [63744, 64255],
2112
+ [65072, 65103],
2113
+ [65280, 65519],
2114
+ [131072, 195103]
2115
+ ];
2116
+ /** Whether one code point bills like CJK. */
2117
+ function isCjkCodePoint(code) {
2118
+ return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
2119
+ }
2120
+ /** Count characters (code points) and how many of them are CJK. */
2121
+ function countChars(text) {
2122
+ let chars = 0;
2123
+ let cjkChars = 0;
2124
+ for (const character of text) {
2125
+ chars += 1;
2126
+ const code = character.codePointAt(0);
2127
+ if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
2128
+ }
2129
+ return {
2130
+ chars,
2131
+ cjkChars
2132
+ };
2133
+ }
2134
+ /** Build one block report from already-extracted text. */
2135
+ function block(type, text) {
2136
+ return {
2137
+ type,
2138
+ ...countChars(text)
2139
+ };
2140
+ }
2141
+ /** Whether a value is a plain object (not null, not an array). */
2142
+ function isRecord(value) {
2143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2144
+ }
2145
+ /**
2146
+ * Describe one message's blocks.
2147
+ *
2148
+ * The role decides what a content string IS, and that is not cosmetic: a
2149
+ * `tool` message's payload is a tool result, so counting it as `text` would hide
2150
+ * the very bucket a long agentic session is mostly made of.
2151
+ */
2152
+ function describeMessage(message) {
2153
+ const role = typeof message["role"] === "string" ? message["role"] : "unknown";
2154
+ const blocks = [];
2155
+ const content = message["content"];
2156
+ /**
2157
+ * The block type a plain string carries, by the ROLE carrying it.
2158
+ *
2159
+ * A `tool` message's string is a tool RESULT and a `system` one is the system
2160
+ * prompt; calling both `text` would fold the biggest bucket of a long agentic
2161
+ * session into the smallest one, which is the opposite of what a composition
2162
+ * report is for.
2163
+ */
2164
+ const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
2165
+ if (typeof content === "string") {
2166
+ if (content !== "") blocks.push(block(stringType, content));
2167
+ } else if (Array.isArray(content)) for (const part of content) {
2168
+ if (!isRecord(part)) continue;
2169
+ const type = typeof part["type"] === "string" ? part["type"] : "unknown";
2170
+ if (type === "text") {
2171
+ const text = part["text"];
2172
+ if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
2173
+ continue;
2174
+ }
2175
+ blocks.push({
2176
+ type: type === "image_url" ? "image" : type,
2177
+ chars: 0,
2178
+ cjkChars: 0
2179
+ });
2180
+ }
2181
+ const toolCalls = message["tool_calls"];
2182
+ if (Array.isArray(toolCalls)) for (const call of toolCalls) {
2183
+ if (!isRecord(call)) continue;
2184
+ const fn = call["function"];
2185
+ const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
2186
+ const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
2187
+ blocks.push(block("tool-call", `${name}${args}`));
2188
+ }
2189
+ for (const field of [
2190
+ "reasoning_content",
2191
+ "reasoning",
2192
+ "reasoning_text"
2193
+ ]) {
2194
+ const value = message[field];
2195
+ if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
2196
+ }
2197
+ return {
2198
+ role,
2199
+ chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
2200
+ cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
2201
+ blocks
2202
+ };
2203
+ }
2204
+ /**
2205
+ * Describe one serialized request body.
2206
+ *
2207
+ * Returns `undefined` when the body is not a JSON object — a diagnostic must
2208
+ * never be the reason a chat request fails, so every caller treats `undefined`
2209
+ * as "nothing to report" rather than an error.
2210
+ */
2211
+ function describeBody(bodyJson, region, nowMs) {
2212
+ let parsed;
2213
+ try {
2214
+ parsed = JSON.parse(bodyJson);
2215
+ } catch {
2216
+ return;
2217
+ }
2218
+ if (!isRecord(parsed)) return void 0;
2219
+ const rawMessages = parsed["messages"];
2220
+ const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
2221
+ const rawTools = parsed["tools"];
2222
+ const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
2223
+ type: "tools",
2224
+ chars: 0,
2225
+ cjkChars: 0
2226
+ };
2227
+ const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
2228
+ const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
2229
+ return {
2230
+ at: new Date(nowMs).toISOString(),
2231
+ region,
2232
+ ...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
2233
+ bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
2234
+ messageCount: messages.length,
2235
+ chars,
2236
+ cjkChars,
2237
+ tools,
2238
+ messages
2239
+ };
2240
+ }
2241
+ /**
2242
+ * Resolve the directory the report writes to, or `undefined` when disabled.
2243
+ *
2244
+ * Exported for the same reason the other env-var resolvers in this plugin are:
2245
+ * the behaviour is worth a unit test, and the test must not have to set a real
2246
+ * process-wide variable to get one.
2247
+ */
2248
+ function bodyReportDirectory(env = process.env) {
2249
+ const configured = env[WORKBUDDY_BODY_REPORT_ENV];
2250
+ if (configured === void 0) return void 0;
2251
+ const trimmed = configured.trim();
2252
+ if (trimmed === "") return void 0;
2253
+ if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
2254
+ return trimmed;
2255
+ }
2256
+ /**
2257
+ * Append one composition to `<dir>/<region>.jsonl`.
2258
+ *
2259
+ * One file per region is the whole point: the reporter captures a session, then
2260
+ * diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
2261
+ * one file per request because a single turn makes many upstream calls (one per
2262
+ * tool step) and the LAST one is the interesting one.
2263
+ *
2264
+ * Read-modify-write is avoided on purpose — a long session would rewrite a
2265
+ * growing file on every step. The lock keeps two concurrent requests from
2266
+ * interleaving a line.
2267
+ */
2268
+ async function appendBodyReport(directory, report) {
2269
+ const file = join(directory, `${report.region}.jsonl`);
2270
+ await mkdir(directory, { recursive: true });
2271
+ const line = `${JSON.stringify(report)}\n`;
2272
+ await withFileLock(file, () => appendFile(file, line, "utf8"));
2273
+ }
2274
+ /**
2275
+ * Record one outgoing body, if the report is enabled.
2276
+ *
2277
+ * NEVER THROWS and never awaits long: this is called on the request path, and a
2278
+ * chat request must not fail (or stall) because a diagnostic could not write a
2279
+ * file. A failure is reported through `onError` so the caller can log it.
2280
+ */
2281
+ async function recordBodyReport(region, bodyJson, options = {}) {
2282
+ const directory = bodyReportDirectory(options.env);
2283
+ if (directory === void 0) return;
2284
+ try {
2285
+ const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
2286
+ if (report === void 0) return;
2287
+ await appendBodyReport(directory, report);
2288
+ } catch (error) {
2289
+ options.onError?.(error);
2290
+ }
2291
+ }
2292
+ //#endregion
2045
2293
  //#region src/probe.ts
2046
2294
  /**
2047
2295
  * How long to bench a MODEL that answered 14003 with no stated time.
@@ -2504,7 +2752,22 @@ async function testAllAccounts(targets, modelId, deps, onRow) {
2504
2752
  const rows = [];
2505
2753
  for (const [index, target] of targets.entries()) {
2506
2754
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2507
- const row = await testOne(target, modelId, deps);
2755
+ const wanted = typeof modelId === "function" ? modelId(target.accountId) : { modelId };
2756
+ if (wanted.modelId === void 0) {
2757
+ const row = {
2758
+ accountId: target.accountId,
2759
+ accountName: target.accountName,
2760
+ result: {
2761
+ modelId: wanted.staleModelId ?? "",
2762
+ outcome: "unavailable",
2763
+ message: wanted.staleModelId === void 0 ? "this region offers no model to test" : `the test model "${wanted.staleModelId}" is no longer offered in this region`
2764
+ }
2765
+ };
2766
+ rows.push(row);
2767
+ onRow?.(row);
2768
+ continue;
2769
+ }
2770
+ const row = await testOne(target, wanted.modelId, deps);
2508
2771
  rows.push(row);
2509
2772
  onRow?.(row);
2510
2773
  }
@@ -2567,6 +2830,7 @@ function probeUpdatesOf(rows) {
2567
2830
  outcome: row.result.outcome,
2568
2831
  atMs,
2569
2832
  source: "test-batch",
2833
+ modelId: row.result.modelId,
2570
2834
  ...row.accountName === "" ? {} : { accountName: row.accountName },
2571
2835
  ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
2572
2836
  ...row.result.message === void 0 ? {} : { message: row.result.message }
@@ -2810,6 +3074,30 @@ function pickFreeModel(catalog) {
2810
3074
  * That is the safe direction: no credits are spent while the roster is unknown,
2811
3075
  * and the card offers to switch back to automatic.
2812
3076
  */
3077
+ /**
3078
+ * The model ONE account's test should target (issue #61).
3079
+ *
3080
+ * The region-wide `targetModelId` is one value, but `6004` is an ACCOUNT × MODEL
3081
+ * condition: the reporter measured two accounts where `hy4-preview-f` answered
3082
+ * `ok` and `step-5-preview` returned `429 / 6004` at the same moment. Testing
3083
+ * only the region's model therefore cannot answer "can this account serve the
3084
+ * model I actually use", which is the question the pool's test button is read as
3085
+ * answering.
3086
+ *
3087
+ * A per-account override is the smallest thing that makes the question
3088
+ * answerable, and it is additive: an account with no entry keeps using
3089
+ * `targetModelId`, so every existing profile behaves identically.
3090
+ *
3091
+ * Returns a PREFERENCE, not a resolved target — `resolveTargetModel` still
3092
+ * validates it against the catalog, so an override pointing at a model this
3093
+ * region no longer offers is reported as stale rather than silently swapped for
3094
+ * one the user did not choose.
3095
+ */
3096
+ function targetModelPreference(preferences, accountId) {
3097
+ const perAccount = accountId === void 0 ? void 0 : preferences.targetModelIdByAccount?.[accountId];
3098
+ if (perAccount !== void 0 && perAccount !== "") return perAccount;
3099
+ return preferences.targetModelId;
3100
+ }
2813
3101
  function resolveTargetModel(catalog, preferredId) {
2814
3102
  if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
2815
3103
  modelId: preferredId,
@@ -3919,6 +4207,7 @@ async function workBuddyWebStatus(deps, region) {
3919
4207
  deps.regionUsable?.(region, accounts.length > 0);
3920
4208
  const enabled = deps.regionEnabled(region);
3921
4209
  const showCreditsInMainUi = deps.showCreditsInMainUi?.(region) ?? true;
4210
+ const creditPosition = deps.creditPosition?.(region) ?? "composer";
3922
4211
  const authStatus = await store.status();
3923
4212
  const selectionExplicit = store.hasExplicitSelection();
3924
4213
  if (authStatus.state !== "signed-out" && accounts.length === 0) return {
@@ -3940,6 +4229,7 @@ async function workBuddyWebStatus(deps, region) {
3940
4229
  selectionExplicit,
3941
4230
  enabled,
3942
4231
  showCreditsInMainUi,
4232
+ creditPosition,
3943
4233
  ...await store.selectionLost() ? { selectionLost: true } : {},
3944
4234
  ...webAccounts.length === 0 ? await searchedPaths(store) : {}
3945
4235
  };
@@ -3955,6 +4245,7 @@ async function workBuddyWebStatus(deps, region) {
3955
4245
  selectionExplicit,
3956
4246
  enabled,
3957
4247
  showCreditsInMainUi,
4248
+ creditPosition,
3958
4249
  accounts: accounts.map(toWebAccount),
3959
4250
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))),
3960
4251
  enabledModelIds: [...deps.enabledModelIds(region)],
@@ -4014,7 +4305,8 @@ async function workBuddyWebPool(deps, region) {
4014
4305
  const preferences = pool.preferences(region);
4015
4306
  const members = await pool.members(region);
4016
4307
  const ranked = rankPool(members, Date.now());
4017
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
4308
+ const catalog = pool.catalog?.(region) ?? [];
4309
+ const target = resolveTargetModel(catalog, preferences.targetModelId);
4018
4310
  const currentAccountId = await pool.currentAccountId?.(region);
4019
4311
  const byId = new Map(members.map((member) => [member.account.id, member]));
4020
4312
  const rankedIds = new Set(ranked.map((row) => row.account.id));
@@ -4053,8 +4345,17 @@ async function workBuddyWebPool(deps, region) {
4053
4345
  atMs: measured.probe.atMs,
4054
4346
  ...measured.probe.source === void 0 ? {} : { source: measured.probe.source },
4055
4347
  ...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs },
4056
- ...measured.probe.message === void 0 ? {} : { message: measured.probe.message }
4348
+ ...measured.probe.message === void 0 ? {} : { message: measured.probe.message },
4349
+ ...measured.probe.modelId === void 0 ? {} : { modelId: measured.probe.modelId }
4057
4350
  } },
4351
+ ...(() => {
4352
+ const perRow = resolveTargetModel(catalog, targetModelPreference(preferences, account.id));
4353
+ return {
4354
+ ...perRow.modelId === void 0 ? {} : { testModelId: perRow.modelId },
4355
+ testModelSource: perRow.source,
4356
+ ...perRow.staleModelId === void 0 ? {} : { staleTestModelId: perRow.staleModelId }
4357
+ };
4358
+ })(),
4058
4359
  ...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
4059
4360
  current: account.id === currentAccountId,
4060
4361
  member,
@@ -4067,6 +4368,7 @@ async function workBuddyWebPool(deps, region) {
4067
4368
  ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
4068
4369
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
4069
4370
  targetModelSource: target.source,
4371
+ targetModelIdByAccount: preferences.targetModelIdByAccount ?? {},
4070
4372
  memberAccountIds: preferences.memberAccountIds,
4071
4373
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4072
4374
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
@@ -4310,7 +4612,20 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4310
4612
  reason: "pool-unavailable",
4311
4613
  error: "pool test unavailable"
4312
4614
  });
4313
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], pool.preferences(region).targetModelId);
4615
+ const preferences = pool.preferences(region);
4616
+ const catalog = pool.catalog?.(region) ?? [];
4617
+ /**
4618
+ * The model ONE member is tested on (issue #61).
4619
+ *
4620
+ * Per account, because a test model is a per-account choice: `6004` is
4621
+ * an account × model condition, so a batch that tested every member on
4622
+ * one model would report "可用" for an account whose own model is the
4623
+ * one that is limited. Resolution still runs through
4624
+ * `resolveTargetModel`, so a stale override is reported, never
4625
+ * silently swapped.
4626
+ */
4627
+ const targetFor = (accountId) => resolveTargetModel(catalog, targetModelPreference(preferences, accountId));
4628
+ const target = resolveTargetModel(catalog, targetModelPreference(preferences, void 0));
4314
4629
  if (target.modelId === void 0) return json(res, 409, {
4315
4630
  action: "test",
4316
4631
  modelId: void 0,
@@ -4332,7 +4647,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4332
4647
  modelId: target.modelId
4333
4648
  });
4334
4649
  try {
4335
- await pool.test(region, target.modelId, (row) => {
4650
+ await pool.test(region, targetFor, (row) => {
4336
4651
  writeLine({ row });
4337
4652
  });
4338
4653
  writeLine({ done: true });
@@ -4670,6 +4985,7 @@ const modelConfig = z.object({
4670
4985
  const poolConfig = z.object({
4671
4986
  enabled: z.boolean().default(false).description("Whether this region's account pool is active (opt-in). While on, a failed chat request is retried against the other usable pool members before the error is reported."),
4672
4987
  targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
4988
+ targetModelIdByAccount: z.dict(z.string()).default({}).description("Per-account test model, keyed by account id; an account with no entry uses targetModelId. Use this when an account must be tested on the model you actually chat with, because 6004 is an account-by-model condition."),
4673
4989
  /**
4674
4990
  * Sizing of the test probe, as a token count the user picks from a fixed set.
4675
4991
  *
@@ -4700,6 +5016,7 @@ const regionStateConfig = z.object({
4700
5016
  * which is reachable exactly where the feature renders.
4701
5017
  */
4702
5018
  showCreditsInMainUi: z.boolean().default(true).description("Show this region's remaining credits at the DSH sidebar foot (opt-out; the line carries its own hide button)"),
5019
+ creditPosition: z.union([z.const("composer"), z.const("stats")]).default("composer").description("Where the credit readout sits: \"composer\" = the composer tool row (default), \"stats\" = the ambient row below the composer card, next to the shell's own token pills. Choose \"stats\" if another plugin occupies the composer row."),
4703
5020
  lastCatalog: z.array(modelConfig).default([]),
4704
5021
  enabledModelIds: z.array(z.string()).default([]),
4705
5022
  imageModelIds: z.array(z.string()).default([]),
@@ -4927,6 +5244,9 @@ function apply(ctx, config) {
4927
5244
  * traffic once the provider is registered, which happens after this whole
4928
5245
  * setup completes.
4929
5246
  */
5247
+ onRequestBody: (body) => {
5248
+ recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) });
5249
+ },
4930
5250
  prepareAccount: () => applyPoolSelection(region),
4931
5251
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
4932
5252
  onAccountFailure: (accountId, failure) => {
@@ -5089,6 +5409,13 @@ function apply(ctx, config) {
5089
5409
  */
5090
5410
  showCreditsInMainUi: (region) => regionStateOf(current(), region).showCreditsInMainUi !== false,
5091
5411
  /**
5412
+ * WHERE the readout sits, resolved here rather than read from the browser
5413
+ * settings mirror, for the same measured reason as the flag above: on the
5414
+ * affected 0.1.7 deployments that mirror never picks up this plugin's
5415
+ * writes, and a control reading it would keep showing the old seat.
5416
+ */
5417
+ creditPosition: (region) => creditPositionOf(current(), region),
5418
+ /**
5092
5419
  * One minimal request per named model, through the region's own credential.
5093
5420
  *
5094
5421
  * Resolved here rather than in the route so the browser half never touches a
@@ -5260,7 +5587,7 @@ function apply(ctx, config) {
5260
5587
  * rather than pretending a test ran.
5261
5588
  */
5262
5589
  testOne: async (region, accountId) => {
5263
- const target = regionTargetModel(region);
5590
+ const target = regionTargetModel(region, accountId);
5264
5591
  if (target === void 0) return void 0;
5265
5592
  const store = stacks[region].store;
5266
5593
  const credential = await store.credentialFor(accountId).catch(() => void 0);
@@ -5535,6 +5862,7 @@ function apply(ctx, config) {
5535
5862
  outcome,
5536
5863
  atMs: Date.now(),
5537
5864
  source: "live-request",
5865
+ ...failure.modelId === void 0 ? {} : { modelId: failure.modelId },
5538
5866
  ...retryAtMs === void 0 ? {} : { retryAtMs },
5539
5867
  ...message === "" ? {} : { message }
5540
5868
  } });
@@ -5625,19 +5953,29 @@ function apply(ctx, config) {
5625
5953
  /**
5626
5954
  * The model a per-row test would use right now: the SAME resolution the
5627
5955
  * batch route performs (`resolveTargetModel` over the region's displayed
5628
- * catalog and the saved preference), so a row test and a batch test never
5629
- * measure different models. Undefined mirrors the route's 409 states.
5956
+ * catalog and the saved preference — including a per-account override,
5957
+ * issue #61), so a row test and a batch test never measure different models.
5958
+ * Undefined mirrors the route's 409 states.
5630
5959
  */
5631
- const regionTargetModel = (region) => {
5632
- return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5960
+ const regionTargetModel = (region, accountId) => {
5961
+ return resolveTargetModel(displayModels(current(), region), targetModelPreference(poolPreferencesOf(current(), region), accountId)).modelId;
5633
5962
  };
5634
5963
  /** One region's pool preferences, with the schema's defaults applied. */
5964
+ /**
5965
+ * WHERE this region's credit readout sits (issue #62).
5966
+ *
5967
+ * A local reader rather than a schema default so a config written before the
5968
+ * setting existed reads as `composer` — the seat that was the only one, so an
5969
+ * upgrade moves nothing on screen.
5970
+ */
5971
+ const creditPositionOf = (config, region) => regionStateOf(config, region).creditPosition === "stats" ? "stats" : "composer";
5635
5972
  const poolPreferencesOf = (config, region) => {
5636
5973
  const pool = regionStateOf(config, region).pool;
5637
5974
  const probeInputTokens = pool?.probeInputTokens;
5638
5975
  return {
5639
5976
  enabled: pool?.enabled === true,
5640
5977
  targetModelId: pool?.targetModelId ?? "",
5978
+ targetModelIdByAccount: { ...pool?.targetModelIdByAccount },
5641
5979
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
5642
5980
  memberAccountIds: pool?.memberAccountIds ?? []
5643
5981
  };
@@ -5812,4 +6150,4 @@ function apply(ctx, config) {
5812
6150
  });
5813
6151
  }
5814
6152
  //#endregion
5815
- 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, 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 };
6153
+ 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 };
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": "4.4.5",
5
+ "version": "4.5.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",