dsh-model 0.0.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.en.md +127 -0
  2. package/README.md +96 -43
  3. package/bin/dsh-model.js +8 -0
  4. package/dist/bridge/aggregate.js +91 -0
  5. package/dist/bridge/login.js +127 -0
  6. package/dist/bridge/runtime.js +136 -0
  7. package/dist/bridge/server.js +183 -0
  8. package/dist/bridge/workbuddy/_compat.js +52 -0
  9. package/dist/bridge/workbuddy/app-version.js +141 -0
  10. package/dist/bridge/workbuddy/auth.js +548 -0
  11. package/dist/bridge/workbuddy/catalog.js +154 -0
  12. package/dist/bridge/workbuddy/client-identity.js +234 -0
  13. package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
  14. package/dist/bridge/workbuddy/loopback.js +52 -0
  15. package/dist/bridge/workbuddy/probe.js +153 -0
  16. package/dist/bridge/workbuddy/status-paths.js +49 -0
  17. package/dist/bridge/workbuddy/upstream.js +1180 -0
  18. package/dist/bridge/workbuddy/variants.js +101 -0
  19. package/dist/cli.js +215 -0
  20. package/dist/commands/bridge.js +68 -0
  21. package/dist/commands/connect.js +67 -0
  22. package/dist/commands/engine.js +122 -0
  23. package/dist/commands/key.js +75 -0
  24. package/dist/commands/login.js +113 -0
  25. package/dist/commands/logs.js +19 -0
  26. package/dist/commands/opencode.js +83 -0
  27. package/dist/commands/remote.js +129 -0
  28. package/dist/commands/self.js +116 -0
  29. package/dist/commands/service.js +72 -0
  30. package/dist/commands/setup.js +219 -0
  31. package/dist/commands/sources.js +196 -0
  32. package/dist/commands/status.js +216 -0
  33. package/dist/commands/uninstall.js +97 -0
  34. package/dist/commands/workbuddy.js +117 -0
  35. package/dist/context.js +86 -0
  36. package/dist/daemon/client.js +27 -0
  37. package/dist/daemon/core.js +850 -0
  38. package/dist/daemon/service.js +33 -0
  39. package/dist/daemon/stats.js +126 -0
  40. package/dist/daemon/usage.js +213 -0
  41. package/dist/dsh/cli.js +90 -0
  42. package/dist/dsh/connect.js +257 -0
  43. package/dist/dsh/credentials.js +55 -0
  44. package/dist/dsh/locate.js +129 -0
  45. package/dist/dsh/patch.js +106 -0
  46. package/dist/dsh/pick.js +59 -0
  47. package/dist/engine/auth.js +40 -0
  48. package/dist/engine/client.js +104 -0
  49. package/dist/engine/compat.js +94 -0
  50. package/dist/engine/config.js +47 -0
  51. package/dist/engine/install.js +118 -0
  52. package/dist/engine/mgmt.js +99 -0
  53. package/dist/engine/sourcemap.js +48 -0
  54. package/dist/errors.js +14 -0
  55. package/dist/i18n.js +39 -0
  56. package/dist/integrations/dshplugin.js +89 -0
  57. package/dist/integrations/opencode.js +101 -0
  58. package/dist/integrations/workbuddy.js +114 -0
  59. package/dist/keys.js +54 -0
  60. package/dist/ops.js +178 -0
  61. package/dist/remote/public.js +109 -0
  62. package/dist/secrets.js +13 -0
  63. package/dist/service/index.js +48 -0
  64. package/dist/service/launchd.js +89 -0
  65. package/dist/service/systemd.js +83 -0
  66. package/dist/sources.js +36 -0
  67. package/dist/state.js +90 -0
  68. package/dist/upstreams.js +23 -0
  69. package/dist/util/diff.js +30 -0
  70. package/dist/util/exec.js +42 -0
  71. package/dist/util/fs.js +107 -0
  72. package/dist/util/output.js +50 -0
  73. package/dist/util/pkg.js +13 -0
  74. package/dist/util/port.js +16 -0
  75. package/dist/util/prompt.js +40 -0
  76. package/dist/util/proxy.js +73 -0
  77. package/dist/util/redact.js +7 -0
  78. package/engine-manifest.json +28 -0
  79. package/package.json +77 -7
  80. package/plugin/client.js +688 -0
  81. package/plugin/cordis.patch.yml +4 -0
  82. package/plugin/index.js +231 -0
  83. package/docs/DESIGN.md +0 -610
@@ -0,0 +1,1180 @@
1
+ // Ported from dsh-workbuddy-connect v0.7.1 (commit 390582e04da4e8e8bf746d71997440e4b716ec47),
2
+ // https://github.com/corrinehu/dsh-workbuddy-connect — MIT License, Copyright (c) 2026 Corrine Hu.
3
+ // See LICENSE.dsh-workbuddy-connect in this directory. Changes: imports adapted to dsh-model (./_compat.js).
4
+ /**
5
+ * WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
6
+ * token refresh, model catalog, and credit balance. The wire behavior is
7
+ * ported from Sliverkiss/workbuddy2api (MIT), whose Go implementation is
8
+ * battle-tested against the real endpoint.
9
+ *
10
+ * @module dsh-workbuddy-connect/upstream
11
+ */
12
+ import { appUserAgent, resolveAppVersion } from './app-version.js';
13
+ import { chatUserAgent, fallbackChatIdentity, resolveChatIdentity } from './client-identity.js';
14
+ import { PROBE_MAX_TOKENS, PROBE_PROMPT } from './probe.js';
15
+ const CN_CHAT_BASE = 'https://copilot.tencent.com';
16
+ const CN_BILLING_BASE = 'https://www.codebuddy.cn';
17
+ const GLOBAL_BASE = 'https://www.workbuddy.ai';
18
+ /**
19
+ * Display name for the single synthetic row the enterprise endpoint produces.
20
+ *
21
+ * The endpoint reports one cycle quota, not the personal endpoint's list of
22
+ * named packages, so the card's "by package" table has exactly one row.
23
+ */
24
+ const enterprisePackageName = 'enterprise';
25
+ /**
26
+ * Field names and value types of a response document, for diagnostics.
27
+ *
28
+ * Names and `typeof` only. This string ends up in the status route and then in
29
+ * the browser, and the response describes the account's own usage; the values
30
+ * themselves must never travel. Only the document and its `data` member are
31
+ * described, so the output stays small.
32
+ */
33
+ function describeShape(document) {
34
+ if (typeof document !== 'object' || document === null || Array.isArray(document)) {
35
+ return typeof document;
36
+ }
37
+ const record = document;
38
+ const at = (source) => {
39
+ const keys = Object.keys(source).slice(0, 24);
40
+ return keys.length === 0 ? '(empty)' : keys.map(key => `${key}:${typeof source[key]}`).join(', ');
41
+ };
42
+ const top = `top-level { ${at(record)} }`;
43
+ const data = record['data'];
44
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
45
+ return top;
46
+ return `${top}; data { ${at(data)} }`;
47
+ }
48
+ /** Shared CLI-form User-Agent for refresh and the CN catalog; chat and probe present the desktop identity (client-identity.ts). */
49
+ const CLIENT_UA = 'CLI/2.63.2 CodeBuddy/2.63.2';
50
+ const JSON_TIMEOUT_MS = 30_000;
51
+ const ERROR_BODY_LIMIT = 4096;
52
+ /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
53
+ const HARD_CREDIT_MARKERS = [
54
+ 'insufficient credit', 'no credit', 'credit exhausted', 'credits exhausted', 'out of credit',
55
+ 'quota exceeded', 'quota exhaust', 'payment required', 'credit not enough',
56
+ 'not enough credit',
57
+ '积分不足', '额度不足', '余额不足', '积分用完', '额度用尽', '没有积分',
58
+ ];
59
+ /** The concrete effort spellings WorkBuddy exposes on the wire. */
60
+ const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
61
+ /** Promotional badge keys the upstream tags carry, minus their color suffix. */
62
+ const BADGE_PREFIX = 'badge:';
63
+ /** Parse the upstream `reasoning` object into {@link WorkBuddyModelReasoning}. */
64
+ function resolveUpstreamReasoning(wrapped) {
65
+ const supports = wrapped['supportsReasoning'] === true;
66
+ const onlyReasoning = wrapped['onlyReasoning'] === true;
67
+ const rawReasoning = wrapped['reasoning'];
68
+ let supportedEfforts;
69
+ let defaultEffort;
70
+ let canDisableThinking = true;
71
+ if (typeof rawReasoning === 'object' && rawReasoning !== null && !Array.isArray(rawReasoning)) {
72
+ const reasoning = rawReasoning;
73
+ const rawEfforts = reasoning['supportedEfforts'];
74
+ if (Array.isArray(rawEfforts)) {
75
+ const efforts = rawEfforts.filter((value) => typeof value === 'string' && EFFORT_VALUES.includes(value));
76
+ if (efforts.length > 0)
77
+ supportedEfforts = efforts;
78
+ }
79
+ if (typeof reasoning['defaultEffort'] === 'string'
80
+ && EFFORT_VALUES.includes(reasoning['defaultEffort'])) {
81
+ defaultEffort = reasoning['defaultEffort'];
82
+ }
83
+ else if (typeof reasoning['effort'] === 'string'
84
+ && EFFORT_VALUES.includes(reasoning['effort'])) {
85
+ defaultEffort = reasoning['effort'];
86
+ }
87
+ // Only an explicit `canDisableThinking: true` offers "thinking off"; older
88
+ // rows omit the field and several of them reject `off` on the wire, so the
89
+ // conservative default is "cannot be disabled".
90
+ canDisableThinking = reasoning['canDisableThinking'] === true;
91
+ }
92
+ return {
93
+ reasoning: {
94
+ supports,
95
+ onlyReasoning,
96
+ ...supportedEfforts === undefined ? {} : { supportedEfforts },
97
+ ...defaultEffort === undefined ? {} : { defaultEffort },
98
+ canDisableThinking,
99
+ },
100
+ };
101
+ }
102
+ /**
103
+ * Reduce an upstream credits string to its language-neutral display form.
104
+ *
105
+ * The host LLM seam carries this text to the browser, and the host has no
106
+ * locale service — whatever string is produced here is shown verbatim in every
107
+ * UI language. The upstream is inconsistent in a way that matters: some catalog
108
+ * rows report a bare multiplier (`x0.79`) and others append a unit word
109
+ * (`x0.79 credits`), and the unit word would pin the display to English.
110
+ * Dropping a trailing `credits` (case-insensitive, singular or plural) yields
111
+ * the one spelling that reads identically in every language.
112
+ *
113
+ * @param credits - raw upstream credits string, e.g. `"x0.79 credits"`.
114
+ * @returns the bare multiplier, or undefined when nothing displayable remains.
115
+ */
116
+ export function normalizeCredits(credits) {
117
+ if (credits === undefined)
118
+ return undefined;
119
+ const trimmed = credits.trim();
120
+ if (trimmed === '')
121
+ return undefined;
122
+ // A string that is only the unit word (`credits`) carries no multiplier.
123
+ if (/^credits?$/iu.test(trimmed))
124
+ return undefined;
125
+ const bare = trimmed.replace(/\s+credits?$/iu, '').trim();
126
+ return bare === '' ? undefined : bare;
127
+ }
128
+ /**
129
+ * Parse the upstream `tags` / `credits` fields into billing metadata.
130
+ *
131
+ * @param wrapped - one catalog row.
132
+ * @param extraTags - badge tags recovered from another document, merged in
133
+ * after the row's own. The `/v3/config` product document carries the roster
134
+ * but no `badge:*` tags; those live only in the console catalog, so the CN
135
+ * refresh reads both and joins them here (see `fetchPromoBadges`).
136
+ */
137
+ function resolveUpstreamBilling(wrapped, extraTags) {
138
+ const rawCredits = wrapped['credits'];
139
+ const credits = typeof rawCredits === 'string' && rawCredits.trim() !== '' ? rawCredits.trim() : undefined;
140
+ const badges = [];
141
+ const rawTags = [...Array.isArray(wrapped['tags']) ? wrapped['tags'] : [], ...extraTags ?? []];
142
+ for (const tag of rawTags) {
143
+ if (typeof tag !== 'string')
144
+ continue;
145
+ const lowered = tag.toLowerCase();
146
+ if (!lowered.startsWith(BADGE_PREFIX))
147
+ continue;
148
+ const label = tag.slice(BADGE_PREFIX.length).split(':')[0] ?? tag.slice(BADGE_PREFIX.length);
149
+ if (label !== '' && !badges.includes(label))
150
+ badges.push(label);
151
+ }
152
+ // A `x0.00` multiplier means the model is currently free. Judge from the
153
+ // normalized multiplier: the raw string may carry a trailing unit word
154
+ // (`x0.00 credits`) that a bare-multiplier match would never see.
155
+ const multiplier = normalizeCredits(credits);
156
+ const free = multiplier !== undefined && /^x?0\.0+$/u.test(multiplier);
157
+ return {
158
+ billing: {
159
+ ...credits === undefined ? {} : { credits },
160
+ ...badges.length === 0 ? {} : { badges },
161
+ free,
162
+ },
163
+ };
164
+ }
165
+ /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
166
+ const SESSION_DEAD_MARKERS = ['Offline user session not found', '12153'];
167
+ /** Classify an upstream failure from its HTTP status and body excerpt. */
168
+ export function classifyUpstreamError(status, body) {
169
+ if (status === 402)
170
+ return 'hard_credit';
171
+ // A 401 is an auth failure whatever its body says: there is no business
172
+ // meaning for it on this upstream, and the session-dead markers below are
173
+ // only an additional signal (they also catch dead sessions answered as
174
+ // 403). Keeping every 401 in `session_dead` preserves the `(http 401)`
175
+ // status note the host needs to classify it as AUTH.
176
+ if (status === 401)
177
+ return 'session_dead';
178
+ const lower = body.toLowerCase();
179
+ for (const marker of HARD_CREDIT_MARKERS) {
180
+ if (lower.includes(marker.toLowerCase()) || body.includes(marker))
181
+ return 'hard_credit';
182
+ }
183
+ for (const marker of SESSION_DEAD_MARKERS) {
184
+ if (body.includes(marker))
185
+ return 'session_dead';
186
+ }
187
+ if (status === 429)
188
+ return 'soft_rate';
189
+ if (status === 404)
190
+ return 'not_found';
191
+ if (status >= 500)
192
+ return 'server';
193
+ if (status >= 400)
194
+ return 'client';
195
+ return 'client';
196
+ }
197
+ /**
198
+ * Extract a user-facing error message from an upstream JSON response body.
199
+ * Prefers WorkBuddy's localized `displayMsg.zh` / `displayMsg.en`, falling back to `msg`.
200
+ */
201
+ export function extractDisplayErrorMessage(body) {
202
+ const trimmed = body.trim();
203
+ if (!trimmed.startsWith('{'))
204
+ return undefined;
205
+ try {
206
+ const parsed = JSON.parse(trimmed);
207
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
208
+ return undefined;
209
+ const obj = parsed;
210
+ const displayMsg = obj['displayMsg'];
211
+ if (typeof displayMsg === 'object' && displayMsg !== null && !Array.isArray(displayMsg)) {
212
+ const localized = displayMsg;
213
+ if (typeof localized['zh'] === 'string' && localized['zh'].trim() !== '') {
214
+ return localized['zh'].trim();
215
+ }
216
+ if (typeof localized['en'] === 'string' && localized['en'].trim() !== '') {
217
+ return localized['en'].trim();
218
+ }
219
+ }
220
+ if (typeof obj['msg'] === 'string' && obj['msg'].trim() !== '') {
221
+ return obj['msg'].trim();
222
+ }
223
+ }
224
+ catch {
225
+ // Non-JSON bodies stay undefined
226
+ }
227
+ return undefined;
228
+ }
229
+ /** Region for a login domain; an empty domain means CN (matching upstream tooling). */
230
+ export function regionOf(domain) {
231
+ const lowered = domain.trim().toLowerCase();
232
+ if (lowered === 'workbuddy.ai' || lowered.endsWith('.workbuddy.ai'))
233
+ return 'global';
234
+ return 'cn';
235
+ }
236
+ function chatBase(credential) {
237
+ return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_CHAT_BASE;
238
+ }
239
+ function billingBase(credential) {
240
+ return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_BILLING_BASE;
241
+ }
242
+ function originReferer(credential) {
243
+ return regionOf(credential.domain) === 'global' ? GLOBAL_BASE : CN_BILLING_BASE;
244
+ }
245
+ /** Headers every upstream request shares. */
246
+ function commonHeaders(credential) {
247
+ return {
248
+ 'Accept': 'application/json, text/plain, */*',
249
+ 'X-Requested-With': 'XMLHttpRequest',
250
+ 'Origin': originReferer(credential),
251
+ 'Referer': `${originReferer(credential)}/`,
252
+ 'User-Agent': CLIENT_UA,
253
+ };
254
+ }
255
+ /**
256
+ * Chat request headers, including the X-No-* conventions the official CLI uses.
257
+ *
258
+ * `userAgent` and `clientVersion` carry the same resolved desktop identity for
259
+ * chat and probe requests. Refresh shares `commonHeaders` but never this
260
+ * override, so the two paths cannot drift into each other.
261
+ */
262
+ function chatHeaders(credential, userAgent, clientVersion) {
263
+ const headers = {
264
+ ...commonHeaders(credential),
265
+ 'User-Agent': userAgent,
266
+ 'Content-Type': 'application/json',
267
+ // 安全红线:chat 请求绝不携带 refresh token。
268
+ ...credential.uid === '' ? { 'X-No-User-Id': '1' } : { 'X-User-Id': credential.uid },
269
+ ...credential.enterpriseId === undefined || credential.enterpriseId === ''
270
+ ? { 'X-No-Enterprise-Id': '1' }
271
+ : { 'X-Enterprise-Id': credential.enterpriseId },
272
+ ...credential.domain === '' ? { 'X-No-Department-Info': '1' } : { 'X-Domain': credential.domain },
273
+ 'X-IDE-Type': 'WorkBuddy',
274
+ 'X-IDE-Name': 'WorkBuddy',
275
+ 'X-IDE-Version': clientVersion,
276
+ 'X-Product': 'SaaS',
277
+ };
278
+ return headers;
279
+ }
280
+ /** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
281
+ function refreshHeaders(credential) {
282
+ const headers = {
283
+ ...commonHeaders(credential),
284
+ 'X-Refresh-Token': credential.refreshToken,
285
+ 'X-Auth-Refresh-Source': 'workbuddy',
286
+ };
287
+ if (credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
288
+ headers['X-Enterprise-Id'] = credential.enterpriseId;
289
+ }
290
+ return headers;
291
+ }
292
+ /** Billing request headers. */
293
+ function billingHeaders(credential) {
294
+ const headers = {
295
+ 'Authorization': `Bearer ${credential.accessToken}`,
296
+ 'Accept': 'application/json',
297
+ 'Content-Type': 'application/json',
298
+ };
299
+ if (credential.uid !== '')
300
+ headers['X-User-Id'] = credential.uid;
301
+ if (credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
302
+ headers['X-Enterprise-Id'] = credential.enterpriseId;
303
+ headers['X-Tenant-Id'] = credential.enterpriseId;
304
+ }
305
+ if (credential.domain !== '')
306
+ headers['X-Domain'] = credential.domain;
307
+ return headers;
308
+ }
309
+ /**
310
+ * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
311
+ * force `stream: true` (the upstream rejects non-streaming), flatten
312
+ * `tool_choice` (the upstream's field is a string; object forms return 400),
313
+ * and rewrite `developer` messages as `system`.
314
+ *
315
+ * The `developer` rewrite is load-bearing: pi-ai emits the system prompt as
316
+ * `role: "developer"` (the OpenAI convention it adopted), but the WorkBuddy
317
+ * upstream rejects that role with HTTP 400 code 11128 ("Illegal API
318
+ * invocation from an unapproved channel"). Rewriting to `system` is the
319
+ * compatible spelling the upstream accepts.
320
+ */
321
+ export function prepareChatBody(source) {
322
+ let body;
323
+ try {
324
+ body = JSON.parse(source);
325
+ }
326
+ catch {
327
+ return source;
328
+ }
329
+ if (typeof body !== 'object' || body === null || Array.isArray(body))
330
+ return source;
331
+ const obj = body;
332
+ obj['stream'] = true;
333
+ normalizeDeveloperRole(obj);
334
+ normalizeToolChoice(obj);
335
+ return JSON.stringify(obj);
336
+ }
337
+ /** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
338
+ function normalizeDeveloperRole(obj) {
339
+ const messages = obj['messages'];
340
+ if (!Array.isArray(messages))
341
+ return;
342
+ for (const message of messages) {
343
+ if (typeof message !== 'object' || message === null || Array.isArray(message))
344
+ continue;
345
+ const wrapped = message;
346
+ if (wrapped['role'] === 'developer')
347
+ wrapped['role'] = 'system';
348
+ }
349
+ }
350
+ /** Rewrite OpenAI `tool_choice` spellings into the upstream's string form. */
351
+ function normalizeToolChoice(obj) {
352
+ const suppress = () => {
353
+ delete obj['tools'];
354
+ delete obj['functions'];
355
+ };
356
+ const present = 'tool_choice' in obj;
357
+ if (!present)
358
+ return;
359
+ const choice = obj['tool_choice'];
360
+ if (typeof choice === 'string') {
361
+ if (choice.trim().toLowerCase() === 'none') {
362
+ delete obj['tool_choice'];
363
+ suppress();
364
+ }
365
+ return;
366
+ }
367
+ if (typeof choice === 'object' && choice !== null && !Array.isArray(choice)) {
368
+ const wrapped = choice;
369
+ const type = typeof wrapped['type'] === 'string' ? wrapped['type'].trim().toLowerCase() : '';
370
+ if (type === 'none') {
371
+ delete obj['tool_choice'];
372
+ suppress();
373
+ }
374
+ else if (type === 'auto' || type === 'required') {
375
+ obj['tool_choice'] = type;
376
+ }
377
+ else if (type === 'function') {
378
+ const fn = typeof wrapped['function'] === 'object' && wrapped['function'] !== null
379
+ ? wrapped['function']
380
+ : undefined;
381
+ let name = typeof fn?.['name'] === 'string' ? fn['name'] : '';
382
+ if (name === '' && typeof wrapped['name'] === 'string')
383
+ name = wrapped['name'];
384
+ name = name.trim();
385
+ obj['tool_choice'] = name !== '' ? name : 'auto';
386
+ }
387
+ else {
388
+ delete obj['tool_choice'];
389
+ }
390
+ return;
391
+ }
392
+ delete obj['tool_choice'];
393
+ }
394
+ async function readEnvelope(response) {
395
+ const text = await response.text();
396
+ let parsed;
397
+ try {
398
+ parsed = JSON.parse(text);
399
+ }
400
+ catch {
401
+ throw new Error(`workbuddy upstream returned non-JSON (http ${response.status}): ${text.slice(0, 160)}`);
402
+ }
403
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
404
+ throw new Error(`workbuddy upstream returned an unexpected document (http ${response.status})`);
405
+ }
406
+ const document = parsed;
407
+ const envelope = {
408
+ code: typeof document['code'] === 'number' ? document['code'] : 0,
409
+ msg: typeof document['msg'] === 'string' ? document['msg'] : '',
410
+ data: 'data' in document ? document['data'] : undefined,
411
+ document,
412
+ };
413
+ return envelope;
414
+ }
415
+ /** Fail an envelope whose business code is non-zero, classified like HTTP errors. */
416
+ function envelopeError(status, envelope) {
417
+ const kind = classifyUpstreamError(status, envelope.msg);
418
+ return new Error(`workbuddy upstream ${kind} (http ${status}): ${envelope.msg.slice(0, 160)}`);
419
+ }
420
+ /**
421
+ * Upstream HTTP client. One instance serves the whole plugin; requests take
422
+ * the credential explicitly so token refreshes apply on the next call.
423
+ *
424
+ * One instance is *per variant*: the international provider needs its own
425
+ * catalog source, UA version, and probe differences, and keeping them on the
426
+ * instance avoids passing a variant through every call signature.
427
+ */
428
+ export class WorkBuddyUpstreamClient {
429
+ /**
430
+ * Resolves the App-shaped UA version for international catalog requests.
431
+ * Injectable so tests never read the real filesystem.
432
+ */
433
+ resolveAppVersion;
434
+ /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
435
+ resolveChatIdentity;
436
+ /** Provenance of the most recent successful catalog fetch, for the card. */
437
+ lastCatalog;
438
+ constructor(options = {}) {
439
+ this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
440
+ this.resolveChatIdentity = options.resolveChatIdentity ?? (region => resolveChatIdentity(region));
441
+ }
442
+ /** POST the chat endpoint; a successful answer is the raw SSE response. */
443
+ async chatStream(credential, bodyJson, signal) {
444
+ const region = regionOf(credential.domain);
445
+ // Identity resolution must never block a message: any failure — a thrown
446
+ // resolver included — degrades to the desktop fallback form (built-in
447
+ // version, no CLI segment), never to the legacy CLI UA.
448
+ let identity;
449
+ let userAgent;
450
+ try {
451
+ identity = await this.resolveChatIdentity(region);
452
+ userAgent = chatUserAgent(identity, region);
453
+ }
454
+ catch {
455
+ identity = fallbackChatIdentity(region);
456
+ userAgent = chatUserAgent(identity, region);
457
+ }
458
+ let response;
459
+ try {
460
+ response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
461
+ method: 'POST',
462
+ headers: { ...chatHeaders(credential, userAgent, identity.clientVersion), 'Authorization': `Bearer ${credential.accessToken}` },
463
+ body: region === 'global' ? prepareInternationalChatBody(bodyJson) : bodyJson,
464
+ ...signal === undefined ? {} : { signal },
465
+ });
466
+ }
467
+ catch (error) {
468
+ return { ok: false, status: 0, kind: 'server', message: `transport error: ${String(error)}` };
469
+ }
470
+ if (response.ok)
471
+ return { ok: true, response };
472
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
473
+ return {
474
+ ok: false,
475
+ status: response.status,
476
+ kind: classifyUpstreamError(response.status, text),
477
+ message: text,
478
+ };
479
+ }
480
+ /** POST the token-refresh endpoint; the caller merges the outcome. */
481
+ async refreshToken(credential) {
482
+ const response = await fetch(`${chatBase(credential)}/v2/plugin/auth/token/refresh`, {
483
+ method: 'POST',
484
+ headers: refreshHeaders(credential),
485
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
486
+ });
487
+ const envelope = await readEnvelope(response);
488
+ if (!response.ok || envelope.code !== 0)
489
+ throw envelopeError(response.status, envelope);
490
+ const data = typeof envelope.data === 'object' && envelope.data !== null
491
+ ? envelope.data
492
+ : {};
493
+ const accessToken = typeof data['accessToken'] === 'string' ? data['accessToken'] : '';
494
+ if (accessToken === '')
495
+ throw new Error('workbuddy token refresh returned no accessToken; sign in again in the WorkBuddy app');
496
+ const outcome = { accessToken };
497
+ if (typeof data['refreshToken'] === 'string' && data['refreshToken'] !== '')
498
+ outcome.refreshToken = data['refreshToken'];
499
+ if (typeof data['expiresIn'] === 'number' && data['expiresIn'] > 0)
500
+ outcome.expiresInSec = data['expiresIn'];
501
+ if (typeof data['domain'] === 'string' && data['domain'] !== '')
502
+ outcome.domain = data['domain'];
503
+ return outcome;
504
+ }
505
+ /**
506
+ * GET the personal model catalog.
507
+ *
508
+ * Both variants read `/v3/config`, the product document the desktop product
509
+ * itself fetches. CN used to read `/console/enterprises/personal/models`
510
+ * (the console catalog) instead, and that was why its model list drifted
511
+ * from the desktop App's selector: the console document lags the product
512
+ * one, and the product roster itself churns day to day (`auto`,
513
+ * `kimi-k3-1`, `minimax-m3` have each appeared and disappeared within a
514
+ * week).
515
+ *
516
+ * What distinguishes the two variants here is the User-Agent, not the path:
517
+ * the gateway splits `/v3/config` by client identity, and the split is
518
+ * load-bearing. A CLI-shaped UA yields the CLI's roster — the chat models
519
+ * this plugin serves — while an App-shaped UA yields the App's internal
520
+ * roster. CN keeps the CLI UA it sends for chat, so the catalog it
521
+ * advertises is exactly the one its own requests can use. The international
522
+ * variant has no CLI identity, so it keeps the App-shaped UA.
523
+ *
524
+ * Membership is the `cli` roster intersected with the usable rows (see
525
+ * {@link parseModelCatalog}); the promo badges the product document does
526
+ * not carry are merged in from a best-effort console read — see
527
+ * {@link fetchPromoBadges}.
528
+ *
529
+ * Responses are unwrapped and classified the same way — `readEnvelope` plus
530
+ * `envelopeError` — so an expired session or exhausted credit is reported as
531
+ * such rather than as a generic catalog failure.
532
+ */
533
+ async fetchModels(credential, signal) {
534
+ const international = regionOf(credential.domain) === 'global';
535
+ // `this.resolveAppVersion`, not the module-level function: the constructor
536
+ // injects a resolver so tests never read the real filesystem, and calling
537
+ // the module function directly made that seam inert.
538
+ const appVersion = international ? await this.resolveAppVersion() : undefined;
539
+ const response = await fetch(`${chatBase(credential)}/v3/config`, {
540
+ headers: {
541
+ Authorization: `Bearer ${credential.accessToken}`,
542
+ Accept: 'application/json',
543
+ Origin: originReferer(credential),
544
+ Referer: `${originReferer(credential)}/`,
545
+ ...international ? { 'X-Requested-With': 'XMLHttpRequest', 'X-Product': 'SaaS' } : {},
546
+ 'User-Agent': appVersion === undefined ? CLIENT_UA : appUserAgent(appVersion.version),
547
+ },
548
+ signal: signal === undefined
549
+ ? AbortSignal.timeout(JSON_TIMEOUT_MS)
550
+ : AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)]),
551
+ });
552
+ const envelope = await readEnvelope(response);
553
+ if (!response.ok || envelope.code !== 0)
554
+ throw envelopeError(response.status, envelope);
555
+ // Two catalog shapes share this path. The endpoints wrap their answer in
556
+ // `{code,msg,data}`, but `/v3/config` has also been observed answering with
557
+ // the product document bare at the top level (no wrapper at all). Treating
558
+ // a missing `data` as an empty document turned that second shape into a
559
+ // spurious "no cli agent models", so a body that itself looks like a
560
+ // catalog (it carries models or agents) is used as the answer. A body with
561
+ // neither shape still falls through to the empty-parse error below.
562
+ const data = isObject(envelope.data) ? envelope.data
563
+ : 'models' in envelope.document || 'agents' in envelope.document ? envelope.document
564
+ : {};
565
+ // Promo badges exist only in the console document, so CN merges them in.
566
+ // The read is best-effort and separate: its failure costs the badges and
567
+ // never the catalog.
568
+ const promoBadges = international ? undefined : await this.fetchPromoBadges(credential, signal);
569
+ const models = parseModelCatalog(data, international, promoBadges);
570
+ this.lastCatalog = {
571
+ fetchedAtMs: Date.now(),
572
+ source: international ? 'workbuddy-ai:app' : 'workbuddy:cli',
573
+ ...appVersion === undefined ? {} : { appVersion },
574
+ };
575
+ return models;
576
+ }
577
+ /**
578
+ * Read the console catalog's promotional tags, by model id.
579
+ *
580
+ * `/v3/config` carries no `badge:<label>:<color>` tags — the discount labels
581
+ * the cards render (`限时免费`, `夜间折扣`, …) live only in
582
+ * `/console/enterprises/personal/models`. Since the roster now comes from
583
+ * the product document, those tags are read from the console one in a
584
+ * second request and merged by id.
585
+ *
586
+ * Best-effort by construction: a badge is a label on a price, so failing to
587
+ * read this document must not fail a catalog refresh. Every failure —
588
+ * network, envelope, an unreadable body — returns undefined, and the models
589
+ * simply ship without badges.
590
+ */
591
+ async fetchPromoBadges(credential, signal) {
592
+ try {
593
+ const response = await fetch(`${chatBase(credential)}/console/enterprises/personal/models`, {
594
+ headers: {
595
+ Authorization: `Bearer ${credential.accessToken}`,
596
+ Accept: 'application/json',
597
+ Origin: originReferer(credential),
598
+ Referer: `${originReferer(credential)}/`,
599
+ 'User-Agent': CLIENT_UA,
600
+ },
601
+ signal: signal === undefined
602
+ ? AbortSignal.timeout(JSON_TIMEOUT_MS)
603
+ : AbortSignal.any([signal, AbortSignal.timeout(JSON_TIMEOUT_MS)]),
604
+ });
605
+ if (!response.ok)
606
+ return undefined;
607
+ const envelope = await readEnvelope(response);
608
+ const data = isObject(envelope.data) ? envelope.data : envelope.document;
609
+ const rawModels = Array.isArray(data['models']) ? data['models'] : [];
610
+ const badges = new Map();
611
+ for (const model of rawModels) {
612
+ if (!isObject(model))
613
+ continue;
614
+ const id = typeof model['id'] === 'string' ? model['id'] : '';
615
+ if (id === '')
616
+ continue;
617
+ const tags = Array.isArray(model['tags'])
618
+ ? model['tags'].filter((tag) => typeof tag === 'string' && tag.toLowerCase().startsWith(BADGE_PREFIX))
619
+ : [];
620
+ if (tags.length > 0)
621
+ badges.set(id, tags);
622
+ }
623
+ return badges.size === 0 ? undefined : badges;
624
+ }
625
+ catch {
626
+ return undefined;
627
+ }
628
+ }
629
+ /**
630
+ * POST the billing endpoint for the aggregated remaining credit.
631
+ *
632
+ * Two upstream shapes, chosen by account type:
633
+ *
634
+ * - **CN enterprise** (`regionOf === 'cn'` and `enterpriseId` non-empty) asks
635
+ * `/v2/billing/meter/get-enterprise-user-usage`, which answers with a single
636
+ * cycle quota. The personal endpoint serves these accounts an empty
637
+ * `Accounts` list, which the card then renders as "0 credit" — a wrong
638
+ * number rather than a visible failure (issue #31).
639
+ * - **Everyone else** keeps the personal endpoint unchanged.
640
+ *
641
+ * The region gate is load-bearing: the enterprise endpoint is unverified for
642
+ * the global region, so an international credential that happens to carry an
643
+ * `enterpriseId` must stay on the measured personal path instead of being
644
+ * moved onto an unmeasured one.
645
+ */
646
+ async fetchCredits(credential) {
647
+ if (regionOf(credential.domain) === 'cn'
648
+ && credential.enterpriseId !== undefined && credential.enterpriseId !== '') {
649
+ return await this.fetchEnterpriseCredits(credential);
650
+ }
651
+ const now = new Date();
652
+ const format = (date) => [
653
+ date.getFullYear().toString().padStart(4, '0'),
654
+ (date.getMonth() + 1).toString().padStart(2, '0'),
655
+ date.getDate().toString().padStart(2, '0'),
656
+ ].join('-') + ' ' + [
657
+ date.getHours().toString().padStart(2, '0'),
658
+ date.getMinutes().toString().padStart(2, '0'),
659
+ date.getSeconds().toString().padStart(2, '0'),
660
+ ].join(':');
661
+ const response = await fetch(`${billingBase(credential)}/v2/billing/meter/get-user-resource`, {
662
+ method: 'POST',
663
+ headers: billingHeaders(credential),
664
+ body: JSON.stringify({
665
+ PageNumber: 1,
666
+ PageSize: 100,
667
+ ProductCode: 'p_tcaca',
668
+ Status: [0, 3],
669
+ PackageEndTimeRangeBegin: format(now),
670
+ PackageEndTimeRangeEnd: format(new Date(now.getTime() + 365 * 101 * 24 * 3600 * 1000)),
671
+ }),
672
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
673
+ });
674
+ const envelope = await readEnvelope(response);
675
+ if (!response.ok || envelope.code !== 0)
676
+ throw envelopeError(response.status, envelope);
677
+ const responseWrapper = typeof envelope.data === 'object' && envelope.data !== null
678
+ ? envelope.data
679
+ : {};
680
+ const data = typeof responseWrapper['Response'] === 'object' && responseWrapper['Response'] !== null
681
+ ? responseWrapper['Response']
682
+ : {};
683
+ const inner = typeof data['Data'] === 'object' && data['Data'] !== null
684
+ ? data['Data']
685
+ : {};
686
+ const rawAccounts = Array.isArray(inner['Accounts']) ? inner['Accounts'] : [];
687
+ const accounts = [];
688
+ let total = 0;
689
+ for (const raw of rawAccounts) {
690
+ if (typeof raw !== 'object' || raw === null)
691
+ continue;
692
+ const account = raw;
693
+ const numberField = (key) => (typeof account[key] === 'number' ? account[key] : 0);
694
+ const size = numberField('CycleCapacitySize');
695
+ const cycleRemain = numberField('CycleCapacityRemain');
696
+ const cycleUsed = numberField('CycleCapacityUsed');
697
+ const capacityRemain = numberField('CapacityRemain');
698
+ let remain;
699
+ if (size > 0)
700
+ remain = cycleRemain;
701
+ else if (cycleRemain > 0 || cycleUsed > 0)
702
+ remain = cycleRemain;
703
+ else
704
+ remain = capacityRemain;
705
+ if (remain < 0)
706
+ remain = 0;
707
+ total += remain;
708
+ accounts.push({
709
+ packageName: typeof account['PackageName'] === 'string' ? account['PackageName'] : '(unnamed)',
710
+ remain,
711
+ size: size > 0 ? size : numberField('CapacitySize'),
712
+ });
713
+ }
714
+ return { total, accounts };
715
+ }
716
+ /**
717
+ * CN enterprise credit read: a single cycle quota instead of a package list.
718
+ *
719
+ * Verified against the WorkBuddy desktop app (`app.asar`,
720
+ * `BackendProvider.getEnterpriseUsage` and `CloudAccountRepo.billing`): the
721
+ * body is an empty object and the account identity travels only in the
722
+ * headers. The two official call sites disagree on the field spelling
723
+ * (`limitNum`/`credit` vs `limit_num`/`used_num`), so both are accepted.
724
+ *
725
+ * A body carrying no recognisable quota field is a hard error rather than a
726
+ * zero. Rendering `0` for "we did not understand the answer" is exactly how
727
+ * issue #31 stayed invisible while users saw a plausible wrong number.
728
+ *
729
+ * The error names fields and types only: it reaches the browser, and the
730
+ * response body may describe the account's usage.
731
+ */
732
+ async fetchEnterpriseCredits(credential) {
733
+ const response = await fetch(`${CN_BILLING_BASE}/v2/billing/meter/get-enterprise-user-usage`, {
734
+ method: 'POST',
735
+ headers: billingHeaders(credential),
736
+ body: JSON.stringify({}),
737
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS),
738
+ });
739
+ const envelope = await readEnvelope(response);
740
+ if (!response.ok || envelope.code !== 0)
741
+ throw envelopeError(response.status, envelope);
742
+ // The official reader accepts the payload at `data.data`, `data`, or the
743
+ // envelope itself; the observed CN answer puts the fields at `data`.
744
+ const sources = [];
745
+ for (const candidate of [envelope.data, envelope.document]) {
746
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate))
747
+ continue;
748
+ const record = candidate;
749
+ if (typeof record['data'] === 'object' && record['data'] !== null && !Array.isArray(record['data'])) {
750
+ sources.push(record['data']);
751
+ }
752
+ sources.push(record);
753
+ }
754
+ const numberAt = (source, key) => typeof source[key] === 'number' ? source[key] : undefined;
755
+ let limit;
756
+ let used;
757
+ let resetTime;
758
+ for (const source of sources) {
759
+ const candidate = numberAt(source, 'limitNum') ?? numberAt(source, 'limit_num');
760
+ if (candidate === undefined)
761
+ continue;
762
+ limit = candidate;
763
+ used = numberAt(source, 'credit') ?? numberAt(source, 'used_num');
764
+ if (typeof source['cycleResetTime'] === 'string' && source['cycleResetTime'] !== '') {
765
+ resetTime = source['cycleResetTime'];
766
+ }
767
+ break;
768
+ }
769
+ if (limit === undefined) {
770
+ throw new Error(`workbuddy enterprise billing response carried no recognised quota field (expected limitNum/limit_num + credit/used_num; received ${describeShape(envelope.document)})`);
771
+ }
772
+ // `-1` is the upstream's "no cap" marker, not a balance. Carried as an
773
+ // explicit flag so no renderer can mistake it for a number. The used amount
774
+ // is not part of an uncapped reading.
775
+ if (limit === -1) {
776
+ return {
777
+ total: 0,
778
+ accounts: [{ packageName: enterprisePackageName, remain: 0, size: 0, unlimited: true }],
779
+ unlimited: true,
780
+ ...resetTime === undefined ? {} : { cycleResetTime: resetTime },
781
+ };
782
+ }
783
+ // A limit with no usable amount must fail rather than assume zero used.
784
+ // Defaulting to 0 would render a confident "full quota remaining" from a
785
+ // response we could not read — the same species of wrong-but-plausible
786
+ // number as the bug this branch exists to fix.
787
+ if (used === undefined) {
788
+ throw new Error(`workbuddy enterprise billing response carried a quota limit but no recognised usage field (expected credit/used_num alongside limitNum/limit_num; received ${describeShape(envelope.document)})`);
789
+ }
790
+ let remain = limit - used;
791
+ if (remain < 0)
792
+ remain = 0;
793
+ return {
794
+ total: remain,
795
+ accounts: [{ packageName: enterprisePackageName, remain, size: limit }],
796
+ ...resetTime === undefined ? {} : { cycleResetTime: resetTime },
797
+ };
798
+ }
799
+ /**
800
+ * One probe request: a real streaming chat call carrying the effort under
801
+ * test.
802
+ *
803
+ * Shares `chatHeaders` with the normal chat path on purpose — the plan
804
+ * forbids probing through anything but the plugin's own credential handling,
805
+ * so a result describes what a real message would experience.
806
+ *
807
+ * The caller aborts as soon as a parseable event arrives; the body is never
808
+ * assembled into an answer. `reasoning_effort` is omitted entirely (rather
809
+ * than sent empty) when `effort` is undefined, so the baseline case is a
810
+ * genuinely bare request.
811
+ *
812
+ * Two international differences, both measured on 2026-09-11:
813
+ *
814
+ * - The gateway requires a leading `system` message (400/11128 otherwise), so
815
+ * one is prepended for the global region only.
816
+ * - `max_tokens: 1` is below some models' floor (the GPT-5.6 family rejects it
817
+ * with 400/11133 `integer_below_min_value`), so the international probe asks
818
+ * for a slightly larger minimum. This is a floor the plugin must clear, not
819
+ * evidence about any model's effort support: a model still refusing that
820
+ * minimum is reported as an incompatible request, never as "effort
821
+ * unsupported", and the ceiling is never raised further to force an answer.
822
+ */
823
+ async probeEffort(credential, model, effort, signal) {
824
+ const international = regionOf(credential.domain) === 'global';
825
+ // Same identity rule as the chat path — chat and its probe sibling must
826
+ // never present two different clients, and a thrown resolver degrades to
827
+ // the desktop fallback form exactly as in `chatStream`.
828
+ let identity;
829
+ let userAgent;
830
+ try {
831
+ identity = await this.resolveChatIdentity(international ? 'global' : 'cn');
832
+ userAgent = chatUserAgent(identity, international ? 'global' : 'cn');
833
+ }
834
+ catch {
835
+ identity = fallbackChatIdentity(international ? 'global' : 'cn');
836
+ userAgent = chatUserAgent(identity, international ? 'global' : 'cn');
837
+ }
838
+ const payload = {
839
+ model,
840
+ stream: true,
841
+ messages: [
842
+ ...international ? [{ role: 'system', content: INTERNATIONAL_SYSTEM_PROMPT }] : [],
843
+ { role: 'user', content: PROBE_PROMPT },
844
+ ],
845
+ max_tokens: international ? INTERNATIONAL_PROBE_MAX_TOKENS : PROBE_MAX_TOKENS,
846
+ };
847
+ if (effort !== undefined)
848
+ payload['reasoning_effort'] = effort;
849
+ let response;
850
+ try {
851
+ response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
852
+ method: 'POST',
853
+ headers: { ...chatHeaders(credential, userAgent, identity.clientVersion), 'Authorization': `Bearer ${credential.accessToken}` },
854
+ body: JSON.stringify(payload),
855
+ signal,
856
+ });
857
+ }
858
+ catch (error) {
859
+ return { status: 0, streamed: false, detail: `transport error: ${String(error)}` };
860
+ }
861
+ if (!response.ok) {
862
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
863
+ return { status: response.status, streamed: false, ...errorCodeOf(text) };
864
+ }
865
+ // Read until the first parseable event, then hang up: the probe wants the
866
+ // acceptance signal, not a completion.
867
+ const streamed = await readFirstEvent(response);
868
+ return { status: response.status, streamed };
869
+ }
870
+ }
871
+ /** Pull `extError.code` out of an upstream error body, if it is shaped that way. */
872
+ function errorCodeOf(text) {
873
+ try {
874
+ const parsed = JSON.parse(text);
875
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
876
+ const wrapped = parsed;
877
+ const extError = wrapped['extError'];
878
+ if (typeof extError === 'object' && extError !== null && !Array.isArray(extError)) {
879
+ const code = extError['code'];
880
+ if (typeof code === 'string')
881
+ return { errorCode: code, detail: code };
882
+ }
883
+ }
884
+ }
885
+ catch {
886
+ // Not JSON: fall through to a plain detail line.
887
+ }
888
+ return { detail: text.slice(0, 200) };
889
+ }
890
+ /**
891
+ * Consume just enough of a streaming response to know it really streams.
892
+ *
893
+ * Returns true on the first chunk containing a data line. Cancels the body
894
+ * afterwards; a stream that ends or errors before that counts as not streamed,
895
+ * because an empty 200 is not evidence the effort was accepted.
896
+ */
897
+ async function readFirstEvent(response) {
898
+ const body = response.body;
899
+ if (body === null)
900
+ return false;
901
+ const reader = body.getReader();
902
+ const decoder = new TextDecoder();
903
+ try {
904
+ for (;;) {
905
+ const { done, value } = await reader.read();
906
+ if (done)
907
+ return false;
908
+ const text = decoder.decode(value, { stream: true });
909
+ if (text.includes('data:'))
910
+ return true;
911
+ }
912
+ }
913
+ catch {
914
+ return false;
915
+ }
916
+ finally {
917
+ await reader.cancel().catch(() => { });
918
+ }
919
+ }
920
+ function isObject(value) {
921
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
922
+ }
923
+ function positive(value) {
924
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
925
+ }
926
+ /**
927
+ * Parse either response shape after its envelope has been checked.
928
+ *
929
+ * Membership is the `cli` agent's roster, intersected with the rows that are
930
+ * usable: the roster is what the client identity this plugin presents is
931
+ * allowed to chat with, and joining rather than trusting it outright drops
932
+ * both ids the roster has retired and rows the document lists but cannot
933
+ * serve (no row, `disabled: true`, or non-positive caps all drop out here —
934
+ * a published-but-unservable id must never reach the picker).
935
+ *
936
+ * @param data - the unwrapped catalog/product document.
937
+ * @param international - whether it is the international product document, whose
938
+ * rows carry window objects and promotions.
939
+ * @param promoBadges - badge tags by model id, read from the console document,
940
+ * which is the only one that carries them.
941
+ */
942
+ export function parseModelCatalog(data, international = false, promoBadges) {
943
+ const rawModels = Array.isArray(data['models']) ? data['models'] : [];
944
+ const agents = Array.isArray(data['agents']) ? data['agents'] : [];
945
+ let cliIds;
946
+ for (const agent of agents) {
947
+ if (typeof agent === 'object' && agent !== null) {
948
+ const wrapped = agent;
949
+ if (wrapped['name'] === 'cli' && Array.isArray(wrapped['models'])) {
950
+ cliIds = wrapped['models'].filter((id) => typeof id === 'string');
951
+ break;
952
+ }
953
+ }
954
+ }
955
+ if (cliIds === undefined || cliIds.length === 0) {
956
+ throw new Error('workbuddy model catalog lists no cli agent models');
957
+ }
958
+ const byId = new Map();
959
+ for (const model of rawModels) {
960
+ if (typeof model !== 'object' || model === null)
961
+ continue;
962
+ const wrapped = model;
963
+ const id = typeof wrapped['id'] === 'string' ? wrapped['id'] : '';
964
+ if (id === '' || wrapped['disabled'] === true)
965
+ continue;
966
+ const input = typeof wrapped['maxInputTokens'] === 'number' ? wrapped['maxInputTokens'] : 0;
967
+ const output = typeof wrapped['maxOutputTokens'] === 'number' ? wrapped['maxOutputTokens'] : 0;
968
+ if (input <= 0 || output <= 0)
969
+ continue;
970
+ byId.set(id, {
971
+ id,
972
+ name: typeof wrapped['name'] === 'string' && wrapped['name'] !== '' ? wrapped['name'] : id,
973
+ contextWindow: international && isObject(wrapped['contextWindow']) && positive(wrapped['contextWindow']['defaultLength'])
974
+ ? wrapped['contextWindow']['defaultLength'] : input,
975
+ ...(international ? {
976
+ ...isObject(wrapped['contextWindow']) && positive(wrapped['contextWindow']['defaultLength'])
977
+ ? { defaultContextWindow: wrapped['contextWindow']['defaultLength'] } : {},
978
+ maxInputTokens: input,
979
+ supportedContextWindows: isObject(wrapped['contextWindow']) && Array.isArray(wrapped['contextWindow']['supportedLengths'])
980
+ ? wrapped['contextWindow']['supportedLengths'].filter(positive) : [],
981
+ promotions: parsePromotions(data['modelPromotions'], id),
982
+ } : {}),
983
+ maxTokens: output,
984
+ supportsImages: wrapped['supportsImages'] === true && wrapped['disabledMultimodal'] !== true,
985
+ ...resolveUpstreamReasoning(wrapped),
986
+ ...resolveUpstreamBilling(wrapped, promoBadges?.get(id)),
987
+ });
988
+ }
989
+ const models = cliIds
990
+ .map(id => byId.get(id))
991
+ .filter((model) => model !== undefined);
992
+ if (models.length === 0)
993
+ throw new Error('workbuddy model catalog resolved to an empty list');
994
+ return models;
995
+ }
996
+ /** Extract the promotions covering `model` from the `modelPromotions` array. */
997
+ function parsePromotions(value, model) {
998
+ if (!Array.isArray(value))
999
+ return [];
1000
+ return value.flatMap(item => {
1001
+ if (!isObject(item) || item['enabled'] !== true)
1002
+ return [];
1003
+ const modelIds = item['modelIds'];
1004
+ if (!Array.isArray(modelIds) || !modelIds.includes(model))
1005
+ return [];
1006
+ const schedule = item['schedule'];
1007
+ const discount = item['discount'];
1008
+ const badge = item['badge'];
1009
+ if (!isObject(schedule) || !isObject(discount) || !isObject(badge))
1010
+ return [];
1011
+ // Only a replacement discount has an unambiguous display rule; any other
1012
+ // display mode is left to the upstream's own client.
1013
+ if (discount['displayMode'] !== 'replace')
1014
+ return [];
1015
+ const start = typeof schedule['validFrom'] === 'string' ? Date.parse(schedule['validFrom']) : Number.NaN;
1016
+ const end = typeof schedule['validUntil'] === 'string' ? Date.parse(schedule['validUntil']) : Number.NaN;
1017
+ const factor = discount['factor'];
1018
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
1019
+ return [];
1020
+ if (typeof factor !== 'number' || !Number.isFinite(factor) || factor < 0)
1021
+ return [];
1022
+ return [{
1023
+ start,
1024
+ end,
1025
+ factor,
1026
+ label: typeof badge['label'] === 'string' ? badge['label'] : '',
1027
+ priority: typeof item['priority'] === 'number' && Number.isFinite(item['priority']) ? item['priority'] : 0,
1028
+ }];
1029
+ });
1030
+ }
1031
+ /**
1032
+ * Re-evaluate a model's promotion against the current time.
1033
+ *
1034
+ * Promotions are time-boxed, and the catalog they arrive in is cached for the
1035
+ * life of the process. Frozen at parse time, a cached "Free now" would keep
1036
+ * claiming a discount after `validUntil` had passed, and would keep showing the
1037
+ * pre-discount rate as the discounted one. Re-deriving on every read means the
1038
+ * badge disappears on its own and the rate reverts, with no refresh needed.
1039
+ *
1040
+ * Non-destructive: the model's own `credits` and `badges` are the base, and the
1041
+ * promotion is layered onto a copy. A model with no live promotion is returned
1042
+ * as-is, so the common case allocates nothing.
1043
+ */
1044
+ export function modelWithCurrentPromotion(model, now = Date.now()) {
1045
+ if (model.promotions === undefined || model.promotions.length === 0)
1046
+ return model;
1047
+ const promotion = [...model.promotions]
1048
+ .sort((a, b) => b.priority - a.priority)
1049
+ .find(candidate => now >= candidate.start && now < candidate.end);
1050
+ if (promotion === undefined) {
1051
+ // This row arrives with promotions attached, but none is in force now. The
1052
+ // catalog is cached for the process's life, so the rate baked into the row
1053
+ // was read while a promotion *was* active — the upstream writes the
1054
+ // discounted value into `credits` itself (the international document's
1055
+ // free models ship `credits: "x0.00"`). Keeping that value would advertise
1056
+ // a discount that has ended, and `free: true` is the worst case of it: the
1057
+ // user would be told a model costs nothing when it does not.
1058
+ //
1059
+ // The original price is not recoverable from this row, so the honest answer
1060
+ // is to stop asserting one: the rate is dropped and any promo badge
1061
+ // removed. `rateUnknown` marks it so the card can say the price needs a
1062
+ // refresh rather than implying the model is free.
1063
+ const derivedFromPromotion = model.billing?.free === true
1064
+ || (model.billing?.badges?.length ?? 0) > 0
1065
+ || model.promotions.some(candidate => candidate.factor !== 1);
1066
+ if (!derivedFromPromotion)
1067
+ return model;
1068
+ return {
1069
+ ...model,
1070
+ billing: {
1071
+ free: false,
1072
+ rateUnknown: true,
1073
+ },
1074
+ };
1075
+ }
1076
+ const rate = normalizeCredits(model.billing?.credits);
1077
+ const original = rate !== undefined && rate.startsWith('x') ? Number(rate.slice(1)) : Number.NaN;
1078
+ // A replacement to zero is meaningful even when the base rate is unknown (the
1079
+ // App document's Auto row carries an empty rate string); any other multiplier
1080
+ // needs a number to scale, so it is skipped rather than invented.
1081
+ if (promotion.factor !== 0 && !Number.isFinite(original))
1082
+ return model;
1083
+ const value = promotion.factor === 0 ? 0 : original * promotion.factor;
1084
+ return {
1085
+ ...model,
1086
+ billing: {
1087
+ ...model.billing,
1088
+ credits: `x${value.toFixed(2)}`,
1089
+ free: value === 0,
1090
+ badges: [
1091
+ ...(model.billing?.badges ?? []),
1092
+ ...promotion.label === '' ? [] : [promotion.label],
1093
+ ],
1094
+ },
1095
+ };
1096
+ }
1097
+ /**
1098
+ * Apply the international endpoint's extra chat requirement: the first message
1099
+ * must be a system prompt.
1100
+ *
1101
+ * The international gateway rejects a body whose first message is not `system`
1102
+ * with HTTP 400 code 11128 ("first message is not system prompt"). Note that
1103
+ * the *same* code means something else on the CN endpoint — there it reports a
1104
+ * rejected `developer` role — so the two are never branched on by code alone.
1105
+ *
1106
+ * The added prompt is deliberately empty of user content and prepended, never
1107
+ * merged: existing messages keep their order and wording. A body that is not a
1108
+ * JSON object is returned unchanged, exactly as {@link prepareChatBody} does,
1109
+ * so this is safe to run over an already-prepared-or-not body.
1110
+ */
1111
+ export function prepareInternationalChatBody(source) {
1112
+ const prepared = prepareChatBody(source);
1113
+ let body;
1114
+ try {
1115
+ body = JSON.parse(prepared);
1116
+ }
1117
+ catch {
1118
+ // Not JSON: nothing to prepend to, and the upstream will reject it anyway.
1119
+ return prepared;
1120
+ }
1121
+ if (!isObject(body))
1122
+ return prepared;
1123
+ // Region-scoped strip, deliberately *after* the shared `prepareChatBody`:
1124
+ // the CN variant keeps its existing request behaviour — `reasoning_effort`
1125
+ // is passed through verbatim there, including the adapter's own `off`
1126
+ // spelling. See `dropUnsupportedEffort` below for why only this region drops it.
1127
+ dropUnsupportedEffort(body);
1128
+ const messages = body['messages'];
1129
+ if (!Array.isArray(messages))
1130
+ return JSON.stringify(body);
1131
+ const first = messages[0];
1132
+ if (isObject(first) && first['role'] === 'system')
1133
+ return JSON.stringify(body);
1134
+ // Unshift, so every caller-supplied message keeps its position and content.
1135
+ messages.unshift({ role: 'system', content: INTERNATIONAL_SYSTEM_PROMPT });
1136
+ return JSON.stringify(body);
1137
+ }
1138
+ /**
1139
+ * Remove the adapter's own `off` effort spelling from the **international** wire.
1140
+ *
1141
+ * `thinkingLevelMap.off` is pinned to the literal `'off'` for models that
1142
+ * declare `canDisableThinking`, so that the level stays selectable. pi-ai
1143
+ * sends that value for any request carrying no explicit level, and the
1144
+ * international endpoint rejects it on the GPT family with HTTP 400 `11133` /
1145
+ * `extError.param === 'reasoning.effort'` (issue #49). Omission is the only
1146
+ * form measured good on every such model; a literal `'none'` is *not* a safe
1147
+ * substitute — accepted by the GPT-5.6 family and GLM, rejected by
1148
+ * `gpt-6-astra`.
1149
+ *
1150
+ * Consequences, stated honestly: the international picker still offers Off,
1151
+ * but selecting it now means "the field is omitted" — the model's actual
1152
+ * behaviour is decided upstream and is *not* guaranteed to disable thinking
1153
+ * or to match the catalog's `defaultEffort`. Declared spellings
1154
+ * (`low`/`medium`/`high`/`xhigh`/`max`) and an explicit `none` pass through
1155
+ * untouched. The CN variant is deliberately unaffected: its endpoint has
1156
+ * accepted this spelling in every measurement so far, and keeping its wire
1157
+ * unchanged is a scope decision, not a claim about that endpoint's future.
1158
+ */
1159
+ function dropUnsupportedEffort(obj) {
1160
+ if (obj['reasoning_effort'] === 'off')
1161
+ delete obj['reasoning_effort'];
1162
+ }
1163
+ /**
1164
+ * The system prompt injected when the international endpoint receives a body
1165
+ * with none.
1166
+ *
1167
+ * Minimal on purpose: it exists to satisfy a gateway precondition, not to
1168
+ * steer the model. The plugin is not the place to invent a persona, and the
1169
+ * normal path never reaches this — pi-ai already sends the harness's system
1170
+ * prompt, so this only covers a caller that omitted one.
1171
+ */
1172
+ const INTERNATIONAL_SYSTEM_PROMPT = 'You are a helpful assistant.';
1173
+ /**
1174
+ * Output ceiling for an international probe request.
1175
+ *
1176
+ * Above the smallest value that the strictest observed model accepts (the
1177
+ * GPT-5.6 family rejects `1` with 11133), while still being far too small to
1178
+ * produce a real answer. See {@link WorkBuddyUpstreamClient.probeEffort}.
1179
+ */
1180
+ const INTERNATIONAL_PROBE_MAX_TOKENS = 16;