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.
- package/lib/providers/workbuddy.d.ts +23 -0
- package/lib/providers/workbuddy.js +256 -3
- package/package.json +1 -1
|
@@ -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:
|
|
171
|
-
//
|
|
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.
|
|
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",
|