dsh-plugin-subscription-oauth 0.12.8 → 0.15.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/protocol.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  /** Prefix of every RPC method this plugin serves. */
13
13
  export declare const RPC_PREFIX = "subscription-oauth.";
14
14
  /** Endpoints the Settings page may call. */
15
- export declare const RPC_ENDPOINTS: readonly ["status", "login", "cancel", "logout", "setDefault", "refresh", "discover", "setModelVisible", "setModelOrder", "addModel", "removeModel", "usage", "reload", "auto-state", "setAutoOn", "deepseek-balance", "ccswitch-balance", "model-variants"];
15
+ export declare const RPC_ENDPOINTS: readonly ["status", "login", "cancel", "logout", "setDefault", "refresh", "discover", "setModelVisible", "setModelOrder", "addModel", "removeModel", "usage", "reload", "auto-state", "setAutoOn", "checkin", "deepseek-balance", "ccswitch-balance", "model-variants"];
16
16
  /** One endpoint name. */
17
17
  export type RpcEndpoint = typeof RPC_ENDPOINTS[number];
18
18
  /** Client → host envelope. */
@@ -146,6 +146,13 @@ export interface ProviderUsage {
146
146
  renewsAt?: number;
147
147
  /** Metered allowances, most relevant first. */
148
148
  windows: readonly UsageWindow[];
149
+ /** Daily check-in state, when the provider states one. */
150
+ checkin?: {
151
+ active: boolean;
152
+ todayCheckedIn: boolean;
153
+ streakDays: number;
154
+ todayCredit: number;
155
+ };
149
156
  /** Epoch milliseconds these figures were read. */
150
157
  fetchedAt: number;
151
158
  /**
@@ -323,6 +330,10 @@ export interface RpcMap {
323
330
  payload: SetAutoOnRequest;
324
331
  value: AutoStateResult;
325
332
  };
333
+ checkin: {
334
+ payload: CheckinRequest;
335
+ value: CheckinResult;
336
+ };
326
337
  'deepseek-balance': {
327
338
  payload: DeepSeekBalanceRequest;
328
339
  value: DeepSeekBalanceResult;
@@ -398,6 +409,17 @@ export interface SetAutoOnRequest {
398
409
  export interface AutoStateResult {
399
410
  enabled: boolean;
400
411
  }
412
+ /** Payload of `checkin`: which account claims today's reward. */
413
+ export interface CheckinRequest {
414
+ provider: string;
415
+ account: string;
416
+ }
417
+ /** Result of `checkin`: the credit granted and the streak after claiming. */
418
+ export interface CheckinResult {
419
+ credit: number;
420
+ streakDays: number;
421
+ already: boolean;
422
+ }
401
423
  /** Payload of `deepseek-balance`. */
402
424
  export interface DeepSeekBalanceRequest {
403
425
  /** Client metadata the host's account service expects. */
package/lib/protocol.js CHANGED
@@ -28,6 +28,7 @@ export const RPC_ENDPOINTS = [
28
28
  'reload',
29
29
  'auto-state',
30
30
  'setAutoOn',
31
+ 'checkin',
31
32
  'deepseek-balance',
32
33
  'ccswitch-balance',
33
34
  'model-variants',
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Per-account credit spend tracking for the WorkBuddy route.
3
+ *
4
+ * The gateway states the real billed figure for a turn — `usage.credit` on the last
5
+ * usage frame of the stream — so the spent total is a sum of those, never an estimate
6
+ * from tokens and a rate. Spends accumulate per account in a small JSON file beside the
7
+ * credentials, and fold with the live packages' remaining allowance into one precise
8
+ * spent-against-limit window.
9
+ *
10
+ * @module dsh-plugin-subscription-oauth/providers/workbuddy-credits
11
+ */
12
+ /** One account's accumulated spend. */
13
+ interface AccountSpend {
14
+ spent: number;
15
+ calls: number;
16
+ }
17
+ /** The spend ledger, keyed by account id. */
18
+ export type SpendLedger = Record<string, AccountSpend>;
19
+ /** Read the ledger, tolerating a missing or corrupt file. */
20
+ export declare function readSpendLedger(file: string): Promise<SpendLedger>;
21
+ /** Add one turn's billed credit to an account's spend, returning the new totals. */
22
+ export declare function recordSpend(file: string, account: string, credit: number): Promise<AccountSpend>;
23
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-account credit spend tracking for the WorkBuddy route.
3
+ *
4
+ * The gateway states the real billed figure for a turn — `usage.credit` on the last
5
+ * usage frame of the stream — so the spent total is a sum of those, never an estimate
6
+ * from tokens and a rate. Spends accumulate per account in a small JSON file beside the
7
+ * credentials, and fold with the live packages' remaining allowance into one precise
8
+ * spent-against-limit window.
9
+ *
10
+ * @module dsh-plugin-subscription-oauth/providers/workbuddy-credits
11
+ */
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ /** Read the ledger, tolerating a missing or corrupt file. */
15
+ export async function readSpendLedger(file) {
16
+ try {
17
+ const raw = JSON.parse(await readFile(file, 'utf8'));
18
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
19
+ return {};
20
+ const out = {};
21
+ for (const [key, value] of Object.entries(raw)) {
22
+ if (typeof value !== 'object' || value === null)
23
+ continue;
24
+ const record = value;
25
+ const spent = typeof record.spent === 'number' && Number.isFinite(record.spent) ? record.spent : 0;
26
+ const calls = typeof record.calls === 'number' && Number.isFinite(record.calls) ? record.calls : 0;
27
+ out[key] = { spent: Math.max(0, spent), calls: Math.max(0, calls) };
28
+ }
29
+ return out;
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ /** Add one turn's billed credit to an account's spend, returning the new totals. */
36
+ export async function recordSpend(file, account, credit) {
37
+ const ledger = await readSpendLedger(file);
38
+ const previous = ledger[account] ?? { spent: 0, calls: 0 };
39
+ const next = {
40
+ spent: Math.round((previous.spent + Math.max(0, credit)) * 10000) / 10000,
41
+ calls: previous.calls + 1,
42
+ };
43
+ ledger[account] = next;
44
+ try {
45
+ await mkdir(path.dirname(file), { recursive: true });
46
+ await writeFile(file, JSON.stringify(ledger, null, 2), 'utf8');
47
+ }
48
+ catch {
49
+ // A failed write keeps the in-memory figure; the next turn retries.
50
+ }
51
+ return next;
52
+ }
@@ -8,7 +8,9 @@ import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
8
8
  import { TokenStore } from '../auth/store.js';
9
9
  import { TokenManager } from '../auth/tokens.js';
10
10
  import { LOGIN_CANCELLED_MESSAGE } from '../auth/device-flow.js';
11
- import { finishWorkBuddySignIn, parseWorkBuddySession, readWorkBuddyUsage, refreshWorkBuddyToken, startWorkBuddySignIn, workbuddyRefreshable, workbuddyStale, WORKBUDDY_EARLY_MS, } from '../auth/workbuddy-flow.js';
11
+ import path from 'node:path';
12
+ import { readSpendLedger } from './workbuddy-credits.js';
13
+ import { finishWorkBuddySignIn, parseWorkBuddySession, readWorkBuddyUsage, refreshWorkBuddyToken, startWorkBuddySignIn, workbuddyRefreshable, workbuddyStale, claimWorkBuddyCheckin, WORKBUDDY_EARLY_MS, } from '../auth/workbuddy-flow.js';
12
14
  import { WorkBuddyAdapter, WORKBUDDY_CODE_URL, WORKBUDDY_CATALOG, WORKBUDDY_DEFAULT_MODEL, WORKBUDDY_PROVIDER_ID, WORKBUDDY_PROVIDER_NAME, } from './workbuddy.js';
13
15
  import { ProviderController } from '../auth/provider-controller.js';
14
16
  import { proxiedFetch } from '../http.js';
@@ -129,9 +131,11 @@ export function createWorkBuddyProvider(options) {
129
131
  }
130
132
  },
131
133
  });
134
+ const spendFile = path.join(path.dirname(options.credentialFile), 'workbuddy-spend.json');
132
135
  const adapter = new WorkBuddyAdapter({
133
136
  streamIdleTimeoutMs: options.streamIdleTimeoutMs,
134
137
  tokens,
138
+ spendFile,
135
139
  ...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
136
140
  hiddenModels: () => options.preferences.hiddenIds(WORKBUDDY_PROVIDER_ID),
137
141
  userModels: () => options.preferences.extraIds(WORKBUDDY_PROVIDER_ID),
@@ -164,8 +168,10 @@ export function createWorkBuddyProvider(options) {
164
168
  const held = await store.get(account);
165
169
  if (held === undefined)
166
170
  return undefined;
167
- const read = await readWorkBuddyUsage(tokenOf(held), held.accountId, fetchFn);
168
- return { plan: read.plan, windows: read.windows };
171
+ const ledger = await readSpendLedger(spendFile);
172
+ const spend = ledger[account];
173
+ const read = await readWorkBuddyUsage(tokenOf(held), held.accountId, fetchFn, spend === undefined ? undefined : { spent: spend.spent });
174
+ return { plan: read.plan, windows: read.windows, ...read.checkin === undefined ? {} : { checkin: read.checkin } };
169
175
  },
170
176
  details: () => [
171
177
  { key: 'provider', value: WORKBUDDY_PROVIDER_NAME },
@@ -201,5 +207,21 @@ export function createWorkBuddyProvider(options) {
201
207
  removeModel: (id) => controller.removeModel(id),
202
208
  usage: (force) => controller.usage(force),
203
209
  reload: () => controller.reload(),
210
+ checkin: async (account) => {
211
+ const held = await store.get(account);
212
+ if (held === undefined)
213
+ throw new ProviderError('WorkBuddy account not signed in', REAUTH_REQUIRED);
214
+ try {
215
+ const claim = await claimWorkBuddyCheckin(tokenOf(held), fetchFn);
216
+ return { credit: claim.credit, streakDays: claim.streakDays, already: claim.credit === 0 };
217
+ }
218
+ catch (error) {
219
+ const message = error instanceof Error ? error.message : String(error);
220
+ // An already-claimed check-in is a normal answer, not a failure.
221
+ if (/already|已签到/i.test(message))
222
+ return { credit: 0, streakDays: 0, already: true };
223
+ throw new ProviderError(`WorkBuddy check-in failed: ${message}`, REAUTH_REQUIRED);
224
+ }
225
+ },
204
226
  };
205
227
  }
@@ -43,6 +43,8 @@ export interface WorkBuddyAdapterOptions {
43
43
  onWarn?: (message: string) => void;
44
44
  hiddenModels?: () => Promise<ReadonlySet<string>>;
45
45
  userModels?: () => Promise<readonly string[]>;
46
+ /** Where per-account credit spends accumulate; beside the credential file. */
47
+ spendFile?: string;
46
48
  fetchFn?: FetchFn;
47
49
  }
48
50
  /** The WorkBuddy China wire adapter serving the `workbuddy` route. */
@@ -60,9 +62,34 @@ export declare class WorkBuddyAdapter extends LlmAdapter {
60
62
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
61
63
  providerRetryPolicy(): undefined;
62
64
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
65
+ /** The current turn's billed credit, read off the last usage frame. */
66
+ private turnCredit;
63
67
  /** The stored session's fields as the token the request signs with. */
64
68
  private tokenOf;
65
69
  private requestBody;
70
+ /**
71
+ * Mend the tool schemas the way WorkBuddy's model gateway accepts.
72
+ *
73
+ * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
74
+ * a schema with a union at the parameter root or a `\0` escape is turned away by the
75
+ * model. Each unsafe name is made safe and the safe→original map kept on the adapter so
76
+ * the model's calls can be renamed back in the answer. Adapted from magpie's fitTools.
77
+ *
78
+ * @param tools - the declared tool schemas, when any.
79
+ * @returns the mended wire tools, or undefined when there are none.
80
+ */
81
+ private fittedTools;
82
+ /** The safe→original tool names to restore in the answer. */
83
+ private safeToolNames;
84
+ /** The assistant/tool history of the request being built, for renaming past calls. */
85
+ private historyMessages;
86
+ /**
87
+ * Restore the original tool names the model was given, in one parsed chunk.
88
+ *
89
+ * @param value - a decoded SSE payload.
90
+ * @returns whether any name changed.
91
+ */
92
+ private namedBack;
66
93
  /** The system prompt: the caller's, else WorkBuddy's default. */
67
94
  private systemPrompt;
68
95
  private toChatMessage;
@@ -8,11 +8,12 @@
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';
15
15
  import { WORKBUDDY_ENDPOINT, WORKBUDDY_UA_VERSION, workbuddyHeaders } from '../auth/workbuddy-flow.js';
16
+ import { recordSpend } from './workbuddy-credits.js';
16
17
  /** The `workbuddy` provider route key (China build). */
17
18
  export const WORKBUDDY_PROVIDER_ID = 'workbuddy';
18
19
  /** Human name for the route and its cards. */
@@ -85,6 +86,121 @@ function randomHex() {
85
86
  crypto.getRandomValues(bytes);
86
87
  return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
87
88
  }
89
+ /** A tool's name may hold letters, digits, `_`, `-`; another character is refused. */
90
+ const NAME_OK = /^[A-Za-z0-9_-]+$/;
91
+ /** A name with each character WorkBuddy refuses as `_`, kept apart from `taken`. */
92
+ function safeName(name, taken) {
93
+ const base = name.replace(/[^A-Za-z0-9_-]/g, '_') || 'tool';
94
+ let candidate = base;
95
+ for (let index = 2; taken.has(candidate); index++)
96
+ candidate = `${base}_${index}`;
97
+ taken.add(candidate);
98
+ return candidate;
99
+ }
100
+ /** True for a plain object usable as a JSON-schema node. */
101
+ function isSchemaRecord(value) {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
103
+ }
104
+ /**
105
+ * Mend a schema where the model gateway refuses one: `\0` escapes in a pattern, and an
106
+ * `items` list (deep models take a single shape or an anyOf, not a bare array).
107
+ */
108
+ function fitSchema(schema, depth = 0) {
109
+ if (depth > 64)
110
+ return false;
111
+ let changed = false;
112
+ if (Array.isArray(schema)) {
113
+ for (const item of schema)
114
+ if (isSchemaRecord(item) && fitSchema(item, depth + 1))
115
+ changed = true;
116
+ return changed;
117
+ }
118
+ if (typeof schema.pattern === 'string' && schema.pattern.includes('\\0')) {
119
+ schema.pattern = schema.pattern.replace(/\\0/g, '\\u0000');
120
+ changed = true;
121
+ }
122
+ if (Array.isArray(schema.items)) {
123
+ const list = schema.items.filter(isSchemaRecord);
124
+ schema.items = list.length === 0 ? {} : list.length === 1 ? list[0] : { anyOf: list };
125
+ delete schema.additionalItems;
126
+ changed = true;
127
+ }
128
+ for (const value of Object.values(schema)) {
129
+ if (isSchemaRecord(value) && fitSchema(value, depth + 1))
130
+ changed = true;
131
+ }
132
+ return changed;
133
+ }
134
+ /** The schema a local `$ref` names, or `node` itself. */
135
+ function localRef(root, node) {
136
+ const ref = node.$ref;
137
+ if (typeof ref !== 'string' || !ref.startsWith('#/'))
138
+ return node;
139
+ let at = root;
140
+ for (const key of ref.slice(2).split('/')) {
141
+ at = isSchemaRecord(at) ? at[key.replaceAll('~1', '/').replaceAll('~0', '~')] : undefined;
142
+ }
143
+ return isSchemaRecord(at) ? at : node;
144
+ }
145
+ /**
146
+ * Make a tool's parameters an object at the root, the shape every model takes: `allOf`'s
147
+ * branches are merged whole; of `anyOf`/`oneOf`'s object branches the properties are
148
+ * merged and a field each requires stays required.
149
+ */
150
+ function objectRoot(node, root = node, depth = 0) {
151
+ const unions = ['allOf', 'anyOf', 'oneOf'].filter((key) => Array.isArray(node[key]));
152
+ if (node.type === 'object' && unions.length === 0)
153
+ return false;
154
+ const properties = isSchemaRecord(node.properties) ? { ...node.properties } : {};
155
+ const required = Array.isArray(node.required) ? [...node.required] : [];
156
+ for (const branch0 of Array.isArray(node.allOf) ? node.allOf : []) {
157
+ let branch = localRef(root, isSchemaRecord(branch0) ? branch0 : {});
158
+ if (unions.length > 0 && depth < 8)
159
+ objectRoot((branch = structuredClone(branch)), root, depth + 1);
160
+ if (isSchemaRecord(branch) && isSchemaRecord(branch.properties)) {
161
+ for (const [key, value] of Object.entries(branch.properties))
162
+ if (!(key in properties))
163
+ properties[key] = value;
164
+ }
165
+ if (isSchemaRecord(branch) && Array.isArray(branch.required))
166
+ required.push(...branch.required);
167
+ }
168
+ const branches = [];
169
+ for (const key of ['anyOf', 'oneOf']) {
170
+ for (const branch0 of Array.isArray(node[key]) ? node[key] : []) {
171
+ let branch = localRef(root, isSchemaRecord(branch0) ? branch0 : {});
172
+ if (unions.length > 0 && depth < 8)
173
+ objectRoot((branch = structuredClone(branch)), root, depth + 1);
174
+ if (isSchemaRecord(branch) && (branch.type === 'object' || isSchemaRecord(branch.properties)))
175
+ branches.push(branch);
176
+ }
177
+ }
178
+ const from = new Map();
179
+ for (const branch of branches) {
180
+ if (!isSchemaRecord(branch.properties))
181
+ continue;
182
+ for (const [key, value] of Object.entries(branch.properties)) {
183
+ if (key in properties)
184
+ continue;
185
+ const seen = from.get(key) ?? [];
186
+ if (!seen.some((entry) => JSON.stringify(entry) === JSON.stringify(value)))
187
+ seen.push(value);
188
+ from.set(key, seen);
189
+ }
190
+ }
191
+ for (const [key, list] of from)
192
+ properties[key] = list.length === 1 ? list[0] : { anyOf: list };
193
+ delete node.allOf;
194
+ delete node.anyOf;
195
+ delete node.oneOf;
196
+ node.type = 'object';
197
+ node.properties = properties;
198
+ if (required.length > 0)
199
+ node.required = [...new Set(required)];
200
+ else
201
+ delete node.required;
202
+ return true;
203
+ }
88
204
  /** The WorkBuddy China wire adapter serving the `workbuddy` route. */
89
205
  export class WorkBuddyAdapter extends LlmAdapter {
90
206
  options;
@@ -159,6 +275,12 @@ export class WorkBuddyAdapter extends LlmAdapter {
159
275
  throw new Error('WorkBuddy is not signed in');
160
276
  const token = this.tokenOf(held.session);
161
277
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
278
+ // Capture the tool-call history so unsafe tool names in past turns can be renamed to
279
+ // the safe ones the model now sees, keeping the conversation consistent.
280
+ this.historyMessages = options.messages.filter((message) => message.role === 'assistant' || message.role === 'tool');
281
+ // The last usage frame carries the turn's real billed credit; record it so the spend
282
+ // total is a sum of real figures, not an estimate.
283
+ this.turnCredit = 0;
162
284
  try {
163
285
  const body = this.requestBody(options);
164
286
  const response = await this.fetchFn(WORKBUDDY_API, {
@@ -190,8 +312,13 @@ export class WorkBuddyAdapter extends LlmAdapter {
190
312
  }
191
313
  finally {
192
314
  watchdog.stop();
315
+ if (this.turnCredit > 0 && this.options.spendFile !== undefined) {
316
+ void recordSpend(this.options.spendFile, held.account, this.turnCredit).catch(() => { });
317
+ }
193
318
  }
194
319
  }
320
+ /** The current turn's billed credit, read off the last usage frame. */
321
+ turnCredit = 0;
195
322
  /** The stored session's fields as the token the request signs with. */
196
323
  tokenOf(session) {
197
324
  return {
@@ -215,13 +342,108 @@ export class WorkBuddyAdapter extends LlmAdapter {
215
342
  // Claude Code's own) out of every message — WorkBuddy reads them all, whatever role.
216
343
  const system = this.systemPrompt(options);
217
344
  messages.unshift({ role: 'system', content: system });
345
+ const tools = this.fittedTools(options.tools);
218
346
  return {
219
347
  model: options.model,
220
348
  messages: unflagMessages(messages),
349
+ ...tools === undefined ? {} : { tools: tools.tools },
221
350
  ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
222
351
  stream: true,
223
352
  };
224
353
  }
354
+ /**
355
+ * Mend the tool schemas the way WorkBuddy's model gateway accepts.
356
+ *
357
+ * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
358
+ * a schema with a union at the parameter root or a `\0` escape is turned away by the
359
+ * model. Each unsafe name is made safe and the safe→original map kept on the adapter so
360
+ * the model's calls can be renamed back in the answer. Adapted from magpie's fitTools.
361
+ *
362
+ * @param tools - the declared tool schemas, when any.
363
+ * @returns the mended wire tools, or undefined when there are none.
364
+ */
365
+ fittedTools(tools) {
366
+ if (tools === undefined || tools.length === 0)
367
+ return undefined;
368
+ const wire = tools.map((tool) => ({
369
+ type: 'function',
370
+ function: {
371
+ name: tool.name,
372
+ ...tool.description === '' ? {} : { description: tool.description },
373
+ ...tool.parameters === undefined ? {} : { parameters: structuredClone(tool.parameters) },
374
+ },
375
+ }));
376
+ const taken = new Set();
377
+ for (const tool of wire) {
378
+ const name = tool.function.name;
379
+ if (typeof name === 'string' && NAME_OK.test(name))
380
+ taken.add(name);
381
+ }
382
+ const names = new Map();
383
+ const theirs = new Map();
384
+ for (const tool of wire) {
385
+ const fn = tool.function;
386
+ if (typeof fn.name === 'string' && !NAME_OK.test(fn.name)) {
387
+ const safe = safeName(fn.name, taken);
388
+ names.set(safe, fn.name);
389
+ theirs.set(fn.name, safe);
390
+ fn.name = safe;
391
+ }
392
+ if (fn.parameters !== undefined) {
393
+ if (!isSchemaRecord(fn.parameters))
394
+ fn.parameters = { type: 'object', properties: {} };
395
+ else {
396
+ fitSchema(fn.parameters);
397
+ objectRoot(fn.parameters);
398
+ }
399
+ }
400
+ }
401
+ // Rename the calls in the conversation history to the safe names, so a turn that
402
+ // already called a tool matches the name the model now sees.
403
+ for (const message of this.historyMessages ?? []) {
404
+ for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
405
+ const fn = call?.function;
406
+ if (fn && typeof fn.name === 'string' && theirs.has(fn.name))
407
+ fn.name = theirs.get(fn.name);
408
+ }
409
+ if (message.role === 'tool' && typeof message.name === 'string' && theirs.has(message.name)) {
410
+ message.name = theirs.get(message.name);
411
+ }
412
+ }
413
+ this.safeToolNames = names;
414
+ return { tools: wire, names };
415
+ }
416
+ /** The safe→original tool names to restore in the answer. */
417
+ safeToolNames = new Map();
418
+ /** The assistant/tool history of the request being built, for renaming past calls. */
419
+ historyMessages = [];
420
+ /**
421
+ * Restore the original tool names the model was given, in one parsed chunk.
422
+ *
423
+ * @param value - a decoded SSE payload.
424
+ * @returns whether any name changed.
425
+ */
426
+ namedBack(value) {
427
+ let changed = false;
428
+ for (const choice of Array.isArray(value.choices) ? value.choices : []) {
429
+ if (!isSchemaRecord(choice))
430
+ continue;
431
+ for (const at of [choice.message, choice.delta]) {
432
+ if (!isSchemaRecord(at))
433
+ continue;
434
+ for (const call of Array.isArray(at.tool_calls) ? at.tool_calls : []) {
435
+ if (!isSchemaRecord(call) || !isSchemaRecord(call.function))
436
+ continue;
437
+ const original = this.safeToolNames.get(String(call.function.name));
438
+ if (original !== undefined) {
439
+ call.function.name = original;
440
+ changed = true;
441
+ }
442
+ }
443
+ }
444
+ }
445
+ return changed;
446
+ }
225
447
  /** The system prompt: the caller's, else WorkBuddy's default. */
226
448
  systemPrompt(options) {
227
449
  if (options.system !== undefined && options.system.trim() !== '')
@@ -316,6 +538,29 @@ export class WorkBuddyAdapter extends LlmAdapter {
316
538
  const msg = chunk.msg;
317
539
  throw new Error(`WorkBuddy: ${typeof msg === 'string' ? msg : `error ${wireCode}`}`);
318
540
  }
541
+ // Restore the tool names the model called to the ones the client declared, before
542
+ // the chunk is read as a stream event.
543
+ this.namedBack(chunk);
544
+ // A tool-call delta: emit it so the harness can run the tool. The id and name ride
545
+ // the first delta of each call; later deltas carry argument fragments.
546
+ const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
547
+ if (Array.isArray(toolCalls)) {
548
+ for (const call of toolCalls) {
549
+ const fn = call?.function;
550
+ if (fn === undefined || fn === null)
551
+ continue;
552
+ const index = call.index ?? 0;
553
+ const id = ToolCallId(call.id ?? `wb-tool-${index}`);
554
+ const name = fn.name;
555
+ const args = fn.arguments;
556
+ if (typeof name === 'string' && name !== '') {
557
+ yield { type: 'tool-call-delta', index, id, name, argumentsDelta: '' };
558
+ }
559
+ if (typeof args === 'string' && args !== '') {
560
+ yield { type: 'tool-call-delta', index, id, argumentsDelta: args };
561
+ }
562
+ }
563
+ }
319
564
  const delta = chunk.choices?.[0]?.delta?.content;
320
565
  if (typeof delta === 'string' && delta !== '') {
321
566
  if (!started) {
@@ -328,6 +573,11 @@ export class WorkBuddyAdapter extends LlmAdapter {
328
573
  if (usage !== undefined && usage !== null) {
329
574
  const input = usage.prompt_tokens ?? 0;
330
575
  const output = usage.completion_tokens ?? 0;
576
+ // The last usage frame carries the real billed credit; keep the latest, so a
577
+ // mid-stream retry does not double-count.
578
+ if (typeof usage.credit === 'number' && Number.isFinite(usage.credit)) {
579
+ this.turnCredit = usage.credit;
580
+ }
331
581
  yield {
332
582
  type: 'usage',
333
583
  usage: { inputTokens: input, outputTokens: output, totalTokens: usage.total_tokens ?? input + output },
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.15.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",