dsh-plugin-subscription-oauth 0.16.2 → 0.17.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.
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/auth/workbuddy-flow.d.ts +1 -1
  6. package/lib/auth/workbuddy-flow.js +14 -9
  7. package/lib/client/index.js +10 -1
  8. package/lib/client.js +6 -1
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +21 -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 +6 -0
  15. package/lib/providers/ark.d.ts +96 -10
  16. package/lib/providers/ark.js +176 -48
  17. package/lib/providers/catalog.d.ts +91 -0
  18. package/lib/providers/catalog.js +190 -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 +6 -0
  23. package/lib/providers/glm.d.ts +92 -16
  24. package/lib/providers/glm.js +172 -85
  25. package/lib/providers/grok.d.ts +106 -27
  26. package/lib/providers/grok.js +312 -128
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +8 -3
  29. package/lib/providers/kimi.d.ts +104 -32
  30. package/lib/providers/kimi.js +198 -129
  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 +520 -55
  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,
@@ -80,6 +244,16 @@ function unflagMessages(messages) {
80
244
  }
81
245
  return messages;
82
246
  }
247
+ /** 32 hex digits, for the chat ids WorkBuddy's own client makes. */
248
+ function randomHex() {
249
+ const bytes = new Uint8Array(16);
250
+ crypto.getRandomValues(bytes);
251
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
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.
83
257
  /** A tool's name may hold letters, digits, `_`, `-`; another character is refused. */
84
258
  const NAME_OK = /^[A-Za-z0-9_-]+$/;
85
259
  /** A name with each character WorkBuddy refuses as `_`, kept apart from `taken`. */
@@ -199,6 +373,16 @@ function objectRoot(node, root = node, depth = 0) {
199
373
  export class WorkBuddyAdapter extends LlmAdapter {
200
374
  options;
201
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;
202
386
  constructor(options) {
203
387
  super();
204
388
  this.options = options;
@@ -207,11 +391,69 @@ export class WorkBuddyAdapter extends LlmAdapter {
207
391
  providerInfo(provider) {
208
392
  return { id: provider, name: WORKBUDDY_PROVIDER_NAME };
209
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
+ }
210
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.
211
452
  if (await this.options.tokens.session() === undefined)
212
453
  return [];
454
+ await this.hydrate();
213
455
  const hidden = await this.hidden();
214
- return WORKBUDDY_CATALOG.filter((entry) => !hidden.has(entry.id)).map((entry) => ({
456
+ return this.shippedCatalog().filter((entry) => !hidden.has(entry.id)).map((entry) => ({
215
457
  provider: WORKBUDDY_PROVIDER_ID,
216
458
  id: entry.id,
217
459
  name: entry.name,
@@ -229,17 +471,53 @@ export class WorkBuddyAdapter extends LlmAdapter {
229
471
  return new Set();
230
472
  }
231
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
+ }
232
491
  /** The catalog the Settings list reads. */
233
492
  catalogEntries() {
234
- return WORKBUDDY_CATALOG;
493
+ return this.shippedCatalog();
235
494
  }
236
495
  /** The entries the route itself serves, without hand-added extras. */
237
496
  ownCatalogEntries() {
238
- 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'));
239
513
  }
240
514
  async resolveModel(_provider, model) {
241
- const entry = findEntry(WORKBUDDY_CATALOG, model);
515
+ await this.hydrate();
516
+ const entry = findEntry(this.shippedCatalog(), model);
242
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.
243
521
  return {
244
522
  provider: WORKBUDDY_PROVIDER_ID,
245
523
  id: model,
@@ -248,6 +526,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
248
526
  inputModalities: ['text', 'image'],
249
527
  };
250
528
  }
529
+ const reasoning = reasoningInfo(entry);
251
530
  return {
252
531
  provider: WORKBUDDY_PROVIDER_ID,
253
532
  id: entry.id,
@@ -255,15 +534,114 @@ export class WorkBuddyAdapter extends LlmAdapter {
255
534
  ...entry.description === undefined ? {} : { description: entry.description },
256
535
  ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
257
536
  context: { contextWindow: entry.contextWindow },
258
- ...entry.reasoningEfforts === undefined || entry.reasoningEfforts.length === 0
259
- ? {}
260
- : { reasoning: { efforts: entry.reasoningEfforts.map((id) => ({ id: ReasoningEffortId(id), name: id })) } },
537
+ ...reasoning === undefined ? {} : { reasoning },
261
538
  };
262
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
+ }
263
640
  providerRetryPolicy() {
264
641
  return undefined;
265
642
  }
266
643
  async *stream(options) {
644
+ diag(`stream entered: model=${options.model} provider=${options.provider}`);
267
645
  const held = await this.options.tokens.session();
268
646
  if (held === undefined)
269
647
  throw new Error('WorkBuddy is not signed in');
@@ -271,37 +649,49 @@ export class WorkBuddyAdapter extends LlmAdapter {
271
649
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
272
650
  // Capture the tool-call history so unsafe tool names in past turns can be renamed to
273
651
  // the safe ones the model now sees, keeping the conversation consistent.
274
- 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');
275
653
  // The last usage frame carries the turn's real billed credit; record it so the spend
276
654
  // total is a sum of real figures, not an estimate.
277
- 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()}`);
278
657
  try {
279
- 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)}`);
280
660
  const response = await this.fetchFn(WORKBUDDY_API, {
281
661
  method: 'POST',
282
- headers: workbuddyHeaders(token),
662
+ headers: {
663
+ 'Content-Type': 'application/json',
664
+ 'Accept': 'text/event-stream',
665
+ ...workbuddyHeaders(token),
666
+ // Name the chat the way WorkBuddy's own client does: the message, the request,
667
+ // the turn, and the conversation. Without these the gateway counts the request
668
+ // under no client and can cut the stream.
669
+ 'X-Conversation-Message-ID': randomHex(),
670
+ 'X-Request-ID': randomHex(),
671
+ 'X-Conversation-Request-ID': randomHex(),
672
+ 'X-Conversation-ID': randomHex(),
673
+ },
283
674
  body: JSON.stringify(body),
284
675
  signal: watchdog.signal,
285
676
  });
677
+ diag(`response: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
286
678
  // WorkBuddy refuses a chat mid-stream when the request carries words it filters; a
287
679
  // 403/400 here names why, so surface it instead of dying on an empty stream.
288
680
  if (!response.ok || response.body === null) {
289
681
  throw await this.errorOf(response);
290
682
  }
291
- yield* this.readStream(response, watchdog);
683
+ yield* this.readStream(response, watchdog, names, turn);
292
684
  }
293
685
  catch (error) {
294
686
  throw mapFetchFailure(WORKBUDDY_PROVIDER_NAME, error, watchdog, options.signal);
295
687
  }
296
688
  finally {
297
689
  watchdog.stop();
298
- if (this.turnCredit > 0 && this.options.spendFile !== undefined) {
299
- 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(() => { });
300
692
  }
301
693
  }
302
694
  }
303
- /** The current turn's billed credit, read off the last usage frame. */
304
- turnCredit = 0;
305
695
  /** The stored session's fields as the token the request signs with. */
306
696
  tokenOf(session) {
307
697
  return {
@@ -315,7 +705,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
315
705
  ...session.refreshExpiresAt === undefined ? {} : { refreshExpiresAt: session.refreshExpiresAt },
316
706
  };
317
707
  }
318
- requestBody(options) {
708
+ requestBody(options, history) {
319
709
  const messages = [];
320
710
  for (const message of options.messages) {
321
711
  messages.push(this.toChatMessage(message));
@@ -325,13 +715,19 @@ export class WorkBuddyAdapter extends LlmAdapter {
325
715
  // Claude Code's own) out of every message — WorkBuddy reads them all, whatever role.
326
716
  const system = this.systemPrompt(options);
327
717
  messages.unshift({ role: 'system', content: system });
328
- const tools = this.fittedTools(options.tools);
718
+ const tools = this.fittedTools(options.tools, history);
329
719
  return {
330
- model: options.model,
331
- messages: unflagMessages(messages),
332
- ...tools === undefined ? {} : { tools: tools.tools },
333
- ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
334
- 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(),
335
731
  };
336
732
  }
337
733
  /**
@@ -339,13 +735,14 @@ export class WorkBuddyAdapter extends LlmAdapter {
339
735
  *
340
736
  * A tool name may hold only letters, digits, `_`, `-`; another character is refused, and
341
737
  * a schema with a union at the parameter root or a `\0` escape is turned away by the
342
- * model. Each unsafe name is made safe and the safe→original map kept on the adapter so
343
- * 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.
344
740
  *
345
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.
346
743
  * @returns the mended wire tools, or undefined when there are none.
347
744
  */
348
- fittedTools(tools) {
745
+ fittedTools(tools, history) {
349
746
  if (tools === undefined || tools.length === 0)
350
747
  return undefined;
351
748
  const wire = tools.map((tool) => ({
@@ -383,7 +780,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
383
780
  }
384
781
  // Rename the calls in the conversation history to the safe names, so a turn that
385
782
  // already called a tool matches the name the model now sees.
386
- for (const message of this.historyMessages ?? []) {
783
+ for (const message of history) {
387
784
  for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
388
785
  const fn = call?.function;
389
786
  if (fn && typeof fn.name === 'string' && theirs.has(fn.name))
@@ -393,20 +790,16 @@ export class WorkBuddyAdapter extends LlmAdapter {
393
790
  message.name = theirs.get(message.name);
394
791
  }
395
792
  }
396
- this.safeToolNames = names;
397
793
  return { tools: wire, names };
398
794
  }
399
- /** The safe→original tool names to restore in the answer. */
400
- safeToolNames = new Map();
401
- /** The assistant/tool history of the request being built, for renaming past calls. */
402
- historyMessages = [];
403
795
  /**
404
796
  * Restore the original tool names the model was given, in one parsed chunk.
405
797
  *
406
798
  * @param value - a decoded SSE payload.
799
+ * @param names - the safe→original map the request that produced this stream mended.
407
800
  * @returns whether any name changed.
408
801
  */
409
- namedBack(value) {
802
+ namedBack(value, names) {
410
803
  let changed = false;
411
804
  for (const choice of Array.isArray(value.choices) ? value.choices : []) {
412
805
  if (!isSchemaRecord(choice))
@@ -417,7 +810,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
417
810
  for (const call of Array.isArray(at.tool_calls) ? at.tool_calls : []) {
418
811
  if (!isSchemaRecord(call) || !isSchemaRecord(call.function))
419
812
  continue;
420
- const original = this.safeToolNames.get(String(call.function.name));
813
+ const original = names.get(String(call.function.name));
421
814
  if (original !== undefined) {
422
815
  call.function.name = original;
423
816
  changed = true;
@@ -475,17 +868,34 @@ export class WorkBuddyAdapter extends LlmAdapter {
475
868
  : response.status === 429 ? 'RATE_LIMIT'
476
869
  : response.status >= 500 ? 'SERVER' : 'HTTP_ERROR', { status: response.status });
477
870
  }
478
- async *readStream(response, watchdog) {
871
+ async *readStream(response, watchdog, names, turn) {
479
872
  const reader = response.body.getReader();
480
873
  const decoder = new TextDecoder();
481
874
  let buffer = '';
482
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') ?? ''}`);
483
890
  try {
484
891
  while (true) {
485
892
  const { done, value } = await reader.read();
486
893
  if (done)
487
894
  break;
488
895
  watchdog.pulse();
896
+ bytesSeen += value.byteLength;
897
+ if (firstBytes.length < 200)
898
+ firstBytes += decoder.decode(value.slice(0, 200 - firstBytes.length), { stream: false });
489
899
  buffer += decoder.decode(value, { stream: true });
490
900
  // WorkBuddy's SSE frames are blank-line separated, whatever the platform's line
491
901
  // ending (\n, \r\n, or \r alone): split on any blank line so a \r\n stream is not
@@ -498,15 +908,22 @@ export class WorkBuddyAdapter extends LlmAdapter {
498
908
  .filter((line) => line.startsWith('data:'))
499
909
  .map((line) => line.slice(5).trim())
500
910
  .join('\n');
911
+ framesSeen += 1;
912
+ if (framesSeen <= 6)
913
+ diag(`f${framesSeen} raw-data=${JSON.stringify(data.slice(0, 100))}`);
501
914
  if (data === '' || data === '[DONE]')
502
915
  continue;
503
916
  let chunk;
504
917
  try {
505
918
  chunk = JSON.parse(data);
506
919
  }
507
- catch {
920
+ catch (error) {
921
+ if (framesSeen <= 6)
922
+ diag(`f${framesSeen} PARSE FAIL: ${error instanceof Error ? error.message : String(error)}`);
508
923
  continue;
509
924
  }
925
+ if (framesSeen <= 6)
926
+ diag(`f${framesSeen} delta=${JSON.stringify(chunk.choices?.[0]?.delta ?? null).slice(0, 120)}`);
510
927
  // WorkBuddy names a refusal mid-stream (an error frame, or a body with a non-zero
511
928
  // code): surface it instead of reading the rest as silence, so the turn fails with
512
929
  // the reason rather than stopping after a few empty seconds.
@@ -523,24 +940,38 @@ export class WorkBuddyAdapter extends LlmAdapter {
523
940
  }
524
941
  // Restore the tool names the model called to the ones the client declared, before
525
942
  // the chunk is read as a stream event.
526
- this.namedBack(chunk);
943
+ this.namedBack(chunk, names);
527
944
  // A tool-call delta: emit it so the harness can run the tool. The id and name ride
528
945
  // the first delta of each call; later deltas carry argument fragments.
529
946
  const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
530
947
  if (Array.isArray(toolCalls)) {
948
+ if (framesSeen <= 8)
949
+ diag(`f${framesSeen} tool_calls=${JSON.stringify(toolCalls).slice(0, 160)}`);
531
950
  for (const call of toolCalls) {
532
951
  const fn = call?.function;
533
952
  if (fn === undefined || fn === null)
534
953
  continue;
535
- const index = call.index ?? 0;
536
- const id = ToolCallId(call.id ?? `wb-tool-${index}`);
537
- const name = fn.name;
538
- const args = fn.arguments;
539
- if (typeof name === 'string' && name !== '') {
540
- 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' };
541
967
  }
542
- if (typeof args === 'string' && args !== '') {
543
- yield { type: 'tool-call-delta', index, id, argumentsDelta: args };
968
+ if (name !== '' && open.name === '') {
969
+ open.name = name;
970
+ yield { type: 'tool-call-delta', index: open.index, id: ToolCallId(open.id), name, argumentsDelta: '' };
971
+ }
972
+ if (args !== '') {
973
+ open.args += args;
974
+ yield { type: 'tool-call-delta', index: open.index, id: ToolCallId(open.id), argumentsDelta: args };
544
975
  }
545
976
  }
546
977
  }
@@ -550,6 +981,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
550
981
  yield { type: 'block-start', index: 0, blockType: 'text' };
551
982
  started = true;
552
983
  }
984
+ text += delta;
553
985
  yield { type: 'text-delta', index: 0, text: delta };
554
986
  }
555
987
  const usage = chunk.usage;
@@ -559,7 +991,7 @@ export class WorkBuddyAdapter extends LlmAdapter {
559
991
  // The last usage frame carries the real billed credit; keep the latest, so a
560
992
  // mid-stream retry does not double-count.
561
993
  if (typeof usage.credit === 'number' && Number.isFinite(usage.credit)) {
562
- this.turnCredit = usage.credit;
994
+ turn.credit = usage.credit;
563
995
  }
564
996
  yield {
565
997
  type: 'usage',
@@ -568,12 +1000,45 @@ export class WorkBuddyAdapter extends LlmAdapter {
568
1000
  }
569
1001
  }
570
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.
571
1005
  if (started)
572
- yield { type: 'block-end', index: 0, block: { type: 'text', text: '' } };
573
- yield { type: 'finish', reason: { kind: 'stop' } };
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
+ };
1013
+ }
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' } };
574
1021
  }
575
1022
  finally {
576
1023
  reader.cancel().catch(() => { });
577
1024
  }
578
1025
  }
579
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
+ }