dsh-plugin-subscription-oauth 0.12.7 → 0.13.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.
@@ -63,6 +63,29 @@ export declare class WorkBuddyAdapter extends LlmAdapter {
63
63
  /** The stored session's fields as the token the request signs with. */
64
64
  private tokenOf;
65
65
  private requestBody;
66
+ /**
67
+ * Mend the tool schemas the way WorkBuddy's model gateway accepts.
68
+ *
69
+ * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
70
+ * a schema with a union at the parameter root or a `\0` escape is turned away by the
71
+ * model. Each unsafe name is made safe and the safe→original map kept on the adapter so
72
+ * the model's calls can be renamed back in the answer. Adapted from magpie's fitTools.
73
+ *
74
+ * @param tools - the declared tool schemas, when any.
75
+ * @returns the mended wire tools, or undefined when there are none.
76
+ */
77
+ private fittedTools;
78
+ /** The safe→original tool names to restore in the answer. */
79
+ private safeToolNames;
80
+ /** The assistant/tool history of the request being built, for renaming past calls. */
81
+ private historyMessages;
82
+ /**
83
+ * Restore the original tool names the model was given, in one parsed chunk.
84
+ *
85
+ * @param value - a decoded SSE payload.
86
+ * @returns whether any name changed.
87
+ */
88
+ private namedBack;
66
89
  /** The system prompt: the caller's, else WorkBuddy's default. */
67
90
  private systemPrompt;
68
91
  private toChatMessage;
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module dsh-plugin-subscription-oauth/providers/workbuddy
10
10
  */
11
- import { LlmAdapter, errorChain, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
11
+ import { LlmAdapter, errorChain, ReasoningEffortId, ToolCallId, } from '@deepseek-ai/dsh-llm';
12
12
  import { idleWatchdog, mapFetchFailure } from './common.js';
13
13
  import { proxiedFetch } from '../http.js';
14
14
  import { findEntry } from './catalog.js';
@@ -85,6 +85,121 @@ function randomHex() {
85
85
  crypto.getRandomValues(bytes);
86
86
  return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
87
87
  }
88
+ /** A tool's name may hold letters, digits, `_`, `-`; another character is refused. */
89
+ const NAME_OK = /^[A-Za-z0-9_-]+$/;
90
+ /** A name with each character WorkBuddy refuses as `_`, kept apart from `taken`. */
91
+ function safeName(name, taken) {
92
+ const base = name.replace(/[^A-Za-z0-9_-]/g, '_') || 'tool';
93
+ let candidate = base;
94
+ for (let index = 2; taken.has(candidate); index++)
95
+ candidate = `${base}_${index}`;
96
+ taken.add(candidate);
97
+ return candidate;
98
+ }
99
+ /** True for a plain object usable as a JSON-schema node. */
100
+ function isSchemaRecord(value) {
101
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
102
+ }
103
+ /**
104
+ * Mend a schema where the model gateway refuses one: `\0` escapes in a pattern, and an
105
+ * `items` list (deep models take a single shape or an anyOf, not a bare array).
106
+ */
107
+ function fitSchema(schema, depth = 0) {
108
+ if (depth > 64)
109
+ return false;
110
+ let changed = false;
111
+ if (Array.isArray(schema)) {
112
+ for (const item of schema)
113
+ if (isSchemaRecord(item) && fitSchema(item, depth + 1))
114
+ changed = true;
115
+ return changed;
116
+ }
117
+ if (typeof schema.pattern === 'string' && schema.pattern.includes('\\0')) {
118
+ schema.pattern = schema.pattern.replace(/\\0/g, '\\u0000');
119
+ changed = true;
120
+ }
121
+ if (Array.isArray(schema.items)) {
122
+ const list = schema.items.filter(isSchemaRecord);
123
+ schema.items = list.length === 0 ? {} : list.length === 1 ? list[0] : { anyOf: list };
124
+ delete schema.additionalItems;
125
+ changed = true;
126
+ }
127
+ for (const value of Object.values(schema)) {
128
+ if (isSchemaRecord(value) && fitSchema(value, depth + 1))
129
+ changed = true;
130
+ }
131
+ return changed;
132
+ }
133
+ /** The schema a local `$ref` names, or `node` itself. */
134
+ function localRef(root, node) {
135
+ const ref = node.$ref;
136
+ if (typeof ref !== 'string' || !ref.startsWith('#/'))
137
+ return node;
138
+ let at = root;
139
+ for (const key of ref.slice(2).split('/')) {
140
+ at = isSchemaRecord(at) ? at[key.replaceAll('~1', '/').replaceAll('~0', '~')] : undefined;
141
+ }
142
+ return isSchemaRecord(at) ? at : node;
143
+ }
144
+ /**
145
+ * Make a tool's parameters an object at the root, the shape every model takes: `allOf`'s
146
+ * branches are merged whole; of `anyOf`/`oneOf`'s object branches the properties are
147
+ * merged and a field each requires stays required.
148
+ */
149
+ function objectRoot(node, root = node, depth = 0) {
150
+ const unions = ['allOf', 'anyOf', 'oneOf'].filter((key) => Array.isArray(node[key]));
151
+ if (node.type === 'object' && unions.length === 0)
152
+ return false;
153
+ const properties = isSchemaRecord(node.properties) ? { ...node.properties } : {};
154
+ const required = Array.isArray(node.required) ? [...node.required] : [];
155
+ for (const branch0 of Array.isArray(node.allOf) ? node.allOf : []) {
156
+ let branch = localRef(root, isSchemaRecord(branch0) ? branch0 : {});
157
+ if (unions.length > 0 && depth < 8)
158
+ objectRoot((branch = structuredClone(branch)), root, depth + 1);
159
+ if (isSchemaRecord(branch) && isSchemaRecord(branch.properties)) {
160
+ for (const [key, value] of Object.entries(branch.properties))
161
+ if (!(key in properties))
162
+ properties[key] = value;
163
+ }
164
+ if (isSchemaRecord(branch) && Array.isArray(branch.required))
165
+ required.push(...branch.required);
166
+ }
167
+ const branches = [];
168
+ for (const key of ['anyOf', 'oneOf']) {
169
+ for (const branch0 of Array.isArray(node[key]) ? node[key] : []) {
170
+ let branch = localRef(root, isSchemaRecord(branch0) ? branch0 : {});
171
+ if (unions.length > 0 && depth < 8)
172
+ objectRoot((branch = structuredClone(branch)), root, depth + 1);
173
+ if (isSchemaRecord(branch) && (branch.type === 'object' || isSchemaRecord(branch.properties)))
174
+ branches.push(branch);
175
+ }
176
+ }
177
+ const from = new Map();
178
+ for (const branch of branches) {
179
+ if (!isSchemaRecord(branch.properties))
180
+ continue;
181
+ for (const [key, value] of Object.entries(branch.properties)) {
182
+ if (key in properties)
183
+ continue;
184
+ const seen = from.get(key) ?? [];
185
+ if (!seen.some((entry) => JSON.stringify(entry) === JSON.stringify(value)))
186
+ seen.push(value);
187
+ from.set(key, seen);
188
+ }
189
+ }
190
+ for (const [key, list] of from)
191
+ properties[key] = list.length === 1 ? list[0] : { anyOf: list };
192
+ delete node.allOf;
193
+ delete node.anyOf;
194
+ delete node.oneOf;
195
+ node.type = 'object';
196
+ node.properties = properties;
197
+ if (required.length > 0)
198
+ node.required = [...new Set(required)];
199
+ else
200
+ delete node.required;
201
+ return true;
202
+ }
88
203
  /** The WorkBuddy China wire adapter serving the `workbuddy` route. */
89
204
  export class WorkBuddyAdapter extends LlmAdapter {
90
205
  options;
@@ -159,6 +274,9 @@ export class WorkBuddyAdapter extends LlmAdapter {
159
274
  throw new Error('WorkBuddy is not signed in');
160
275
  const token = this.tokenOf(held.session);
161
276
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
277
+ // Capture the tool-call history so unsafe tool names in past turns can be renamed to
278
+ // the safe ones the model now sees, keeping the conversation consistent.
279
+ this.historyMessages = options.messages.filter((message) => message.role === 'assistant' || message.role === 'tool');
162
280
  try {
163
281
  const body = this.requestBody(options);
164
282
  const response = await this.fetchFn(WORKBUDDY_API, {
@@ -167,10 +285,13 @@ export class WorkBuddyAdapter extends LlmAdapter {
167
285
  'Content-Type': 'application/json',
168
286
  'Accept': 'text/event-stream',
169
287
  ...workbuddyHeaders(token),
170
- // Name the chat the way WorkBuddy's own client does: without these the gateway
171
- // counts the request under no client and can cut the stream.
288
+ // Name the chat the way WorkBuddy's own client does: the conversation, the
289
+ // turn, and the message. Without these the gateway counts the request under no
290
+ // client and cuts the stream, which is why the reply stopped after a few seconds.
172
291
  'X-Conversation-Message-ID': randomHex(),
173
292
  'X-Request-ID': randomHex(),
293
+ 'X-Conversation-Request-ID': randomHex(),
294
+ 'X-Conversation-ID': randomHex(),
174
295
  },
175
296
  body: JSON.stringify(body),
176
297
  signal: watchdog.signal,
@@ -212,13 +333,108 @@ export class WorkBuddyAdapter extends LlmAdapter {
212
333
  // Claude Code's own) out of every message — WorkBuddy reads them all, whatever role.
213
334
  const system = this.systemPrompt(options);
214
335
  messages.unshift({ role: 'system', content: system });
336
+ const tools = this.fittedTools(options.tools);
215
337
  return {
216
338
  model: options.model,
217
339
  messages: unflagMessages(messages),
340
+ ...tools === undefined ? {} : { tools: tools.tools },
218
341
  ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
219
342
  stream: true,
220
343
  };
221
344
  }
345
+ /**
346
+ * Mend the tool schemas the way WorkBuddy's model gateway accepts.
347
+ *
348
+ * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
349
+ * a schema with a union at the parameter root or a `\0` escape is turned away by the
350
+ * model. Each unsafe name is made safe and the safe→original map kept on the adapter so
351
+ * the model's calls can be renamed back in the answer. Adapted from magpie's fitTools.
352
+ *
353
+ * @param tools - the declared tool schemas, when any.
354
+ * @returns the mended wire tools, or undefined when there are none.
355
+ */
356
+ fittedTools(tools) {
357
+ if (tools === undefined || tools.length === 0)
358
+ return undefined;
359
+ const wire = tools.map((tool) => ({
360
+ type: 'function',
361
+ function: {
362
+ name: tool.name,
363
+ ...tool.description === '' ? {} : { description: tool.description },
364
+ ...tool.parameters === undefined ? {} : { parameters: structuredClone(tool.parameters) },
365
+ },
366
+ }));
367
+ const taken = new Set();
368
+ for (const tool of wire) {
369
+ const name = tool.function.name;
370
+ if (typeof name === 'string' && NAME_OK.test(name))
371
+ taken.add(name);
372
+ }
373
+ const names = new Map();
374
+ const theirs = new Map();
375
+ for (const tool of wire) {
376
+ const fn = tool.function;
377
+ if (typeof fn.name === 'string' && !NAME_OK.test(fn.name)) {
378
+ const safe = safeName(fn.name, taken);
379
+ names.set(safe, fn.name);
380
+ theirs.set(fn.name, safe);
381
+ fn.name = safe;
382
+ }
383
+ if (fn.parameters !== undefined) {
384
+ if (!isSchemaRecord(fn.parameters))
385
+ fn.parameters = { type: 'object', properties: {} };
386
+ else {
387
+ fitSchema(fn.parameters);
388
+ objectRoot(fn.parameters);
389
+ }
390
+ }
391
+ }
392
+ // Rename the calls in the conversation history to the safe names, so a turn that
393
+ // already called a tool matches the name the model now sees.
394
+ for (const message of this.historyMessages ?? []) {
395
+ for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
396
+ const fn = call?.function;
397
+ if (fn && typeof fn.name === 'string' && theirs.has(fn.name))
398
+ fn.name = theirs.get(fn.name);
399
+ }
400
+ if (message.role === 'tool' && typeof message.name === 'string' && theirs.has(message.name)) {
401
+ message.name = theirs.get(message.name);
402
+ }
403
+ }
404
+ this.safeToolNames = names;
405
+ return { tools: wire, names };
406
+ }
407
+ /** The safe→original tool names to restore in the answer. */
408
+ safeToolNames = new Map();
409
+ /** The assistant/tool history of the request being built, for renaming past calls. */
410
+ historyMessages = [];
411
+ /**
412
+ * Restore the original tool names the model was given, in one parsed chunk.
413
+ *
414
+ * @param value - a decoded SSE payload.
415
+ * @returns whether any name changed.
416
+ */
417
+ namedBack(value) {
418
+ let changed = false;
419
+ for (const choice of Array.isArray(value.choices) ? value.choices : []) {
420
+ if (!isSchemaRecord(choice))
421
+ continue;
422
+ for (const at of [choice.message, choice.delta]) {
423
+ if (!isSchemaRecord(at))
424
+ continue;
425
+ for (const call of Array.isArray(at.tool_calls) ? at.tool_calls : []) {
426
+ if (!isSchemaRecord(call) || !isSchemaRecord(call.function))
427
+ continue;
428
+ const original = this.safeToolNames.get(String(call.function.name));
429
+ if (original !== undefined) {
430
+ call.function.name = original;
431
+ changed = true;
432
+ }
433
+ }
434
+ }
435
+ }
436
+ return changed;
437
+ }
222
438
  /** The system prompt: the caller's, else WorkBuddy's default. */
223
439
  systemPrompt(options) {
224
440
  if (options.system !== undefined && options.system.trim() !== '')
@@ -299,6 +515,43 @@ export class WorkBuddyAdapter extends LlmAdapter {
299
515
  catch {
300
516
  continue;
301
517
  }
518
+ // WorkBuddy names a refusal mid-stream (an error frame, or a body with a non-zero
519
+ // code): surface it instead of reading the rest as silence, so the turn fails with
520
+ // the reason rather than stopping after a few empty seconds.
521
+ const wireError = chunk.error ?? chunk.choices?.[0]?.delta;
522
+ if (wireError !== undefined && wireError !== null && typeof wireError === 'object') {
523
+ const message = wireError.message;
524
+ if (typeof message === 'string' && message !== '')
525
+ throw new Error(`WorkBuddy: ${message}`);
526
+ }
527
+ const wireCode = chunk.code;
528
+ if (typeof wireCode === 'number' && wireCode !== 0) {
529
+ const msg = chunk.msg;
530
+ throw new Error(`WorkBuddy: ${typeof msg === 'string' ? msg : `error ${wireCode}`}`);
531
+ }
532
+ // Restore the tool names the model called to the ones the client declared, before
533
+ // the chunk is read as a stream event.
534
+ this.namedBack(chunk);
535
+ // A tool-call delta: emit it so the harness can run the tool. The id and name ride
536
+ // the first delta of each call; later deltas carry argument fragments.
537
+ const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
538
+ if (Array.isArray(toolCalls)) {
539
+ for (const call of toolCalls) {
540
+ const fn = call?.function;
541
+ if (fn === undefined || fn === null)
542
+ continue;
543
+ const index = call.index ?? 0;
544
+ const id = ToolCallId(call.id ?? `wb-tool-${index}`);
545
+ const name = fn.name;
546
+ const args = fn.arguments;
547
+ if (typeof name === 'string' && name !== '') {
548
+ yield { type: 'tool-call-delta', index, id, name, argumentsDelta: '' };
549
+ }
550
+ if (typeof args === 'string' && args !== '') {
551
+ yield { type: 'tool-call-delta', index, id, argumentsDelta: args };
552
+ }
553
+ }
554
+ }
302
555
  const delta = chunk.choices?.[0]?.delta?.content;
303
556
  if (typeof delta === 'string' && delta !== '') {
304
557
  if (!started) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",