dsh-workbuddy-bridge 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,10 +47,11 @@
47
47
 
48
48
  - **开箱即用** —— 装完启用,模型分组自己出现。
49
49
  - **两版隔离** —— 国内版与国际版各自分组,模型、账号、积分互不混用,只跟随各自 App 的登录状态。
50
- - **推理档位** —— 上游声明了档位的模型直接可选;没声明的可在插件页**手动检测**。
51
50
  - **推理档位** —— 上游声明了档位的模型直接可选;没声明的可在插件页**手动检测**。
52
51
  灯泡表示能切档位;上游已声明的模型也亮着,只是不需要检测。
53
52
  能切档位的模型会带一个**默认档位**(上游声明了就按声明,否则取 `high`),因此选择器里不再出现「Default」这一项。
53
+ 档位里的 **Off 表示「不发送档位参数」**:两个接口都不接受这个字面量,所以插件不会把它发出去;
54
+ 实际是否关闭思考由上游决定,插件不做保证。
54
55
  - **积分可见** —— 每条回复下方显示本次消耗,模型选择器旁常驻剩余积分。拿不到数字时不显示。
55
56
  - **模型显隐** —— 卡片「模型」标签里勾选哪些模型出现在选择器里,按登录账号分别保存。
56
57
  - **图片输入** —— 多数模型可直接粘贴或拖入图片;纯文字模型会明确说明。
package/README_en.md CHANGED
@@ -54,6 +54,8 @@ models appear directly in the DSH model picker — no separate provider to confi
54
54
  on the plugin page. The bulb means "this model can switch levels"; a declared model shows it too, it just
55
55
  has nothing to detect. A switchable model also carries a **default level** (the upstream's own declaration
56
56
  when it has one, otherwise `high`), so the picker no longer offers "Default".
57
+ **Off means "send no level parameter"**: neither endpoint accepts that literal, so the plugin never puts it
58
+ on the wire — whether thinking is then actually disabled is up to the upstream, and the plugin makes no claim.
57
59
  - **Credit in view** — each reply shows what it consumed, and remaining credit sits beside the model picker.
58
60
  Nothing renders when a figure is unavailable.
59
61
  - **Model visibility** — tick which models appear in the picker on the card's "Models" tab, saved per
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as variantFor, D as WorkBuddyUpstreamClient, S as WORKBUDDY_VARIANTS, Y as resolveAppVersion, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-BpW2mSgI.js";
2
+ import { C as variantFor, D as WorkBuddyUpstreamClient, S as WORKBUDDY_VARIANTS, X as resolveAppVersion, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-6xWs7ak3.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/cli/bin.ts
@@ -366,13 +366,15 @@ async function resolveGlobalIdentity(options) {
366
366
  const UPSTREAM_CODE_KINDS = {
367
367
  "12153": "session_dead",
368
368
  "11128": "client",
369
- "11133": "client"
369
+ "11133": "client",
370
+ "11140": "client"
370
371
  };
371
372
  /** 业务码 → 失败类别:信封 code 非 0 时的第一落点。 */
372
373
  const ENVELOPE_CODE_KINDS = {
373
374
  12153: "session_dead",
374
375
  11128: "client",
375
- 11133: "client"
376
+ 11133: "client",
377
+ 11140: "client"
376
378
  };
377
379
  /**
378
380
  * 文案兜底标记。**只在既无机器码也无业务码可依时使用**,且命中后必须记日志:
@@ -396,8 +398,43 @@ const TEXT_MARKERS = [
396
398
  ["offline user session not found", "session_dead"],
397
399
  ["12153", "session_dead"],
398
400
  ["11128", "client"],
399
- ["11133", "client"]
401
+ ["11133", "client"],
402
+ ["11140", "client"]
400
403
  ];
404
+ /**
405
+ * 上游自己的、面向用户的文案:`displayMsg` 优先,其次是 `msg`。
406
+ *
407
+ * 上游把「真正该给人看的那句话」放在 `displayMsg` 里(分别给出 `zh` / `zh-hant` /
408
+ * `en` 三种拼写),而 `msg` 常常是一句机器可读的英文短语(`request illegal`)。
409
+ * 把 `displayMsg` 丢掉的代价不是"少一句说明":`msg` 本身看不出是封号、限流还是
410
+ * 参数错,使用者只能看到一句无从下手的英文。
411
+ *
412
+ * 读取顺序固定在 `zh` → `zh-hant` → `en`:国内网关的 zh 文案是原始表述,en 是
413
+ * 它的译文;先中文,只有上游没给中文时才退回英文。
414
+ *
415
+ * @param body - 响应体原文。
416
+ * @returns 干净的、可直接展示的一行文案;读不出来返回 undefined。
417
+ */
418
+ function upstreamDisplayMessage(body) {
419
+ const parsed = parseJson(body);
420
+ if (!isRecord(parsed)) return void 0;
421
+ const display = parsed["displayMsg"];
422
+ if (isRecord(display)) for (const key of [
423
+ "zh",
424
+ "zh-hant",
425
+ "en"
426
+ ]) {
427
+ const value = display[key];
428
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
429
+ }
430
+ const msg = parsed["msg"];
431
+ if (typeof msg === "string" && msg.trim() !== "") return msg.trim();
432
+ const extError = parsed["extError"];
433
+ if (isRecord(extError)) {
434
+ const message = extError["message"];
435
+ if (typeof message === "string" && message.trim() !== "") return message.trim();
436
+ }
437
+ }
401
438
  /** 上游错误体:只取结构化字段,其余一概不看。 */
402
439
  const ErrorBody = z.object({
403
440
  extError: z.object({
@@ -494,6 +531,10 @@ function parseJson(body) {
494
531
  return;
495
532
  }
496
533
  }
534
+ /** 是否是一个可按键读取的对象(数组与 null 都不算)。 */
535
+ function isRecord(value) {
536
+ return typeof value === "object" && value !== null && !Array.isArray(value);
537
+ }
497
538
  //#endregion
498
539
  //#region src/probe/probe.ts
499
540
  /**
@@ -538,15 +579,31 @@ function randomSentinel() {
538
579
  return `probe_sentinel_${randomBytes(12).toString("hex")}`;
539
580
  }
540
581
  /**
541
- * The upstream's "this effort value is not supported" code, measured
542
- * 2026-09-11 (plan §4.2). It is *not* treated as a permanent protocol promise:
543
- * anything unrecognized degrades to `unknown` rather than to a capability
544
- * conclusion.
582
+ * The upstream's "this effort value is not supported" code.
583
+ *
584
+ * Measured 2026-09-11 (plan §4.2) as `invalid_reasoning_effort`. It is *not* a
585
+ * permanent protocol promise: the upstream has since respelled it to the
586
+ * generic `invalid_parameter_value` on both endpoints, which is why that
587
+ * spelling is recognized too. Anything unrecognized still degrades to
588
+ * `unknown` rather than to a capability conclusion.
545
589
  */
546
590
  const INVALID_EFFORT_CODE = "invalid_reasoning_effort";
591
+ /**
592
+ * Generic rejection code the upstream now uses for every bad field.
593
+ *
594
+ * Because it names no field, a body carrying it is only attributed to the
595
+ * effort when `extError.param` points at the effort; otherwise a rejected
596
+ * `max_tokens` would be misread as "this level is unsupported" and shrink the
597
+ * reported capability set.
598
+ */
599
+ const GENERIC_INVALID_CODE = "invalid_parameter_value";
547
600
  /** Whether an attempt is an attributable rejection of the effort value. */
548
601
  function isEffortRejection(attempt) {
549
- return attempt.status === 400 && attempt.errorCode === INVALID_EFFORT_CODE;
602
+ if (attempt.status !== 400) return false;
603
+ const code = attempt.errorCode;
604
+ if (code === INVALID_EFFORT_CODE) return true;
605
+ if (code !== GENERIC_INVALID_CODE) return false;
606
+ return (attempt.param ?? "").toLowerCase().includes("effort");
550
607
  }
551
608
  /** Whether an attempt shows the upstream accepted the request and streamed. */
552
609
  function isAcceptance(attempt) {
@@ -828,13 +885,22 @@ function billingHeaders(credential) {
828
885
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
829
886
  * force `stream: true` (the upstream rejects non-streaming), flatten
830
887
  * `tool_choice` (the upstream's field is a string; object forms return 400),
831
- * and rewrite `developer` messages as `system`.
888
+ * rewrite `developer` messages as `system`, neutralize the channel signature
889
+ * (see {@link neutralizeChannelSignature}), and drop the adapter's own `off`
890
+ * effort spelling (see {@link dropUnsupportedEffort}).
832
891
  *
833
892
  * The `developer` rewrite is load-bearing: pi-ai emits the system prompt as
834
893
  * `role: "developer"` (the OpenAI convention it adopted), but the WorkBuddy
835
894
  * upstream rejects that role with HTTP 400 code 11128 ("Illegal API
836
895
  * invocation from an unapproved channel"). Rewriting to `system` is the
837
896
  * compatible spelling the upstream accepts.
897
+ *
898
+ * Every transformation here is region-agnostic on purpose. The two endpoints
899
+ * differ in what they *add* (the international one also needs a leading system
900
+ * prompt — see {@link prepareInternationalChatBody}), not in what they reject:
901
+ * both refuse the picker's `off` spelling, which is why that strip lives here
902
+ * rather than in the international layer (issue #13 reversed the earlier
903
+ * international-only scope decision, which rested on CN accepting it).
838
904
  */
839
905
  function prepareChatBody(source) {
840
906
  let body;
@@ -847,9 +913,87 @@ function prepareChatBody(source) {
847
913
  const obj = body;
848
914
  obj["stream"] = true;
849
915
  normalizeDeveloperRole(obj);
916
+ neutralizeChannelSignature(obj);
850
917
  normalizeToolChoice(obj);
918
+ dropUnsupportedEffort(obj);
851
919
  return JSON.stringify(obj);
852
920
  }
921
+ /**
922
+ * Strip the product-identity phrase the CN gateway reads as an unofficial
923
+ * client signature.
924
+ *
925
+ * Measured 2026-10-21 on `copilot.tencent.com/v2/chat/completions`: a request
926
+ * whose **system/developer** message carries the phrase `DeepSeek Harness`
927
+ * answers `400` / `11128` "Illegal API invocation from an unapproved channel",
928
+ * while the same account, headers and body with the phrase rewritten answers
929
+ * 200. DSH injects exactly that phrase as the head of its system prompt
930
+ * ("You are an AI agent powered by DeepSeek Harness."), so every request the
931
+ * harness sends is affected; the official desktop app never sends it.
932
+ *
933
+ * Two rewrites, in this order, because they solve different halves of it:
934
+ *
935
+ * 1. **The opener**, when a message starts with it, becomes a plain
936
+ * "You are an AI agent." — a readable sentence that survives upstream
937
+ * normalization (unlike an invisible character, which a tokenizer or a
938
+ * normalizer may simply drop, and which the same measurement showed the
939
+ * gateway still rejects once an account is restricted).
940
+ * 2. **Any other occurrence** gets a zero-width space between the two words:
941
+ * lossless, the model reads the same thing, and the substring match no
942
+ * longer fires.
943
+ *
944
+ * **Path contexts are never rewritten** (`…\DeepSeek Harness\…`,
945
+ * `…/DeepSeek Harness/…`): a real directory name carries this phrase, and a
946
+ * zero-width space inside it would turn every tool call that quotes the path
947
+ * into a silently wrong path. Both rewrites anchor on the surrounding slashes
948
+ * for that reason.
949
+ *
950
+ * Only `system` / `developer` messages are touched: the same measurement shows
951
+ * user messages are not inspected, and rewriting user content would corrupt
952
+ * data the plugin has no business editing. Idempotent — a second pass finds
953
+ * neither the opener nor a bare phrase.
954
+ */
955
+ function neutralizeChannelSignature(obj) {
956
+ const messages = obj["messages"];
957
+ if (!Array.isArray(messages)) return;
958
+ for (const message of messages) {
959
+ if (!isObject(message)) continue;
960
+ const role = message["role"];
961
+ if (role !== "system" && role !== "developer") continue;
962
+ const content = message["content"];
963
+ if (typeof content === "string") {
964
+ message["content"] = neutralizeSignatureText(content);
965
+ continue;
966
+ }
967
+ if (!Array.isArray(content)) continue;
968
+ for (const part of content) {
969
+ if (!isObject(part) || part["type"] !== "text") continue;
970
+ const text = part["text"];
971
+ if (typeof text === "string") part["text"] = neutralizeSignatureText(text);
972
+ }
973
+ }
974
+ }
975
+ /** Apply the two signature rewrites to one string; see {@link neutralizeChannelSignature}. */
976
+ function neutralizeSignatureText(text) {
977
+ return text.replace(CHANNEL_OPENER, "$1You are an AI agent.").replace(CHANNEL_SIGNATURE_PHRASE, `DeepSeek${ZERO_WIDTH_SPACE}$1Harness`);
978
+ }
979
+ /**
980
+ * The opener DSH puts at the head of every system prompt.
981
+ *
982
+ * Leading whitespace and a BOM are tolerated (group 1 carries them through to
983
+ * the replacement, so the rewrite never touches anything but the sentence), and
984
+ * every gap between the words is matched as whitespace so a respelling cannot
985
+ * walk past it. Case-insensitive for the same reason.
986
+ */
987
+ const CHANNEL_OPENER = /^([\s\uFEFF]*)You\s+are\s+an\s+AI\s+agent\s+powered\s+by\s+DeepSeek[\s\u00A0]+Harness\s*\./i;
988
+ /**
989
+ * The bare phrase, with slash guards on both sides so a real path survives.
990
+ *
991
+ * The capture keeps whatever whitespace the original used, so the rewritten
992
+ * sentence reads identically.
993
+ */
994
+ const CHANNEL_SIGNATURE_PHRASE = /(?<![\\/])DeepSeek([\s\u00A0]+)Harness(?![\\/])/gi;
995
+ /** U+200B: invisible to a reader and to the model, breaks a substring match. */
996
+ const ZERO_WIDTH_SPACE = "​";
853
997
  /** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
854
998
  function normalizeDeveloperRole(obj) {
855
999
  const messages = obj["messages"];
@@ -937,7 +1081,20 @@ var WorkBuddyUpstreamClient = class {
937
1081
  this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
938
1082
  this.resolveChatIdentity = options.resolveChatIdentity ?? ((region) => resolveChatIdentity(region));
939
1083
  }
940
- /** POST the chat endpoint; a successful answer is the raw SSE response. */
1084
+ /**
1085
+ * POST the chat endpoint; a successful answer is the raw SSE response.
1086
+ *
1087
+ * This is the one place every chat request converges on (the loopback shim,
1088
+ * the host LLM route, and any direct caller all end up here), so it is also
1089
+ * where the body is normalized for the upstream — in *both* regions. The
1090
+ * region only decides which extra requirement applies on top: the
1091
+ * international gateway additionally demands a leading system message. It
1092
+ * used to decide whether the body was normalized at all, which left the CN
1093
+ * path sending the picker's `off` spelling verbatim (issue #13).
1094
+ *
1095
+ * Normalization is idempotent, so a caller that already prepared the body
1096
+ * (the shim does) sees no second change.
1097
+ */
941
1098
  async chatStream(credential, bodyJson, signal) {
942
1099
  const region = regionOf(credential.domain);
943
1100
  let userAgent;
@@ -946,6 +1103,7 @@ var WorkBuddyUpstreamClient = class {
946
1103
  } catch {
947
1104
  userAgent = chatUserAgent(fallbackChatIdentity(region), region);
948
1105
  }
1106
+ const body = region === "global" ? prepareInternationalChatBody(bodyJson) : prepareChatBody(bodyJson);
949
1107
  let response;
950
1108
  try {
951
1109
  response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
@@ -954,7 +1112,7 @@ var WorkBuddyUpstreamClient = class {
954
1112
  ...chatHeaders(credential, userAgent),
955
1113
  Authorization: `Bearer ${credential.accessToken}`
956
1114
  },
957
- body: region === "global" ? prepareInternationalChatBody(bodyJson) : bodyJson,
1115
+ body,
958
1116
  ...signal === void 0 ? {} : { signal }
959
1117
  });
960
1118
  } catch (error) {
@@ -1315,7 +1473,14 @@ var WorkBuddyUpstreamClient = class {
1315
1473
  };
1316
1474
  }
1317
1475
  };
1318
- /** Pull `extError.code` out of an upstream error body, if it is shaped that way. */
1476
+ /**
1477
+ * Pull the structured failure fields out of an upstream error body.
1478
+ *
1479
+ * `extError.param` matters as much as the code: the upstream reuses one generic
1480
+ * code (`invalid_parameter_value`) for every rejected field, so the parameter
1481
+ * name is the only thing that attributes the rejection to `reasoning.effort`
1482
+ * rather than to `max_tokens` or anything else.
1483
+ */
1319
1484
  function errorCodeOf(text) {
1320
1485
  try {
1321
1486
  const parsed = JSON.parse(text);
@@ -1323,7 +1488,12 @@ function errorCodeOf(text) {
1323
1488
  const extError = parsed["extError"];
1324
1489
  if (typeof extError === "object" && extError !== null && !Array.isArray(extError)) {
1325
1490
  const code = extError["code"];
1326
- if (typeof code === "string") return {
1491
+ const param = extError["param"];
1492
+ if (typeof code === "string") return typeof param === "string" ? {
1493
+ errorCode: code,
1494
+ detail: code,
1495
+ param
1496
+ } : {
1327
1497
  errorCode: code,
1328
1498
  detail: code
1329
1499
  };
@@ -1498,6 +1668,11 @@ function modelWithCurrentPromotion(model, now = Date.now()) {
1498
1668
  * merged: existing messages keep their order and wording. A body that is not a
1499
1669
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
1500
1670
  * so this is safe to run over an already-prepared-or-not body.
1671
+ *
1672
+ * This layer now carries *only* that requirement. The `reasoning_effort` strip
1673
+ * used to live here, scoped to this region; issue #13 showed CN rejects the
1674
+ * same spelling, so it moved into the shared {@link prepareChatBody} and this
1675
+ * function no longer touches the effort field at all.
1501
1676
  */
1502
1677
  function prepareInternationalChatBody(source) {
1503
1678
  const prepared = prepareChatBody(source);
@@ -1508,7 +1683,6 @@ function prepareInternationalChatBody(source) {
1508
1683
  return prepared;
1509
1684
  }
1510
1685
  if (!isObject(body)) return prepared;
1511
- dropUnsupportedEffort(body);
1512
1686
  const messages = body["messages"];
1513
1687
  if (!Array.isArray(messages)) return JSON.stringify(body);
1514
1688
  const first = messages[0];
@@ -1520,25 +1694,35 @@ function prepareInternationalChatBody(source) {
1520
1694
  return JSON.stringify(body);
1521
1695
  }
1522
1696
  /**
1523
- * Remove the adapter's own `off` effort spelling from the **international** wire.
1524
- *
1525
- * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1526
- * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1527
- * sends that value for any request carrying no explicit level, and the
1528
- * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1529
- * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1530
- * form measured good on every such model; a literal `'none'` is *not* a safe
1531
- * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1532
- * `gpt-6-astra`.
1533
- *
1534
- * Consequences, stated honestly: the international picker still offers Off,
1535
- * but selecting it now means "the field is omitted" — the model's actual
1536
- * behaviour is decided upstream and is *not* guaranteed to disable thinking
1537
- * or to match the catalog's `defaultEffort`. Declared spellings
1538
- * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1539
- * untouched. The CN variant is deliberately unaffected: its endpoint has
1540
- * accepted this spelling in every measurement so far, and keeping its wire
1541
- * unchanged is a scope decision, not a claim about that endpoint's future.
1697
+ * Remove the adapter's own `off` effort spelling from the wire, in both regions.
1698
+ *
1699
+ * `thinkingLevelMap.off` is pinned to the literal `'off'` so the level stays
1700
+ * selectable in the picker — pi-ai exposes a level only when its entry is a
1701
+ * string — but that same entry is also the value pi-ai puts on the wire, both
1702
+ * for an explicit Off selection and for *every* request that carries no level
1703
+ * at all. `'off'` is a picker name, not an upstream spelling, and both
1704
+ * endpoints reject it:
1705
+ *
1706
+ * - international GPT family: HTTP 400 `11133` / `extError.param ===
1707
+ * 'reasoning.effort'` (issue #49);
1708
+ * - CN: the same 400 `11133` on every `canDisableThinking` model — measured
1709
+ * on `deepseek-v4.1-flash`, where the author also confirmed `'disabled'`
1710
+ * is rejected and `'none'` answers 200 (issue #13). This is what made the
1711
+ * earlier international-only scope decision obsolete.
1712
+ *
1713
+ * Omission is the only form measured good on every affected model, so it is
1714
+ * the only form this function can produce; a literal `'none'` is *not* a safe
1715
+ * substitute (accepted by the GPT-5.6 family and GLM, rejected by
1716
+ * `gpt-6-astra`, and silently ignored by `kimi-k2.8-preview`). Declared
1717
+ * spellings (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass
1718
+ * through untouched — the adapter does not decide a user's `none` for them.
1719
+ *
1720
+ * Consequences, stated honestly: the picker still offers Off in both regions,
1721
+ * but selecting it means "the field is omitted". The model's actual behaviour
1722
+ * is then decided upstream — *not* guaranteed to disable thinking and not
1723
+ * guaranteed to match the catalog's `defaultEffort`. The plugin used to claim
1724
+ * more than that on CN, where `'off'` was merely accepted (accepted never
1725
+ * meant effective); #13 is why the claim is withdrawn rather than reworded.
1542
1726
  */
1543
1727
  function dropUnsupportedEffort(obj) {
1544
1728
  if (obj["reasoning_effort"] === "off") delete obj["reasoning_effort"];
@@ -3688,7 +3872,7 @@ var WorkBuddyCatalog = class {
3688
3872
  };
3689
3873
  //#endregion
3690
3874
  //#region src/version.ts
3691
- const WORKBUDDY_BRIDGE_VERSION = "0.5.0";
3875
+ const WORKBUDDY_BRIDGE_VERSION = "0.5.2";
3692
3876
  //#endregion
3693
3877
  //#region src/web/heartbeat.ts
3694
3878
  /**
@@ -3859,4 +4043,4 @@ function isHeartbeatProcessAlive(heartbeat) {
3859
4043
  return startAtMs <= heartbeat.registeredAt;
3860
4044
  }
3861
4045
  //#endregion
3862
- export { parseModelCatalog as A, chatUserAgent as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, probeModel as F, WORKBUDDY_APP_VERSION_FILENAME as G, readCliVersion as H, randomSentinel as I, readBundleVersion as J, appUserAgent as K, classifyUpstreamError as L, prepareInternationalChatBody as M, regionOf as N, modelWithCurrentPromotion as O, PROBE_EFFORT_CANDIDATES as P, CN_APP_VERSION_FILENAME as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, resolveChatIdentity as U, fallbackChatIdentity as V, validCliVersion as W, validAppVersion as X, resolveAppVersion as Y, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, prepareChatBody as j, normalizeCredits as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, installedAppVersion as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, FALLBACK_CN_APP_VERSION as z };
4046
+ export { parseModelCatalog as A, FALLBACK_CN_APP_VERSION as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, probeModel as F, validCliVersion as G, fallbackChatIdentity as H, randomSentinel as I, installedAppVersion as J, WORKBUDDY_APP_VERSION_FILENAME as K, classifyUpstreamError as L, prepareInternationalChatBody as M, regionOf as N, modelWithCurrentPromotion as O, PROBE_EFFORT_CANDIDATES as P, upstreamDisplayMessage as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, readCliVersion as U, chatUserAgent as V, resolveChatIdentity as W, resolveAppVersion as X, readBundleVersion as Y, validAppVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, prepareChatBody as j, normalizeCredits as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, appUserAgent as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, CN_APP_VERSION_FILENAME as z };
package/lib/index.d.ts CHANGED
@@ -424,6 +424,13 @@ interface ProbeAttempt {
424
424
  streamed: boolean;
425
425
  /** `extError.code` from a JSON error body, when present. */
426
426
  errorCode?: string;
427
+ /**
428
+ * `extError.param` from the same body.
429
+ *
430
+ * Load-bearing since the upstream started reusing one generic rejection code
431
+ * for every field: it is what attributes the rejection to the effort value.
432
+ */
433
+ param?: string;
427
434
  /** Free-form detail for logs; never shown as a capability claim. */
428
435
  detail?: string;
429
436
  }
@@ -613,13 +620,22 @@ declare function regionOf(domain: string): WorkBuddyRegion;
613
620
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
614
621
  * force `stream: true` (the upstream rejects non-streaming), flatten
615
622
  * `tool_choice` (the upstream's field is a string; object forms return 400),
616
- * and rewrite `developer` messages as `system`.
623
+ * rewrite `developer` messages as `system`, neutralize the channel signature
624
+ * (see {@link neutralizeChannelSignature}), and drop the adapter's own `off`
625
+ * effort spelling (see {@link dropUnsupportedEffort}).
617
626
  *
618
627
  * The `developer` rewrite is load-bearing: pi-ai emits the system prompt as
619
628
  * `role: "developer"` (the OpenAI convention it adopted), but the WorkBuddy
620
629
  * upstream rejects that role with HTTP 400 code 11128 ("Illegal API
621
630
  * invocation from an unapproved channel"). Rewriting to `system` is the
622
631
  * compatible spelling the upstream accepts.
632
+ *
633
+ * Every transformation here is region-agnostic on purpose. The two endpoints
634
+ * differ in what they *add* (the international one also needs a leading system
635
+ * prompt — see {@link prepareInternationalChatBody}), not in what they reject:
636
+ * both refuse the picker's `off` spelling, which is why that strip lives here
637
+ * rather than in the international layer (issue #13 reversed the earlier
638
+ * international-only scope decision, which rested on CN accepting it).
623
639
  */
624
640
  declare function prepareChatBody(source: string): string;
625
641
  /** Provenance of one successful catalog fetch, surfaced by the status card. */
@@ -660,7 +676,20 @@ declare class WorkBuddyUpstreamClient {
660
676
  /** Provenance of the most recent successful catalog fetch, for the card. */
661
677
  lastCatalog: WorkBuddyCatalogFetch | undefined;
662
678
  constructor(options?: WorkBuddyUpstreamClientOptions);
663
- /** POST the chat endpoint; a successful answer is the raw SSE response. */
679
+ /**
680
+ * POST the chat endpoint; a successful answer is the raw SSE response.
681
+ *
682
+ * This is the one place every chat request converges on (the loopback shim,
683
+ * the host LLM route, and any direct caller all end up here), so it is also
684
+ * where the body is normalized for the upstream — in *both* regions. The
685
+ * region only decides which extra requirement applies on top: the
686
+ * international gateway additionally demands a leading system message. It
687
+ * used to decide whether the body was normalized at all, which left the CN
688
+ * path sending the picker's `off` spelling verbatim (issue #13).
689
+ *
690
+ * Normalization is idempotent, so a caller that already prepared the body
691
+ * (the shim does) sees no second change.
692
+ */
664
693
  chatStream(credential: WorkBuddyCredential, bodyJson: string, signal?: AbortSignal): Promise<WorkBuddyChatResult>;
665
694
  /** POST the token-refresh endpoint; the caller merges the outcome. */
666
695
  refreshToken(credential: WorkBuddyCredential): Promise<WorkBuddyRefreshOutcome>;
@@ -833,6 +862,11 @@ declare function modelWithCurrentPromotion(model: WorkBuddyUpstreamModel, now?:
833
862
  * merged: existing messages keep their order and wording. A body that is not a
834
863
  * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
835
864
  * so this is safe to run over an already-prepared-or-not body.
865
+ *
866
+ * This layer now carries *only* that requirement. The `reasoning_effort` strip
867
+ * used to live here, scoped to this region; issue #13 showed CN rejects the
868
+ * same spelling, so it moved into the shared {@link prepareChatBody} and this
869
+ * function no longer touches the effort field at all.
836
870
  */
837
871
  declare function prepareInternationalChatBody(source: string): string;
838
872
  //#endregion
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as parseModelCatalog, B as chatUserAgent, C as variantFor, D as WorkBuddyUpstreamClient, F as probeModel, G as WORKBUDDY_APP_VERSION_FILENAME, H as readCliVersion, I as randomSentinel, J as readBundleVersion, K as appUserAgent, L as classifyUpstreamError, M as prepareInternationalChatBody, N as regionOf, O as modelWithCurrentPromotion, P as PROBE_EFFORT_CANDIDATES, R as CN_APP_VERSION_FILENAME, S as WORKBUDDY_VARIANTS, U as resolveChatIdentity, V as fallbackChatIdentity, W as validCliVersion, X as validAppVersion, Y as resolveAppVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as prepareChatBody, k as normalizeCredits, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as installedAppVersion, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as FALLBACK_CN_APP_VERSION } from "./heartbeat-BpW2mSgI.js";
1
+ import { A as parseModelCatalog, B as FALLBACK_CN_APP_VERSION, C as variantFor, D as WorkBuddyUpstreamClient, F as probeModel, G as validCliVersion, H as fallbackChatIdentity, I as randomSentinel, J as installedAppVersion, K as WORKBUDDY_APP_VERSION_FILENAME, L as classifyUpstreamError, M as prepareInternationalChatBody, N as regionOf, O as modelWithCurrentPromotion, P as PROBE_EFFORT_CANDIDATES, R as upstreamDisplayMessage, S as WORKBUDDY_VARIANTS, U as readCliVersion, V as chatUserAgent, W as resolveChatIdentity, X as resolveAppVersion, Y as readBundleVersion, Z as validAppVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as prepareChatBody, k as normalizeCredits, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as appUserAgent, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as CN_APP_VERSION_FILENAME } from "./heartbeat-6xWs7ak3.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
@@ -446,6 +446,13 @@ function withCatalogDisplay(name, info) {
446
446
  * The offered set is described internally as "verified accepted", never as
447
447
  * "verified effective": acceptance proves the upstream did not reject the
448
448
  * spelling, not that it changes what the model does.
449
+ *
450
+ * `off` is a picker name, not a wire spelling. It stays pinned to the literal
451
+ * `'off'` (a `null` here would remove the level from the picker entirely), and
452
+ * the shared preparation in `src/protocol/client.ts` strips that exact value
453
+ * before the request leaves the plugin — in both regions, because both
454
+ * endpoints reject it (issues #49 and #13). Selecting Off therefore means "send
455
+ * no `reasoning_effort`", and what the model then does is upstream's call.
449
456
  */
450
457
  function reasoningFields(info, observed) {
451
458
  const resolved = resolveEfforts(info, observed);
@@ -798,6 +805,25 @@ function writeOpenAIError(res, status, kind, message) {
798
805
  code: kind
799
806
  } });
800
807
  }
808
+ /**
809
+ * The one-line reason a failed upstream request is reported with.
810
+ *
811
+ * The host does not read our HTTP status to classify a failure: `dsh-llm-pi-ai`
812
+ * matches the **message text** (`if (/\b(?:401|403)\b/.test(message)) return
813
+ * "AUTH"`), and the chat UI then replaces whatever we wrote with
814
+ * "API key is invalid". So a status may only appear here when the failure
815
+ * really is an authentication one; every other class keeps its digits out, or
816
+ * a restricted account or a rejected prompt reaches the user as a bad key.
817
+ *
818
+ * The upstream's own `displayMsg` wins over the raw body: it is the sentence
819
+ * written for a human ("账号存在异常行为,已被限制使用"), while the body is a JSON
820
+ * document whose `msg` is often a machine phrase such as `request illegal`.
821
+ */
822
+ function failureMessage(result) {
823
+ const reason = upstreamDisplayMessage(result.message) ?? result.message.slice(0, 400);
824
+ const head = `workbuddy upstream ${result.kind}`;
825
+ return result.kind === "session_dead" ? `${head} (http ${result.status}): ${reason}` : `${head}: ${reason}`;
826
+ }
801
827
  /** Read a request body with a size cap; over-limit bodies fail the request. */
802
828
  function readBody$1(req) {
803
829
  return new Promise((resolve, reject) => {
@@ -909,7 +935,7 @@ function createWorkBuddyShim(options) {
909
935
  req.on("close", () => controller.abort());
910
936
  const result = await client.chatStream(credential, prepared, controller.signal);
911
937
  if (!result.ok) {
912
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
938
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, failureMessage(result));
913
939
  return;
914
940
  }
915
941
  res.writeHead(200, {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-bridge",
3
3
  "displayName": "DSH WorkBuddy Bridge",
4
4
  "description": "将 WorkBuddy 桌面 App 包含的模型自动接入 DeepSeek Harness — bring WorkBuddy desktop app models into DeepSeek Harness with zero configuration.",
5
- "version": "0.5.0",
5
+ "version": "0.5.2",
6
6
  "author": "zlZayn",
7
7
  "keywords": [
8
8
  "dsh",
@@ -97,7 +97,7 @@
97
97
  "react-dom": "^18.2.0"
98
98
  },
99
99
  "devDependencies": {
100
- "@deepseek-ai/cordis": "4.0.4",
100
+ "@deepseek-ai/cordis": "^4.0.4",
101
101
  "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
102
102
  "@deepseek-ai/dsh-atomic-write": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
103
103
  "@deepseek-ai/dsh-attachment": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
@@ -119,7 +119,7 @@
119
119
  "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
120
120
  "@deepseek-ai/dsh-util-code-language": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
121
121
  "@deepseek-ai/dsh-util-workspace-path": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
122
- "@deepseek-ai/schemastery": "3.18.5",
122
+ "@deepseek-ai/schemastery": "^3.18.4",
123
123
  "@earendil-works/pi-ai": "^1.1.0",
124
124
  "@eslint/js": "^10.0.1",
125
125
  "@shikijs/langs": "^4.4.3",
@@ -150,7 +150,7 @@
150
150
  "micromark-util-symbol": "^2.0.1",
151
151
  "prettier": "^3.9.9",
152
152
  "react": "^18.2.0",
153
- "react-dom": "^18.3.1",
153
+ "react-dom": "^18.2.0",
154
154
  "react-test-renderer": "18.3.1",
155
155
  "shiki": "^4.4.3",
156
156
  "simple-icons": "^16.32.0",