dsh-plugin-subscription-oauth 0.16.3 → 0.17.1

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 (43) hide show
  1. package/README.dev.md +1 -1
  2. package/README.md +7 -6
  3. package/README.zh.dev.md +1 -1
  4. package/README.zh.md +7 -6
  5. package/lib/client/FastModeButton.d.ts +0 -1
  6. package/lib/client/FastModeButton.js +48 -11
  7. package/lib/client.js +32 -9
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +21 -0
  10. package/lib/protocol.d.ts +9 -0
  11. package/lib/providers/antigravity.d.ts +34 -4
  12. package/lib/providers/antigravity.js +100 -28
  13. package/lib/providers/ark-provider.d.ts +10 -0
  14. package/lib/providers/ark-provider.js +16 -2
  15. package/lib/providers/ark.d.ts +104 -10
  16. package/lib/providers/ark.js +186 -48
  17. package/lib/providers/catalog.d.ts +92 -0
  18. package/lib/providers/catalog.js +191 -0
  19. package/lib/providers/common.d.ts +14 -0
  20. package/lib/providers/common.js +23 -0
  21. package/lib/providers/glm-provider.d.ts +10 -0
  22. package/lib/providers/glm-provider.js +16 -2
  23. package/lib/providers/glm.d.ts +100 -16
  24. package/lib/providers/glm.js +182 -85
  25. package/lib/providers/grok.d.ts +114 -27
  26. package/lib/providers/grok.js +330 -129
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +16 -4
  29. package/lib/providers/kimi.d.ts +122 -32
  30. package/lib/providers/kimi.js +243 -122
  31. package/lib/providers/mimo-provider.d.ts +14 -1
  32. package/lib/providers/mimo-provider.js +17 -6
  33. package/lib/providers/mimo.d.ts +164 -19
  34. package/lib/providers/mimo.js +318 -19
  35. package/lib/providers/model-cache.d.ts +57 -0
  36. package/lib/providers/model-cache.js +105 -0
  37. package/lib/providers/responses-stream.d.ts +8 -0
  38. package/lib/providers/responses-stream.js +21 -5
  39. package/lib/providers/workbuddy-provider.d.ts +17 -0
  40. package/lib/providers/workbuddy-provider.js +14 -6
  41. package/lib/providers/workbuddy.d.ts +140 -13
  42. package/lib/providers/workbuddy.js +501 -59
  43. package/package.json +1 -1
@@ -3,17 +3,37 @@
3
3
  *
4
4
  * WorkBuddy chats are chat completions at `https://copilot.tencent.com/v2/chat/completions`,
5
5
  * streamed only, and the API refuses a request whose User-Agent is not
6
- * `WorkBuddy/<version>`. The route ships the China build's catalog (Hy, GLM, Kimi,
7
- * DeepSeek, MiniMax). Adapted from magpie's `workbuddy` for this plugin's contracts.
6
+ * `WorkBuddy/<version>`. The model table this route publishes is the console's own
7
+ * listing — `GET /console/enterprises/<enterpriseId || personal>/models`, the route
8
+ * WorkBuddy's web console reads — fetched once and kept in this plugin's cache (see
9
+ * {@link WorkBuddyAdapter.hydrate} and {@link WorkBuddyAdapter.fetchCatalog}).
10
+ * {@link WORKBUDDY_CATALOG} is the cold-start seed used until a listing has been read,
11
+ * the fallback when a read fails, and the metadata overlay for a row the listing
12
+ * describes only partly, so a model released, renamed or retired server-side reaches the
13
+ * picker without a plugin update and without a request per refresh.
14
+ * Adapted from magpie's `workbuddy` for this plugin's contracts.
8
15
  *
9
16
  * @module dsh-plugin-subscription-oauth/providers/workbuddy
10
17
  */
11
18
  import { LlmAdapter, errorChain, ReasoningEffortId, ToolCallId, } from '@deepseek-ai/dsh-llm';
12
19
  import { idleWatchdog, mapFetchFailure } from './common.js';
13
20
  import { proxiedFetch } from '../http.js';
14
- import { findEntry } from './catalog.js';
21
+ import { composeCatalog, findEntry, liveEntry } from './catalog.js';
15
22
  import { WORKBUDDY_ENDPOINT, workbuddyHeaders } from '../auth/workbuddy-flow.js';
16
23
  import { recordSpend } from './workbuddy-credits.js';
24
+ import { appendFileSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import path from 'node:path';
27
+ /** Append one diagnostic line to a file the user can read regardless of where stdout goes. */
28
+ function diag(message) {
29
+ const line = `[${new Date().toISOString()}] ${message}\n`;
30
+ try {
31
+ appendFileSync(path.join(homedir(), '.dsh', 'workbuddy-diag.log'), line);
32
+ }
33
+ catch {
34
+ console.warn(`[wb-diag] ${message}`);
35
+ }
36
+ }
17
37
  /** The `workbuddy` provider route key (China build). */
18
38
  export const WORKBUDDY_PROVIDER_ID = 'workbuddy';
19
39
  /** Human name for the route and its cards. */
@@ -22,9 +42,45 @@ export const WORKBUDDY_PROVIDER_NAME = 'WorkBuddy';
22
42
  export const WORKBUDDY_CODE_URL = 'https://copilot.tencent.com';
23
43
  /** The chat completions endpoint. */
24
44
  const WORKBUDDY_API = `${WORKBUDDY_ENDPOINT}/v2/chat/completions`;
25
- /** The model the picker preselects. */
26
- export const WORKBUDDY_DEFAULT_MODEL = 'hy4-preview-f';
27
- /** The China build's catalog, [id, name, context, efforts?]. */
45
+ /**
46
+ * The model the picker preselects.
47
+ *
48
+ * The console's own default, and a row its listing actually serves, so the page's
49
+ * "recommended" marker never names a model the picker cannot offer. The previous value
50
+ * (`hy4-preview-f`) was a seed-table spelling the live listing does not serve — it offers
51
+ * `hy4-preview` — which is the staleness this route no longer keeps in its catalog.
52
+ */
53
+ export const WORKBUDDY_DEFAULT_MODEL = 'auto';
54
+ /** Context advertised for a listed id neither the listing nor the seed table describes. */
55
+ export const WORKBUDDY_FALLBACK_CONTEXT_WINDOW = 168_000;
56
+ /**
57
+ * The console route that lists the models one account may use.
58
+ *
59
+ * The scope segment is the enterprise the session is attributed to, or `personal` for an
60
+ * account that states none. This is the route WorkBuddy's own console reads; it is not
61
+ * one of the plugin's `/v2` API routes, which is why probing those found nothing.
62
+ *
63
+ * @param enterpriseId - the session's enterprise, when it has one.
64
+ * @returns the absolute listing URL.
65
+ */
66
+ export function workbuddyModelsUrl(enterpriseId) {
67
+ const scope = typeof enterpriseId === 'string' && enterpriseId.trim() !== '' ? enterpriseId.trim() : 'personal';
68
+ return `${WORKBUDDY_ENDPOINT}/console/enterprises/${encodeURIComponent(scope)}/models`;
69
+ }
70
+ /**
71
+ * The cold-start seed table for the China build's catalog, [id, name, context, efforts?].
72
+ *
73
+ * Not the roster. Membership and metadata come from WorkBuddy's own listing (see
74
+ * {@link WorkBuddyAdapter.fetchCatalog}); this table is what the picker shows until a
75
+ * listing has been read, what a failed read falls back to, and the metadata overlay for a
76
+ * listed row that states less than this table knows.
77
+ *
78
+ * It cannot be the authority, because it ships with the plugin: a model WorkBuddy
79
+ * releases, renames or retires server-side would reach a user only through a plugin
80
+ * update. The live listing already contradicts it — `hy4-preview-f` and `hy3-x` are rows
81
+ * here that the console does not serve, while `hy4-preview`, `step-5-preview`,
82
+ * `space-bunny` and `kimi-k2.8-preview` are served and are not rows here.
83
+ */
28
84
  const CN_MODELS = [
29
85
  ['auto', 'Auto', 168_000],
30
86
  ['hy4-preview-f', 'Hy4 preview', 1_000_000, ['high']],
@@ -42,7 +98,14 @@ const CN_MODELS = [
42
98
  ['kimi-k2.6', 'Kimi-K2.6', 256_000],
43
99
  ['deepseek-v4-pro', 'Deepseek-V4-Pro', 1_000_000, ['none', 'high', 'xhigh']],
44
100
  ];
45
- /** The catalog the route serves. */
101
+ /**
102
+ * The seed table the route falls back to when no listing has been read (see
103
+ * {@link CN_MODELS}).
104
+ *
105
+ * A table this build ships, never the roster: WorkBuddy's own listing decides which
106
+ * models exist, and each row's own figures decide what they are. This is only the
107
+ * cold-start membership and the overlay for what a listing leaves unsaid.
108
+ */
46
109
  export const WORKBUDDY_CATALOG = CN_MODELS.map((entry) => {
47
110
  const [id, name, contextWindow, efforts] = entry;
48
111
  return {
@@ -52,6 +115,107 @@ export const WORKBUDDY_CATALOG = CN_MODELS.map((entry) => {
52
115
  ...efforts === undefined ? {} : { reasoningEfforts: efforts },
53
116
  };
54
117
  });
118
+ /** The reasoning rungs this route publishes; a listing's own word outside the ladder is dropped. */
119
+ const REASONING_LADDER = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
120
+ /** A non-empty trimmed string from a wire field. */
121
+ function textField(value) {
122
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
123
+ }
124
+ /** A positive whole number from a wire field, stated as a number or as a numeric string. */
125
+ function countField(value) {
126
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0)
127
+ return Math.floor(value);
128
+ if (typeof value !== 'string' || value.trim() === '')
129
+ return undefined;
130
+ const parsed = Number(value);
131
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : undefined;
132
+ }
133
+ /**
134
+ * The facts one row of WorkBuddy's own listing states.
135
+ *
136
+ * The shared reader knows the field names Kimi, xAI and BigModel use
137
+ * (`context_length`, `think_efforts`, `supports_image_in`); WorkBuddy states its own —
138
+ * `maxInputTokens` (a few rows `contextWindow`, some `maxAllowedSize`), `supportsImages`,
139
+ * `reasoning.effort` — so a row is read here and the result handed to the shared merge.
140
+ * A field the row does not state stays absent, so the seed table can describe it.
141
+ *
142
+ * @param row - one entry of the listing's `data.models`.
143
+ * @returns the facts, or undefined when the row names no model.
144
+ */
145
+ function workbuddyFacts(row) {
146
+ if (!isSchemaRecord(row))
147
+ return undefined;
148
+ const id = textField(row['id']);
149
+ if (id === undefined)
150
+ return undefined;
151
+ const name = textField(row['name']);
152
+ const description = textField(row['descriptionEn']) ?? textField(row['descriptionZh']);
153
+ // The row's own order of preference: what it takes per request, the window it states,
154
+ // then the cap the app applies. The first figure it states is the one it means.
155
+ const contextWindow = countField(row['maxInputTokens'])
156
+ ?? countField(row['contextWindow'])
157
+ ?? countField(row['maxAllowedSize']);
158
+ // A row is text-only unless it says otherwise; `supportsImages` is the app's own field.
159
+ const inputModalities = row['supportsImages'] === true ? ['text', 'image'] : ['text'];
160
+ const reasoning = isSchemaRecord(row['reasoning']) ? row['reasoning'] : undefined;
161
+ const stated = reasoning === undefined ? undefined : textField(reasoning['effort'])?.toLowerCase();
162
+ // A word the harness has no rung for (`turbo`) is dropped rather than advertised.
163
+ const rung = stated !== undefined && REASONING_LADDER.has(stated) ? stated : undefined;
164
+ return {
165
+ id,
166
+ inputModalities,
167
+ ...name === undefined ? {} : { name },
168
+ ...description === undefined ? {} : { description },
169
+ ...contextWindow === undefined ? {} : { contextWindow },
170
+ ...rung === undefined ? {} : { reasoningEfforts: [rung], defaultEffort: rung },
171
+ };
172
+ }
173
+ /** The `data` object of one listing payload, from either shape a cache may hold. */
174
+ function listingData(payload) {
175
+ if (Array.isArray(payload) || !isSchemaRecord(payload))
176
+ return undefined;
177
+ const data = payload['data'];
178
+ if (isSchemaRecord(data))
179
+ return data;
180
+ // A cache written as the route's own object (`{models, agents}`) is read too.
181
+ return 'models' in payload || 'agents' in payload ? payload : undefined;
182
+ }
183
+ /** The model rows of one listing payload. */
184
+ function listingRows(payload, data) {
185
+ if (Array.isArray(payload))
186
+ return payload;
187
+ const models = data?.['models'];
188
+ return Array.isArray(models) ? models : [];
189
+ }
190
+ /**
191
+ * The ids WorkBuddy's own console offers, when the listing narrows them.
192
+ *
193
+ * The console reads the account's `cli` agent: its `models` list is the set enabled for
194
+ * this account, and it can be narrower than the model table (models the account may see
195
+ * but not call). An absent, empty or non-array `cli` entry means the whole table is
196
+ * available, which is the console's own fallback.
197
+ *
198
+ * @param data - the listing's `data` object.
199
+ * @returns the ids to offer, or undefined when the listing does not narrow them.
200
+ */
201
+ function cliModelIds(data) {
202
+ const agents = data?.['agents'];
203
+ if (!Array.isArray(agents))
204
+ return undefined;
205
+ for (const agent of agents) {
206
+ if (!isSchemaRecord(agent) || textField(agent['name']) !== 'cli')
207
+ continue;
208
+ const models = agent['models'];
209
+ if (!Array.isArray(models))
210
+ return undefined;
211
+ const ids = models.flatMap((model) => {
212
+ const id = textField(model);
213
+ return id === undefined ? [] : [id];
214
+ });
215
+ return ids.length === 0 ? undefined : ids;
216
+ }
217
+ return undefined;
218
+ }
55
219
  /**
56
220
  * Words WorkBuddy refuses a chat for ("Illegal API invocation from an unapproved
57
221
  * channel"), each with words that say the same and pass. WorkBuddy reads every message,
@@ -86,6 +250,10 @@ function randomHex() {
86
250
  crypto.getRandomValues(bytes);
87
251
  return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
88
252
  }
253
+ // DSH runs a turn and a session-title request at the same time on one adapter instance,
254
+ // so nothing a request needs may live on the instance: the tool rename map, the history
255
+ // it rewrites, the text it accumulates, and the credit it bills are all locals, built and
256
+ // consumed inside the one `stream()` call that owns them.
89
257
  /** A tool's name may hold letters, digits, `_`, `-`; another character is refused. */
90
258
  const NAME_OK = /^[A-Za-z0-9_-]+$/;
91
259
  /** A name with each character WorkBuddy refuses as `_`, kept apart from `taken`. */
@@ -205,6 +373,16 @@ function objectRoot(node, root = node, depth = 0) {
205
373
  export class WorkBuddyAdapter extends LlmAdapter {
206
374
  options;
207
375
  fetchFn;
376
+ /**
377
+ * The catalog this route serves: entries derived from WorkBuddy's own listing, either
378
+ * the one just read or the one the cache kept from the last read.
379
+ *
380
+ * Undefined means neither source has produced a listing yet, so the seed table
381
+ * ({@link WORKBUDDY_CATALOG}) is what the picker shows.
382
+ */
383
+ live;
384
+ /** Whether the cached listing has been read; it is read at most once per session. */
385
+ hydrated = false;
208
386
  constructor(options) {
209
387
  super();
210
388
  this.options = options;
@@ -213,11 +391,69 @@ export class WorkBuddyAdapter extends LlmAdapter {
213
391
  providerInfo(provider) {
214
392
  return { id: provider, name: WORKBUDDY_PROVIDER_NAME };
215
393
  }
394
+ /**
395
+ * Adopt one listing as this route's catalog.
396
+ *
397
+ * Everything the picker shows is derived here from WorkBuddy's own answer: which models
398
+ * exist (the account's `cli` agent when the listing states one), in the order that
399
+ * listing states them, each with the name, window, modalities and reasoning rung its row
400
+ * states. The seed table is consulted only for what a row leaves unsaid, so a live
401
+ * figure is never overridden by a shipped guess.
402
+ *
403
+ * @param payload - the decoded listing, or the `{models, agents}` object a cache kept.
404
+ * @returns the entries to publish, in picker order.
405
+ */
406
+ adoptListing(payload) {
407
+ const data = listingData(payload);
408
+ const byId = new Map();
409
+ for (const row of listingRows(payload, data)) {
410
+ const facts = workbuddyFacts(row);
411
+ if (facts !== undefined && !byId.has(facts.id))
412
+ byId.set(facts.id, facts);
413
+ }
414
+ // Membership is the account's own `cli` list when the listing states one, and the
415
+ // whole model table otherwise: the same rule WorkBuddy's console applies.
416
+ const membership = cliModelIds(data) ?? [...byId.keys()];
417
+ const entries = [];
418
+ const seen = new Set();
419
+ for (const id of membership) {
420
+ if (seen.has(id))
421
+ continue;
422
+ seen.add(id);
423
+ entries.push(liveEntry(byId.get(id) ?? { id }, WORKBUDDY_CATALOG, WORKBUDDY_FALLBACK_CONTEXT_WINDOW, 'Reported by the WorkBuddy model list'));
424
+ }
425
+ return entries;
426
+ }
427
+ /**
428
+ * Read the cached listing, once, so the picker works without asking the console.
429
+ *
430
+ * Called before the first catalog query. A read failure is a cache miss, never a
431
+ * failure: the route falls back to its seed table until a listing is fetched, so a
432
+ * broken cache can never empty the picker.
433
+ */
434
+ async hydrate() {
435
+ if (this.hydrated)
436
+ return;
437
+ this.hydrated = true;
438
+ if (this.live !== undefined || this.options.cachedListing === undefined)
439
+ return;
440
+ try {
441
+ const entries = this.adoptListing(await this.options.cachedListing());
442
+ if (entries.length > 0)
443
+ this.live = entries;
444
+ }
445
+ catch (error) {
446
+ this.options.onWarn?.(`cached model catalog unavailable (${errorChain(error)})`);
447
+ }
448
+ }
216
449
  async listModels(_provider) {
450
+ // Nothing to offer without a signed-in account: a model that cannot be called
451
+ // must not appear in the picker, and Auto must not try it.
217
452
  if (await this.options.tokens.session() === undefined)
218
453
  return [];
454
+ await this.hydrate();
219
455
  const hidden = await this.hidden();
220
- return WORKBUDDY_CATALOG.filter((entry) => !hidden.has(entry.id)).map((entry) => ({
456
+ return this.shippedCatalog().filter((entry) => !hidden.has(entry.id)).map((entry) => ({
221
457
  provider: WORKBUDDY_PROVIDER_ID,
222
458
  id: entry.id,
223
459
  name: entry.name,
@@ -235,17 +471,53 @@ export class WorkBuddyAdapter extends LlmAdapter {
235
471
  return new Set();
236
472
  }
237
473
  }
474
+ /**
475
+ * The catalog to present: the models WorkBuddy's listing named, in the order its own
476
+ * console states them, each with the metadata that listing stated.
477
+ *
478
+ * The seed table is the membership only while no listing has been read, and the metadata
479
+ * overlay for a row the listing describes partly. It is never allowed to add a row back:
480
+ * a model the console stopped serving must leave the picker without a plugin update,
481
+ * which is the whole point of reading the listing instead of shipping a roster.
482
+ */
483
+ shippedCatalog() {
484
+ return composeCatalog({
485
+ curated: WORKBUDDY_CATALOG,
486
+ ...this.live === undefined ? {} : { live: this.live },
487
+ fallbackContextWindow: WORKBUDDY_FALLBACK_CONTEXT_WINDOW,
488
+ liveDescription: 'Reported by the WorkBuddy model list',
489
+ });
490
+ }
238
491
  /** The catalog the Settings list reads. */
239
492
  catalogEntries() {
240
- return WORKBUDDY_CATALOG;
493
+ return this.shippedCatalog();
241
494
  }
242
495
  /** The entries the route itself serves, without hand-added extras. */
243
496
  ownCatalogEntries() {
244
- return WORKBUDDY_CATALOG;
497
+ return this.shippedCatalog();
498
+ }
499
+ /**
500
+ * Adopt the ids a caller reports as the served set, without a listing.
501
+ *
502
+ * {@link fetchCatalog} already derived the entries from the listing it read, and the
503
+ * controller reports those same ids back here; the entries it recorded are kept, so a
504
+ * live name, window or rung is not replaced by the seed table's. A caller that only has
505
+ * ids (a test, another discovery path) gets the seed table's metadata where it knows the
506
+ * id and conservative entries otherwise.
507
+ *
508
+ * @param ids - model ids the caller reports.
509
+ */
510
+ discover(ids) {
511
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
512
+ this.live = ids.map((id) => known.get(id) ?? liveEntry({ id }, WORKBUDDY_CATALOG, WORKBUDDY_FALLBACK_CONTEXT_WINDOW, 'Reported by the WorkBuddy model list'));
245
513
  }
246
514
  async resolveModel(_provider, model) {
247
- const entry = findEntry(WORKBUDDY_CATALOG, model);
515
+ await this.hydrate();
516
+ const entry = findEntry(this.shippedCatalog(), model);
248
517
  if (entry === undefined) {
518
+ // An id no source describes is still dispatchable: WorkBuddy is the authority on
519
+ // what it serves, and a model added after this build should not be refused. It is
520
+ // advertised conservatively rather than with a guess.
249
521
  return {
250
522
  provider: WORKBUDDY_PROVIDER_ID,
251
523
  id: model,
@@ -254,6 +526,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
254
526
  inputModalities: ['text', 'image'],
255
527
  };
256
528
  }
529
+ const reasoning = reasoningInfo(entry);
257
530
  return {
258
531
  provider: WORKBUDDY_PROVIDER_ID,
259
532
  id: entry.id,
@@ -261,15 +534,114 @@ export class WorkBuddyAdapter extends LlmAdapter {
261
534
  ...entry.description === undefined ? {} : { description: entry.description },
262
535
  ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
263
536
  context: { contextWindow: entry.contextWindow },
264
- ...entry.reasoningEfforts === undefined || entry.reasoningEfforts.length === 0
265
- ? {}
266
- : { reasoning: { efforts: entry.reasoningEfforts.map((id) => ({ id: ReasoningEffortId(id), name: id })) } },
537
+ ...reasoning === undefined ? {} : { reasoning },
267
538
  };
268
539
  }
540
+ /**
541
+ * Read WorkBuddy's own console listing and adopt it as this route's catalog.
542
+ *
543
+ * This is the one place a model table is produced, and it is WorkBuddy's own answer: the
544
+ * account's `cli` agent decides which models are available, and each row states its own
545
+ * name, window, modalities and reasoning rung (see {@link adoptListing}). The payload is
546
+ * also handed to {@link WorkBuddyAdapterOptions.saveListing}, so the next session starts
547
+ * from this table instead of asking again — the Settings "refresh models" action is what
548
+ * re-reads it, and a picker query never does.
549
+ *
550
+ * A failed or empty read keeps the catalog the route already had, and says so once.
551
+ *
552
+ * @param accessToken - bearer for the account.
553
+ * @param enterpriseId - the enterprise to read; taken from the stored session when omitted.
554
+ * @param signal - optional cancellation.
555
+ * @returns the model ids now served, or undefined when the listing is unavailable.
556
+ */
557
+ async fetchCatalog(accessToken, enterpriseId, signal) {
558
+ if (!this.options.discovery)
559
+ return undefined;
560
+ try {
561
+ // The cached listing is read first, so a read that fails leaves the route on the
562
+ // table the endpoint itself answered last rather than on the seed this build ships.
563
+ await this.hydrate();
564
+ // The console route is attributed by the session's own fields (`X-User-Id`, the
565
+ // domain, the enterprise), so the session the caller's token belongs to is read back.
566
+ const held = await this.sessionFor(accessToken);
567
+ const scope = enterpriseId ?? held?.enterpriseId;
568
+ const token = {
569
+ ...held ?? { accessToken, expiresAt: 0 },
570
+ accessToken,
571
+ ...scope === undefined ? {} : { enterpriseId: scope },
572
+ };
573
+ const response = await this.fetchFn(workbuddyModelsUrl(scope), {
574
+ headers: {
575
+ ...workbuddyHeaders(token),
576
+ // The console route is an XHR endpoint: it states the encodings it accepts and
577
+ // refuses a request that does not present itself as the console's own.
578
+ 'Accept': 'application/json, text/plain, */*',
579
+ 'X-Requested-With': 'XMLHttpRequest',
580
+ },
581
+ ...signal === undefined ? {} : { signal },
582
+ });
583
+ if (!response.ok) {
584
+ this.options.onWarn?.(`WorkBuddy model list unavailable (HTTP ${response.status}); keeping the last catalog`);
585
+ return undefined;
586
+ }
587
+ const payload = await response.json();
588
+ const entries = this.adoptListing(payload);
589
+ if (entries.length === 0) {
590
+ this.options.onWarn?.('WorkBuddy model discovery returned an empty catalog; keeping the last catalog');
591
+ return undefined;
592
+ }
593
+ this.live = entries;
594
+ // The endpoint's own payload is what is kept, so a later build that reads more of it
595
+ // benefits from a cache this one wrote. A cache failure never fails the read that
596
+ // produced it.
597
+ if (this.options.saveListing !== undefined) {
598
+ try {
599
+ await this.options.saveListing(payload);
600
+ }
601
+ catch (error) {
602
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
603
+ }
604
+ }
605
+ return entries.map((entry) => entry.id);
606
+ }
607
+ catch (error) {
608
+ if (signal?.aborted === true)
609
+ return undefined;
610
+ this.options.onWarn?.(`WorkBuddy model list unavailable; keeping the last catalog (${errorChain(error)})`);
611
+ return undefined;
612
+ }
613
+ }
614
+ /**
615
+ * The stored session one access token belongs to.
616
+ *
617
+ * Discovery is handed the account's token rather than the session it came from, and the
618
+ * console route is attributed by that session's own fields, so the session is read back
619
+ * here. The first account whose stored token matches wins; a token that matches none
620
+ * falls back to the route's current session. A read failure is never fatal — the listing
621
+ * is still read with the token the caller passed.
622
+ *
623
+ * @param accessToken - the token discovery was called with.
624
+ * @returns the session's fields as a signable token, or undefined when none was readable.
625
+ */
626
+ async sessionFor(accessToken) {
627
+ try {
628
+ for (const account of await this.options.tokens.accounts()) {
629
+ const held = await this.options.tokens.session(account);
630
+ if (held?.session.accessToken === accessToken)
631
+ return this.tokenOf(held.session);
632
+ }
633
+ const current = await this.options.tokens.session();
634
+ return current === undefined ? undefined : this.tokenOf(current.session);
635
+ }
636
+ catch {
637
+ return undefined;
638
+ }
639
+ }
269
640
  providerRetryPolicy() {
270
641
  return undefined;
271
642
  }
272
643
  async *stream(options) {
644
+ diag(`stream entered: model=${options.model} provider=${options.provider}`);
273
645
  const held = await this.options.tokens.session();
274
646
  if (held === undefined)
275
647
  throw new Error('WorkBuddy is not signed in');
@@ -277,12 +649,14 @@ export class WorkBuddyAdapter extends LlmAdapter {
277
649
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
278
650
  // Capture the tool-call history so unsafe tool names in past turns can be renamed to
279
651
  // the safe ones the model now sees, keeping the conversation consistent.
280
- this.historyMessages = options.messages.filter((message) => message.role === 'assistant' || message.role === 'tool');
652
+ const history = options.messages.filter((message) => message.role === 'assistant' || message.role === 'tool');
281
653
  // The last usage frame carries the turn's real billed credit; record it so the spend
282
654
  // total is a sum of real figures, not an estimate.
283
- this.turnCredit = 0;
655
+ const turn = { credit: 0 };
656
+ diag(`stream start: model=${options.model} account=${held.account} expiresAt=${held.session.expiresAt} now=${Date.now()}`);
284
657
  try {
285
- const body = this.requestBody(options);
658
+ const { body, names } = this.requestBody(options, history);
659
+ diag(`body: keys=${Object.keys(body).join(',')} messages=${Array.isArray(body.messages) ? body.messages.length : 0} model=${String(body.model)}`);
286
660
  const response = await this.fetchFn(WORKBUDDY_API, {
287
661
  method: 'POST',
288
662
  headers: {
@@ -300,25 +674,24 @@ export class WorkBuddyAdapter extends LlmAdapter {
300
674
  body: JSON.stringify(body),
301
675
  signal: watchdog.signal,
302
676
  });
677
+ diag(`response: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
303
678
  // WorkBuddy refuses a chat mid-stream when the request carries words it filters; a
304
679
  // 403/400 here names why, so surface it instead of dying on an empty stream.
305
680
  if (!response.ok || response.body === null) {
306
681
  throw await this.errorOf(response);
307
682
  }
308
- yield* this.readStream(response, watchdog);
683
+ yield* this.readStream(response, watchdog, names, turn);
309
684
  }
310
685
  catch (error) {
311
686
  throw mapFetchFailure(WORKBUDDY_PROVIDER_NAME, error, watchdog, options.signal);
312
687
  }
313
688
  finally {
314
689
  watchdog.stop();
315
- if (this.turnCredit > 0 && this.options.spendFile !== undefined) {
316
- void recordSpend(this.options.spendFile, held.account, this.turnCredit).catch(() => { });
690
+ if (turn.credit > 0 && this.options.spendFile !== undefined) {
691
+ void recordSpend(this.options.spendFile, held.account, turn.credit).catch(() => { });
317
692
  }
318
693
  }
319
694
  }
320
- /** The current turn's billed credit, read off the last usage frame. */
321
- turnCredit = 0;
322
695
  /** The stored session's fields as the token the request signs with. */
323
696
  tokenOf(session) {
324
697
  return {
@@ -332,7 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
332
705
  ...session.refreshExpiresAt === undefined ? {} : { refreshExpiresAt: session.refreshExpiresAt },
333
706
  };
334
707
  }
335
- requestBody(options) {
708
+ requestBody(options, history) {
336
709
  const messages = [];
337
710
  for (const message of options.messages) {
338
711
  messages.push(this.toChatMessage(message));
@@ -342,13 +715,19 @@ export class WorkBuddyAdapter extends LlmAdapter {
342
715
  // Claude Code's own) out of every message — WorkBuddy reads them all, whatever role.
343
716
  const system = this.systemPrompt(options);
344
717
  messages.unshift({ role: 'system', content: system });
345
- const tools = this.fittedTools(options.tools);
718
+ const tools = this.fittedTools(options.tools, history);
346
719
  return {
347
- model: options.model,
348
- messages: unflagMessages(messages),
349
- ...tools === undefined ? {} : { tools: tools.tools },
350
- ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
351
- stream: true,
720
+ body: {
721
+ model: options.model,
722
+ messages: unflagMessages(messages),
723
+ ...tools === undefined ? {} : { tools: tools.tools },
724
+ ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
725
+ stream: true,
726
+ stream_options: { include_usage: true },
727
+ },
728
+ // The names this request mended, handed to the reader that renames the answer back:
729
+ // a request that declared no tools gets its own empty map, never a neighbour's.
730
+ names: tools?.names ?? new Map(),
352
731
  };
353
732
  }
354
733
  /**
@@ -356,13 +735,14 @@ export class WorkBuddyAdapter extends LlmAdapter {
356
735
  *
357
736
  * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
358
737
  * 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.
738
+ * model. Each unsafe name is made safe and the safe→original map returned to the caller
739
+ * so the model's calls can be renamed back in the answer. Adapted from magpie's fitTools.
361
740
  *
362
741
  * @param tools - the declared tool schemas, when any.
742
+ * @param history - the request's assistant/tool turns, renamed in place to the safe names.
363
743
  * @returns the mended wire tools, or undefined when there are none.
364
744
  */
365
- fittedTools(tools) {
745
+ fittedTools(tools, history) {
366
746
  if (tools === undefined || tools.length === 0)
367
747
  return undefined;
368
748
  const wire = tools.map((tool) => ({
@@ -400,7 +780,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
400
780
  }
401
781
  // Rename the calls in the conversation history to the safe names, so a turn that
402
782
  // already called a tool matches the name the model now sees.
403
- for (const message of this.historyMessages ?? []) {
783
+ for (const message of history) {
404
784
  for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
405
785
  const fn = call?.function;
406
786
  if (fn && typeof fn.name === 'string' && theirs.has(fn.name))
@@ -410,20 +790,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
410
790
  message.name = theirs.get(message.name);
411
791
  }
412
792
  }
413
- this.safeToolNames = names;
414
793
  return { tools: wire, names };
415
794
  }
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
795
  /**
421
796
  * Restore the original tool names the model was given, in one parsed chunk.
422
797
  *
423
798
  * @param value - a decoded SSE payload.
799
+ * @param names - the safe→original map the request that produced this stream mended.
424
800
  * @returns whether any name changed.
425
801
  */
426
- namedBack(value) {
802
+ namedBack(value, names) {
427
803
  let changed = false;
428
804
  for (const choice of Array.isArray(value.choices) ? value.choices : []) {
429
805
  if (!isSchemaRecord(choice))
@@ -434,7 +810,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
434
810
  for (const call of Array.isArray(at.tool_calls) ? at.tool_calls : []) {
435
811
  if (!isSchemaRecord(call) || !isSchemaRecord(call.function))
436
812
  continue;
437
- const original = this.safeToolNames.get(String(call.function.name));
813
+ const original = names.get(String(call.function.name));
438
814
  if (original !== undefined) {
439
815
  call.function.name = original;
440
816
  changed = true;
@@ -492,17 +868,34 @@ export class WorkBuddyAdapter extends LlmAdapter {
492
868
  : response.status === 429 ? 'RATE_LIMIT'
493
869
  : response.status >= 500 ? 'SERVER' : 'HTTP_ERROR', { status: response.status });
494
870
  }
495
- async *readStream(response, watchdog) {
871
+ async *readStream(response, watchdog, names, turn) {
496
872
  const reader = response.body.getReader();
497
873
  const decoder = new TextDecoder();
498
874
  let buffer = '';
499
875
  let started = false;
876
+ // `block-end` carries the *assembled* block, and the harness takes that one as the
877
+ // block's final content - it does not keep what the deltas accumulated. An empty text
878
+ // there renders the whole answer as a blank body, whatever streamed before it.
879
+ let text = '';
880
+ // One entry per tool call the model streams: its own block index, its identity, and the
881
+ // argument fragments seen so far. Calls are indexed past the text block so a reply that
882
+ // both writes and calls assembles as two blocks instead of folding the call into the
883
+ // text block (where it is silently lost and the harness loops on a call that never ran).
884
+ const calls = new Map();
885
+ let framesSeen = 0;
886
+ let bytesSeen = 0;
887
+ let firstBytes = '';
888
+ let sawToolCall = false;
889
+ diag(`stream open: status=${response.status} content-type=${response.headers.get('content-type') ?? ''}`);
500
890
  try {
501
891
  while (true) {
502
892
  const { done, value } = await reader.read();
503
893
  if (done)
504
894
  break;
505
895
  watchdog.pulse();
896
+ bytesSeen += value.byteLength;
897
+ if (firstBytes.length < 200)
898
+ firstBytes += decoder.decode(value.slice(0, 200 - firstBytes.length), { stream: false });
506
899
  buffer += decoder.decode(value, { stream: true });
507
900
  // WorkBuddy's SSE frames are blank-line separated, whatever the platform's line
508
901
  // ending (\n, \r\n, or \r alone): split on any blank line so a \r\n stream is not
@@ -515,15 +908,22 @@ export class WorkBuddyAdapter extends LlmAdapter {
515
908
  .filter((line) => line.startsWith('data:'))
516
909
  .map((line) => line.slice(5).trim())
517
910
  .join('\n');
911
+ framesSeen += 1;
912
+ if (framesSeen <= 6)
913
+ diag(`f${framesSeen} raw-data=${JSON.stringify(data.slice(0, 100))}`);
518
914
  if (data === '' || data === '[DONE]')
519
915
  continue;
520
916
  let chunk;
521
917
  try {
522
918
  chunk = JSON.parse(data);
523
919
  }
524
- catch {
920
+ catch (error) {
921
+ if (framesSeen <= 6)
922
+ diag(`f${framesSeen} PARSE FAIL: ${error instanceof Error ? error.message : String(error)}`);
525
923
  continue;
526
924
  }
925
+ if (framesSeen <= 6)
926
+ diag(`f${framesSeen} delta=${JSON.stringify(chunk.choices?.[0]?.delta ?? null).slice(0, 120)}`);
527
927
  // WorkBuddy names a refusal mid-stream (an error frame, or a body with a non-zero
528
928
  // code): surface it instead of reading the rest as silence, so the turn fails with
529
929
  // the reason rather than stopping after a few empty seconds.
@@ -540,24 +940,38 @@ export class WorkBuddyAdapter extends LlmAdapter {
540
940
  }
541
941
  // Restore the tool names the model called to the ones the client declared, before
542
942
  // the chunk is read as a stream event.
543
- this.namedBack(chunk);
943
+ this.namedBack(chunk, names);
544
944
  // A tool-call delta: emit it so the harness can run the tool. The id and name ride
545
945
  // the first delta of each call; later deltas carry argument fragments.
546
946
  const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
547
947
  if (Array.isArray(toolCalls)) {
948
+ if (framesSeen <= 8)
949
+ diag(`f${framesSeen} tool_calls=${JSON.stringify(toolCalls).slice(0, 160)}`);
548
950
  for (const call of toolCalls) {
549
951
  const fn = call?.function;
550
952
  if (fn === undefined || fn === null)
551
953
  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: '' };
954
+ const name = typeof fn.name === 'string' ? fn.name : '';
955
+ const args = typeof fn.arguments === 'string' ? fn.arguments : '';
956
+ const slot = call.index ?? 0;
957
+ let open = calls.get(slot);
958
+ // A reply ends with an empty function stub that is not a call: opening a block
959
+ // for it would end the turn as `tool-calls` with nothing for the harness to run.
960
+ if (open === undefined && name === '' && args === '')
961
+ continue;
962
+ if (open === undefined) {
963
+ open = { index: 1 + slot, id: call.id ?? `wb-tool-${slot}`, name: '', args: '' };
964
+ calls.set(slot, open);
965
+ sawToolCall = true;
966
+ yield { type: 'block-start', index: open.index, blockType: 'tool-call' };
967
+ }
968
+ if (name !== '' && open.name === '') {
969
+ open.name = name;
970
+ yield { type: 'tool-call-delta', index: open.index, id: ToolCallId(open.id), name, argumentsDelta: '' };
558
971
  }
559
- if (typeof args === 'string' && args !== '') {
560
- yield { type: 'tool-call-delta', index, id, argumentsDelta: args };
972
+ if (args !== '') {
973
+ open.args += args;
974
+ yield { type: 'tool-call-delta', index: open.index, id: ToolCallId(open.id), argumentsDelta: args };
561
975
  }
562
976
  }
563
977
  }
@@ -567,6 +981,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
567
981
  yield { type: 'block-start', index: 0, blockType: 'text' };
568
982
  started = true;
569
983
  }
984
+ text += delta;
570
985
  yield { type: 'text-delta', index: 0, text: delta };
571
986
  }
572
987
  const usage = chunk.usage;
@@ -576,7 +991,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
576
991
  // The last usage frame carries the real billed credit; keep the latest, so a
577
992
  // mid-stream retry does not double-count.
578
993
  if (typeof usage.credit === 'number' && Number.isFinite(usage.credit)) {
579
- this.turnCredit = usage.credit;
994
+ turn.credit = usage.credit;
580
995
  }
581
996
  yield {
582
997
  type: 'usage',
@@ -585,18 +1000,45 @@ export class WorkBuddyAdapter extends LlmAdapter {
585
1000
  }
586
1001
  }
587
1002
  }
1003
+ // Close every open block with its assembled content: `block-end` is the block the
1004
+ // harness keeps, so the text that streamed has to ride it.
588
1005
  if (started)
589
- yield { type: 'block-end', index: 0, block: { type: 'text', text: '' } };
590
- // A stream that produced no text and no error usually means the gateway accepted the
591
- // request then closed it: name that in the console so the next F12 shows why the turn
592
- // stopped, instead of a silent finish that reads as a clean answer.
593
- if (!started) {
594
- console.warn(`[subscription-oauth] WorkBuddy stream ended with no text after ${String(this.turnCredit > 0 ? 'some' : 'no')} credit; the gateway likely cut it.`);
1006
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } };
1007
+ for (const open of calls.values()) {
1008
+ yield {
1009
+ type: 'block-end',
1010
+ index: open.index,
1011
+ block: { type: 'tool-call', id: ToolCallId(open.id), name: open.name, arguments: open.args },
1012
+ };
595
1013
  }
596
- yield { type: 'finish', reason: { kind: 'stop' } };
1014
+ diag(`stream done: frames=${framesSeen} bytes=${bytesSeen} started=${started} text=${text.length} calls=${calls.size} sawToolCall=${sawToolCall} firstBytes=${JSON.stringify(firstBytes.slice(0, 80))}`);
1015
+ // A turn that called a tool ends with the tool-call reason, not stop: the harness
1016
+ // runs the tool and feeds the result back. A stop reason here would read as a clean,
1017
+ // complete answer and the tool would never run.
1018
+ yield sawToolCall
1019
+ ? { type: 'finish', reason: { kind: 'tool-calls' } }
1020
+ : { type: 'finish', reason: { kind: 'stop' } };
597
1021
  }
598
1022
  finally {
599
1023
  reader.cancel().catch(() => { });
600
1024
  }
601
1025
  }
602
1026
  }
1027
+ /**
1028
+ * The reasoning block for a catalog entry, or undefined when it supports none.
1029
+ *
1030
+ * The ladder is whatever the entry ended up with: the listing's own rung where it stated
1031
+ * one, the seed table's where it did not.
1032
+ *
1033
+ * @param entry - the catalog row.
1034
+ * @returns the reasoning block to advertise, when the row has a ladder.
1035
+ */
1036
+ function reasoningInfo(entry) {
1037
+ const efforts = entry.reasoningEfforts;
1038
+ if (efforts === undefined || efforts.length === 0)
1039
+ return undefined;
1040
+ return {
1041
+ efforts: efforts.map((effort) => ({ id: ReasoningEffortId(effort), name: String(effort) })),
1042
+ ...entry.defaultEffort === undefined ? {} : { defaultEffort: ReasoningEffortId(entry.defaultEffort) },
1043
+ };
1044
+ }