dsh-llm-workbuddy 0.1.6 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +15 -4
  2. package/lib/index.js +141 -31
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-llm-workbuddy
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-llm-workbuddy.svg)](https://www.npmjs.com/package/dsh-llm-workbuddy)
4
+ [![license](https://img.shields.io/npm/l/dsh-llm-workbuddy.svg)](https://github.com/zdk119746/dsh-llm-workbuddy/blob/main/LICENSE)
5
+
3
6
  在 DeepSeek Harness 中使用你的 **WorkBuddy / CodeBuddy** 账号模型的 LLM 适配器插件。
4
7
 
5
8
  它把 `workbuddy` 这个 provider 路由指向本地运行的
@@ -12,7 +15,8 @@
12
15
 
13
16
  1. **模型能力**:Web 界面的模型选择器(composer 模型菜单或 `/model` 命令)会多出
14
17
  一个 **WorkBuddy** 分组,模型(DeepSeek-V4、GLM-5.x、Kimi-K2.x、MiniMax-M3、
15
- Hy3、Hunyuan…)随账号可用列表实时同步,点一下即可切换。
18
+ Hy3、Hunyuan…)随账号可用列表实时同步,点一下即可切换;支持推理的模型还会
19
+ 显示**推理等级**选择器(Low / Medium / High)。
16
20
  2. **Web 登录状态小组件**:在 Web GUI 右下角常驻一个状态胶囊,**实时显示登录/
17
21
  代理状态**,未登录时一键在新标签页打开 WorkBuddy 登录页,登录完成后自动变绿。
18
22
  无需再回到终端手动跑登录脚本。
@@ -269,13 +273,15 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
269
273
  # defaultContextWindow: 200000 # 未在目录中标明容量的模型使用
270
274
  # discovery: true # 实时拉取代理的 /v1/models(30s 缓存)
271
275
  # models: [...] # 静态目录(代理不可达时的兜底)
276
+ # 每个条目可带 reasoningEffort: 'low'|'medium'|'high'(默认推理等级)
272
277
  # loginScript: '' # 登录脚本绝对/相对路径;默认用包内 login_workbuddy.py
273
278
  # sessionFile: ~/.codebuddy-session.json # 会话文件路径;默认同上
274
279
  ```
275
280
 
276
281
  模型列表默认取插件内置目录;代理可达时改为实时拉取 `/v1/models`(支持
277
282
  `{"models": [...]}` / `{"data": [...]}` 两种返回),未列出的模型 id 仍可原样
278
- 传递。
283
+ 传递。每个模型的推理等级(`reasoningEffort`)默认来自内置目录,代理可达时优先
284
+ 沿用目录值(代理 `/v1/models` 当前只上报 `High`,不区分模型)。
279
285
 
280
286
  ---
281
287
 
@@ -296,8 +302,13 @@ DSH 的 `dsh.client` 机制只要求 `package.json` 里:
296
302
 
297
303
  ## 限制
298
304
 
299
- - 当前为纯文本适配器:图片输入会以 `UNSUPPORTED_CONTENT` 拒绝(后续可加)。
300
- - 不公布 reasoning effort 选择器;模型按平台默认强度运行。
305
+ - 图片输入:声明支持图片的模型(`inputModalities: ["text", "image"]`,如 deepseek-v4-pro、
306
+ glm-5.2、kimi-k2.x、hy3 等)可附带图片,适配器会通过 DSH 的附件服务把图片编码为
307
+ `data:<mime>;base64,<bytes>` 以 OpenAI `image_url` 格式透传给代理。若附件服务不可用
308
+ (headless 等无附件场景),图片输入会以 `UNSUPPORTED_CONTENT` 稳定报错。
309
+ - 推理等级(reasoning effort):支持推理的模型(如 DeepSeek-V4、GLM、Kimi、MiniMax、
310
+ Hy3 等)会显示推理等级下拉(Low / Medium / High),默认值取平台默认强度。`reasoning_effort`
311
+ 会透传给代理;若某模型平台侧只接受平台默认、忽略该参数,则退化为平台默认强度,不影响出字。
301
312
  - 代理未运行时,模型请求会以 `TRANSPORT` 错误快速失败(连接被拒绝);但状态
302
313
  小组件本身不依赖代理——代理挂了它仍能显示「代理未运行」并允许触发登录。
303
314
  - 登录态有效期由 WorkBuddy 云端决定;过期后胶囊变红,重新点「登录」即可,
package/lib/index.js CHANGED
@@ -73,28 +73,77 @@ const DISCOVERY_TIMEOUT_MS = 2_000;
73
73
  * proxy is reachable; entries not announced by the proxy are still listed.
74
74
  */
75
75
  const DEFAULT_MODELS = [
76
- { id: "deepseek-v4-pro", name: "Deepseek-V4-Pro", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"] },
77
- { id: "deepseek-v4-flash", name: "Deepseek-V4-Flash", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"] },
78
- { id: "glm-5.2", name: "GLM-5.2", contextWindow: 1_000_000, maxTokens: 48_000, inputModalities: ["text", "image"] },
79
- { id: "glm-5.1", name: "GLM-5.1", contextWindow: 200_000, maxTokens: 48_000 },
80
- { id: "glm-5.0", name: "GLM-5.0", contextWindow: 200_000, maxTokens: 48_000 },
81
- { id: "glm-4.7", name: "GLM-4.7", contextWindow: 200_000, maxTokens: 48_000 },
82
- { id: "glm-4.6", name: "GLM-4.6", contextWindow: 168_000, maxTokens: 32_000 },
83
- { id: "glm-5v-turbo", name: "GLM-5v-Turbo", contextWindow: 200_000, maxTokens: 64_000, inputModalities: ["text", "image"] },
84
- { id: "kimi-k3-1", name: "Kimi-K3", contextWindow: 1_000_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
85
- { id: "kimi-k2.7", name: "Kimi-K2.7-Code", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
86
- { id: "kimi-k2.6", name: "Kimi-K2.6", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
87
- { id: "kimi-k2.5", name: "Kimi-K2.5", contextWindow: 164_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
88
- { id: "kimi-k2-thinking", name: "Kimi-K2-Thinking", contextWindow: 164_000, maxTokens: 32_000 },
89
- { id: "minimax-m3", name: "MiniMax-M3", contextWindow: 512_000, maxTokens: 128_000, inputModalities: ["text", "image"] },
90
- { id: "minimax-m2.5", name: "MiniMax-M2.5", contextWindow: 200_000, maxTokens: 48_000 },
91
- { id: "hy3", name: "Hy3", contextWindow: 192_000, maxTokens: 64_000, inputModalities: ["text", "image"] },
92
- { id: "hunyuan-2.0-thinking", name: "Hunyuan-2.0-Thinking", contextWindow: 128_000, maxTokens: 24_000 },
76
+ { id: "deepseek-v4-pro", name: "Deepseek-V4-Pro", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
77
+ { id: "deepseek-v4-flash", name: "Deepseek-V4-Flash", contextWindow: 1_000_000, maxTokens: 50_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
78
+ { id: "glm-5.2", name: "GLM-5.2", contextWindow: 1_000_000, maxTokens: 48_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
79
+ { id: "glm-5.1", name: "GLM-5.1", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
80
+ { id: "glm-5.0", name: "GLM-5.0", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
81
+ { id: "glm-4.7", name: "GLM-4.7", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "high" },
82
+ { id: "glm-4.6", name: "GLM-4.6", contextWindow: 168_000, maxTokens: 32_000, reasoningEffort: "high" },
83
+ { id: "glm-5v-turbo", name: "GLM-5v-Turbo", contextWindow: 200_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
84
+ { id: "kimi-k3-1", name: "Kimi-K3", contextWindow: 1_000_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
85
+ { id: "kimi-k2.7", name: "Kimi-K2.7-Code", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
86
+ { id: "kimi-k2.6", name: "Kimi-K2.6", contextWindow: 256_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
87
+ { id: "kimi-k2.5", name: "Kimi-K2.5", contextWindow: 164_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
88
+ { id: "kimi-k2-thinking", name: "Kimi-K2-Thinking", contextWindow: 164_000, maxTokens: 32_000, reasoningEffort: "medium" },
89
+ { id: "minimax-m3", name: "MiniMax-M3", contextWindow: 512_000, maxTokens: 128_000, inputModalities: ["text", "image"], reasoningEffort: "medium" },
90
+ { id: "minimax-m2.5", name: "MiniMax-M2.5", contextWindow: 200_000, maxTokens: 48_000, reasoningEffort: "medium" },
91
+ { id: "hy3", name: "Hy3", contextWindow: 192_000, maxTokens: 64_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
92
+ { id: "hunyuan-2.0-thinking", name: "Hunyuan-2.0-Thinking", contextWindow: 128_000, maxTokens: 24_000, reasoningEffort: "medium" },
93
93
  { id: "hunyuan-chat", name: "Hunyuan-Turbos", contextWindow: 200_000, maxTokens: 8_192 },
94
- { id: "auto", name: "Auto", contextWindow: 168_000, maxTokens: 32_000, inputModalities: ["text", "image"] },
94
+ { id: "auto", name: "Auto", contextWindow: 168_000, maxTokens: 32_000, inputModalities: ["text", "image"], reasoningEffort: "high" },
95
95
  { id: "default", name: "Default", contextWindow: 200_000, maxTokens: 24_000 },
96
96
  ];
97
97
 
98
+ /** Selectable reasoning efforts exposed to the harness UI, in display order. */
99
+ const REASONING_EFFORTS = [
100
+ { id: "low", name: "Low" },
101
+ { id: "medium", name: "Medium" },
102
+ { id: "high", name: "High" },
103
+ ];
104
+
105
+ /**
106
+ * Adapter-owned reasoning-effort identifiers accepted by the wire. The harness
107
+ * `ReasoningEffortId` is opaque; we keep the catalog default and the request
108
+ * param in this vocabulary and map to/from the proxy as needed.
109
+ */
110
+ const REASONING_EFFORT_IDS = new Set(["low", "medium", "high"]);
111
+
112
+ /**
113
+ * Build the `reasoning` capability metadata for a model, per the dsh-llm
114
+ * `LlmModelReasoningInfo` contract. `effort` is the model's platform default
115
+ * (`high`/`medium`/`low`); absent means the model exposes no selectable
116
+ * reasoning (the harness then hides the selector). When the platform does not
117
+ * support per-request switching, callers may still omit an explicit effort and
118
+ * the proxy uses its own default — publishing the default keeps the UI honest
119
+ * about the level actually applied.
120
+ * @returns the `reasoning` block, or `undefined` when the model has none.
121
+ */
122
+ function modelReasoningInfo(effort) {
123
+ if (effort === undefined || !REASONING_EFFORT_IDS.has(effort)) return undefined;
124
+ return {
125
+ efforts: REASONING_EFFORTS,
126
+ defaultEffort: effort,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Extract a model's default reasoning effort from a live `/v1/models` entry.
132
+ * The proxy reports `supported_reasoning_levels: [{ effort: 'High' }]` and
133
+ * `default_reasoning_level: null`, so we fall back to the catalog's
134
+ * `reasoningEffort` when the live answer is uninformative (as it currently is).
135
+ * @returns a lowercased effort id (`low`/`medium`/`high`) or `undefined`.
136
+ */
137
+ function liveReasoningEffort(entry, catalogEffort) {
138
+ if (catalogEffort !== undefined) return catalogEffort;
139
+ const levels = Array.isArray(entry?.supported_reasoning_levels) ? entry.supported_reasoning_levels : [];
140
+ const wire = levels[0]?.effort;
141
+ if (typeof wire !== "string") return undefined;
142
+ const lower = wire.toLowerCase();
143
+ return lower === "low" || lower === "medium" || lower === "high" ? lower : undefined;
144
+ }
145
+
146
+
98
147
  // #region serialize
99
148
 
100
149
  /** Join the text blocks of a message (used for user/tool-result content). */
@@ -120,14 +169,29 @@ function serializeAssistant(message) {
120
169
  /**
121
170
  * Serialize the harness conversation into OpenAI chat-completions wire
122
171
  * messages. `tool-result` blocks become standalone `{role: 'tool'}` messages;
123
- * image content is rejected (the initial version is text-only).
172
+ * image blocks are read through the durable attachment service and emitted as
173
+ * OpenAI `image_url` parts (`data:<mime>;base64,<bytes>`), which the
174
+ * workbuddy2api proxy passes through to the upstream platform. When the
175
+ * attachment service is unavailable, image input degrades to the stable
176
+ * `UNSUPPORTED_CONTENT` error.
124
177
  */
125
- function serializeMessages(messages) {
178
+ async function serializeMessages(messages, attachments, signal) {
179
+ const refs = new Map();
180
+ for (const message of messages) collectImageRefs(message.content, refs);
181
+ const requestImages = new Map();
182
+ if (refs.size > 0) {
183
+ if (attachments === undefined) {
184
+ throw new LlmError("WorkBuddy image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
185
+ }
186
+ const policy = { maxPixels: 4_000_000, maxBytes: 20_000_000 };
187
+ const ordered = [...refs.values()];
188
+ const prepared = await Promise.all(ordered.map((ref) => attachments.readImageRequest(ref, policy, signal)));
189
+ for (let index = 0; index < ordered.length; index += 1) {
190
+ requestImages.set(ordered[index].attachmentId, prepared[index]);
191
+ }
192
+ }
126
193
  const wire = [];
127
194
  for (const message of messages) {
128
- if (message.content.some((block) => block.type === "image")) {
129
- throw new LlmError("The WorkBuddy adapter does not support image content yet.", "UNSUPPORTED_CONTENT");
130
- }
131
195
  if (message.role === "system") {
132
196
  wire.push({ role: "system", content: flattenText(message.content) });
133
197
  continue;
@@ -138,7 +202,26 @@ function serializeMessages(messages) {
138
202
  }
139
203
  const toolResults = message.content.filter((block) => block.type === "tool-result");
140
204
  const text = flattenText(message.content);
141
- if (text.length > 0 || toolResults.length === 0) wire.push({ role: "user", content: text });
205
+ const images = message.content.filter((block) => block.type === "image");
206
+ if (text.length > 0 || toolResults.length === 0) {
207
+ if (images.length > 0) {
208
+ const parts = [];
209
+ if (text.length > 0) parts.push({ type: "text", text });
210
+ for (const block of images) {
211
+ const version = requestImages.get(block.attachment.attachmentId);
212
+ if (version === undefined) {
213
+ throw new LlmError("WorkBuddy image input missing attachment bytes", "UNSUPPORTED_CONTENT");
214
+ }
215
+ parts.push({
216
+ type: "image_url",
217
+ image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString("base64")}` },
218
+ });
219
+ }
220
+ wire.push({ role: "user", content: parts });
221
+ } else {
222
+ wire.push({ role: "user", content: text });
223
+ }
224
+ }
142
225
  for (const result of toolResults) {
143
226
  wire.push({ role: "tool", tool_call_id: result.toolCallId, content: flattenText(result.content) || "(no output)" });
144
227
  }
@@ -146,11 +229,19 @@ function serializeMessages(messages) {
146
229
  return wire;
147
230
  }
148
231
 
232
+ /** Collect image attachment refs from a content block list (recursing into tool results). */
233
+ function collectImageRefs(blocks, refs) {
234
+ for (const block of blocks) {
235
+ if (block.type === "image") refs.set(block.attachment.attachmentId, block.attachment);
236
+ else if (block.type === "tool-result") collectImageRefs(block.content, refs);
237
+ }
238
+ }
239
+
149
240
  /** Build the full wire request. Always streaming with usage reporting on. */
150
- function serializeRequest(options) {
241
+ async function serializeRequest(options, attachments, signal) {
151
242
  const messages = [];
152
243
  if (options.system !== undefined) messages.push({ role: "system", content: options.system });
153
- messages.push(...serializeMessages(options.messages));
244
+ messages.push(...await serializeMessages(options.messages, attachments, signal));
154
245
  const tools = options.tools?.map((tool) => ({
155
246
  type: "function",
156
247
  function: { name: tool.name, description: tool.description, parameters: tool.parameters },
@@ -164,6 +255,9 @@ function serializeRequest(options) {
164
255
  ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
165
256
  ...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
166
257
  ...(options.stop !== undefined ? { stop: options.stop } : {}),
258
+ // reasoning_effort is opaque here (low|medium|high); the proxy passes it
259
+ // through verbatim to the upstream /v2/chat/completions body.
260
+ ...(options.reasoningEffort === undefined ? {} : { reasoning_effort: options.reasoningEffort }),
167
261
  };
168
262
  }
169
263
 
@@ -363,12 +457,14 @@ async function* translate(payloads) {
363
457
 
364
458
  /** Display metadata for one catalog entry. */
365
459
  function modelInfo(provider, model) {
460
+ const reasoning = modelReasoningInfo(model.reasoningEffort);
366
461
  return {
367
462
  provider,
368
463
  id: model.id,
369
464
  name: model.name ?? model.id,
370
465
  ...(model.description === undefined ? {} : { description: model.description }),
371
466
  inputModalities: model.inputModalities ?? ["text"],
467
+ ...(reasoning === undefined ? {} : { reasoning }),
372
468
  };
373
469
  }
374
470
 
@@ -457,12 +553,15 @@ export class WorkBuddyAdapter extends LlmAdapter {
457
553
  if (seen.has(entry.id)) continue;
458
554
  seen.add(entry.id);
459
555
  const catalog = byId.get(entry.id);
556
+ const effort = liveReasoningEffort(entry, catalog?.reasoningEffort);
557
+ const reasoning = modelReasoningInfo(effort);
460
558
  merged.push({
461
559
  provider,
462
560
  id: entry.id,
463
561
  name: entry.name ?? catalog?.name ?? entry.id,
464
562
  ...(catalog?.description !== undefined ? { description: catalog.description } : {}),
465
563
  inputModalities: catalog?.inputModalities ?? ["text"],
564
+ ...(reasoning === undefined ? {} : { reasoning }),
466
565
  });
467
566
  }
468
567
  // Catalog entries the proxy did not announce (e.g. unauthenticated or
@@ -482,10 +581,11 @@ export class WorkBuddyAdapter extends LlmAdapter {
482
581
  resolveModel(provider, model, _signal) {
483
582
  const connection = this.config.options();
484
583
  const configured = connection.models.find((entry) => entry.id === model);
584
+ const base = configured === undefined
585
+ ? { provider, id: model, name: model, inputModalities: ["text"] }
586
+ : modelInfo(provider, configured);
485
587
  return Promise.resolve({
486
- ...(configured === undefined
487
- ? { provider, id: model, name: model, inputModalities: ["text"] }
488
- : modelInfo(provider, configured)),
588
+ ...base,
489
589
  context: { contextWindow: configured?.contextWindow ?? connection.defaultContextWindow },
490
590
  defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
491
591
  });
@@ -530,7 +630,8 @@ export class WorkBuddyAdapter extends LlmAdapter {
530
630
  }
531
631
 
532
632
  async *request(options, signal, connection, onComment) {
533
- const body = serializeRequest(options);
633
+ const attachments = this.config.resolveAttachments?.();
634
+ const body = await serializeRequest(options, attachments, signal);
534
635
  const headers = {
535
636
  "content-type": "application/json",
536
637
  "accept": "text/event-stream",
@@ -586,6 +687,7 @@ const catalogModel = z.object({
586
687
  contextWindow: z.number().step(1).min(1),
587
688
  maxTokens: z.number().step(1).min(1),
588
689
  inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(["text"]),
690
+ reasoningEffort: z.string(),
589
691
  });
590
692
 
591
693
  export const Config = z.object({
@@ -625,6 +727,10 @@ function resolveModels(models) {
625
727
  }
626
728
  if (seen.has(model.id)) throw new Error(`dsh-llm-workbuddy: duplicate catalog model "${model.id}"`);
627
729
  seen.add(model.id);
730
+ const reasoningEffort = model.reasoningEffort;
731
+ if (reasoningEffort !== undefined && !REASONING_EFFORT_IDS.has(reasoningEffort)) {
732
+ throw new Error(`dsh-llm-workbuddy: catalog model "${model.id}" reasoningEffort must be one of low|medium|high`);
733
+ }
628
734
  return {
629
735
  id: model.id,
630
736
  ...(model.name === undefined ? {} : { name: model.name }),
@@ -632,6 +738,7 @@ function resolveModels(models) {
632
738
  ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }),
633
739
  ...(model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }),
634
740
  inputModalities: [...inputModalities],
741
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
635
742
  };
636
743
  });
637
744
  }
@@ -989,7 +1096,10 @@ export function apply(ctx, config) {
989
1096
  }
990
1097
  };
991
1098
  options();
992
- const adapter = new WorkBuddyAdapter({ options });
1099
+ const adapter = new WorkBuddyAdapter({
1100
+ options,
1101
+ resolveAttachments: () => ctx.get("attachments"),
1102
+ });
993
1103
  ctx.llm.registerConfigurableProviders([{
994
1104
  provider: PROVIDER,
995
1105
  displayName: "WorkBuddy",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-llm-workbuddy",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "WorkBuddy (via the local workbuddy2api proxy) LLM provider adapter for DeepSeek Harness, with a Web login-status widget",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -48,9 +48,9 @@
48
48
  ],
49
49
  "peerDependencies": {
50
50
  "@deepseek-ai/cordis": "^4.0.1",
51
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
52
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
53
- "@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
51
+ "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2",
54
54
  "@deepseek-ai/schemastery": "^3.18.1"
55
55
  }
56
56
  }