dsh-plugin-subscription-oauth 0.12.8 → 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, {
@@ -215,13 +333,108 @@ export class WorkBuddyAdapter extends LlmAdapter {
215
333
  // Claude Code's own) out of every message — WorkBuddy reads them all, whatever role.
216
334
  const system = this.systemPrompt(options);
217
335
  messages.unshift({ role: 'system', content: system });
336
+ const tools = this.fittedTools(options.tools);
218
337
  return {
219
338
  model: options.model,
220
339
  messages: unflagMessages(messages),
340
+ ...tools === undefined ? {} : { tools: tools.tools },
221
341
  ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
222
342
  stream: true,
223
343
  };
224
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
+ }
225
438
  /** The system prompt: the caller's, else WorkBuddy's default. */
226
439
  systemPrompt(options) {
227
440
  if (options.system !== undefined && options.system.trim() !== '')
@@ -316,6 +529,29 @@ export class WorkBuddyAdapter extends LlmAdapter {
316
529
  const msg = chunk.msg;
317
530
  throw new Error(`WorkBuddy: ${typeof msg === 'string' ? msg : `error ${wireCode}`}`);
318
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
+ }
319
555
  const delta = chunk.choices?.[0]?.delta?.content;
320
556
  if (typeof delta === 'string' && delta !== '') {
321
557
  if (!started) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.12.8",
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",