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.
- package/README.dev.md +1 -1
- package/README.md +7 -6
- package/README.zh.dev.md +1 -1
- package/README.zh.md +7 -6
- package/lib/auth/workbuddy-flow.d.ts +1 -1
- package/lib/auth/workbuddy-flow.js +14 -9
- package/lib/client/index.js +10 -1
- package/lib/client.js +6 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +21 -0
- package/lib/providers/antigravity.d.ts +34 -4
- package/lib/providers/antigravity.js +100 -28
- package/lib/providers/ark-provider.d.ts +10 -0
- package/lib/providers/ark-provider.js +6 -0
- package/lib/providers/ark.d.ts +96 -10
- package/lib/providers/ark.js +176 -48
- package/lib/providers/catalog.d.ts +91 -0
- package/lib/providers/catalog.js +190 -0
- package/lib/providers/common.d.ts +14 -0
- package/lib/providers/common.js +23 -0
- package/lib/providers/glm-provider.d.ts +10 -0
- package/lib/providers/glm-provider.js +6 -0
- package/lib/providers/glm.d.ts +92 -16
- package/lib/providers/glm.js +172 -85
- package/lib/providers/grok.d.ts +106 -27
- package/lib/providers/grok.js +312 -128
- package/lib/providers/kimi-provider.d.ts +10 -0
- package/lib/providers/kimi-provider.js +8 -3
- package/lib/providers/kimi.d.ts +104 -32
- package/lib/providers/kimi.js +198 -129
- package/lib/providers/mimo-provider.d.ts +14 -1
- package/lib/providers/mimo-provider.js +17 -6
- package/lib/providers/mimo.d.ts +164 -19
- package/lib/providers/mimo.js +318 -19
- package/lib/providers/model-cache.d.ts +57 -0
- package/lib/providers/model-cache.js +105 -0
- package/lib/providers/responses-stream.d.ts +8 -0
- package/lib/providers/responses-stream.js +21 -5
- package/lib/providers/workbuddy-provider.d.ts +17 -0
- package/lib/providers/workbuddy-provider.js +14 -6
- package/lib/providers/workbuddy.d.ts +140 -13
- package/lib/providers/workbuddy.js +520 -55
- 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
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
493
|
+
return this.shippedCatalog();
|
|
235
494
|
}
|
|
236
495
|
/** The entries the route itself serves, without hand-added extras. */
|
|
237
496
|
ownCatalogEntries() {
|
|
238
|
-
return
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
299
|
-
void recordSpend(this.options.spendFile, held.account,
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 (
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|