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.
- 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/client/FastModeButton.d.ts +0 -1
- package/lib/client/FastModeButton.js +48 -11
- package/lib/client.js +32 -9
- package/lib/client.js.map +1 -1
- package/lib/index.js +21 -0
- package/lib/protocol.d.ts +9 -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 +16 -2
- package/lib/providers/ark.d.ts +104 -10
- package/lib/providers/ark.js +186 -48
- package/lib/providers/catalog.d.ts +92 -0
- package/lib/providers/catalog.js +191 -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 +16 -2
- package/lib/providers/glm.d.ts +100 -16
- package/lib/providers/glm.js +182 -85
- package/lib/providers/grok.d.ts +114 -27
- package/lib/providers/grok.js +330 -129
- package/lib/providers/kimi-provider.d.ts +10 -0
- package/lib/providers/kimi-provider.js +16 -4
- package/lib/providers/kimi.d.ts +122 -32
- package/lib/providers/kimi.js +243 -122
- 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 +501 -59
- 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,
|
|
@@ -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
|
|
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
|
|
493
|
+
return this.shippedCatalog();
|
|
241
494
|
}
|
|
242
495
|
/** The entries the route itself serves, without hand-added extras. */
|
|
243
496
|
ownCatalogEntries() {
|
|
244
|
-
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'));
|
|
245
513
|
}
|
|
246
514
|
async resolveModel(_provider, model) {
|
|
247
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
316
|
-
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(() => { });
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
553
|
-
const
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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 (
|
|
560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
+
}
|