dsh-model 0.0.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +123 -0
- package/README.md +92 -43
- package/bin/dsh-model.js +8 -0
- package/dist/bridge/aggregate.js +91 -0
- package/dist/bridge/login.js +127 -0
- package/dist/bridge/runtime.js +136 -0
- package/dist/bridge/server.js +178 -0
- package/dist/bridge/workbuddy/_compat.js +52 -0
- package/dist/bridge/workbuddy/app-version.js +141 -0
- package/dist/bridge/workbuddy/auth.js +548 -0
- package/dist/bridge/workbuddy/catalog.js +154 -0
- package/dist/bridge/workbuddy/client-identity.js +234 -0
- package/dist/bridge/workbuddy/desktop-credential-protection.js +992 -0
- package/dist/bridge/workbuddy/loopback.js +52 -0
- package/dist/bridge/workbuddy/probe.js +153 -0
- package/dist/bridge/workbuddy/status-paths.js +49 -0
- package/dist/bridge/workbuddy/upstream.js +1180 -0
- package/dist/bridge/workbuddy/variants.js +101 -0
- package/dist/cli.js +207 -0
- package/dist/commands/bridge.js +68 -0
- package/dist/commands/connect.js +67 -0
- package/dist/commands/engine.js +122 -0
- package/dist/commands/key.js +75 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logs.js +19 -0
- package/dist/commands/opencode.js +77 -0
- package/dist/commands/remote.js +129 -0
- package/dist/commands/service.js +72 -0
- package/dist/commands/setup.js +216 -0
- package/dist/commands/sources.js +196 -0
- package/dist/commands/status.js +216 -0
- package/dist/commands/uninstall.js +97 -0
- package/dist/commands/workbuddy.js +117 -0
- package/dist/context.js +86 -0
- package/dist/daemon/client.js +27 -0
- package/dist/daemon/core.js +790 -0
- package/dist/daemon/service.js +33 -0
- package/dist/daemon/stats.js +126 -0
- package/dist/daemon/usage.js +213 -0
- package/dist/dsh/cli.js +72 -0
- package/dist/dsh/connect.js +257 -0
- package/dist/dsh/credentials.js +55 -0
- package/dist/dsh/locate.js +129 -0
- package/dist/dsh/patch.js +106 -0
- package/dist/dsh/pick.js +59 -0
- package/dist/engine/auth.js +40 -0
- package/dist/engine/client.js +104 -0
- package/dist/engine/compat.js +94 -0
- package/dist/engine/config.js +47 -0
- package/dist/engine/install.js +118 -0
- package/dist/engine/mgmt.js +99 -0
- package/dist/engine/sourcemap.js +48 -0
- package/dist/errors.js +14 -0
- package/dist/i18n.js +39 -0
- package/dist/integrations/dshplugin.js +42 -0
- package/dist/integrations/opencode.js +101 -0
- package/dist/integrations/workbuddy.js +114 -0
- package/dist/keys.js +54 -0
- package/dist/ops.js +178 -0
- package/dist/remote/public.js +109 -0
- package/dist/secrets.js +13 -0
- package/dist/service/index.js +46 -0
- package/dist/service/launchd.js +89 -0
- package/dist/service/systemd.js +83 -0
- package/dist/sources.js +36 -0
- package/dist/state.js +90 -0
- package/dist/upstreams.js +23 -0
- package/dist/util/diff.js +30 -0
- package/dist/util/exec.js +42 -0
- package/dist/util/fs.js +107 -0
- package/dist/util/output.js +50 -0
- package/dist/util/pkg.js +13 -0
- package/dist/util/port.js +16 -0
- package/dist/util/prompt.js +40 -0
- package/dist/util/proxy.js +73 -0
- package/dist/util/redact.js +7 -0
- package/engine-manifest.json +28 -0
- package/package.json +77 -7
- package/plugin/client.js +549 -0
- package/plugin/cordis.patch.yml +4 -0
- package/plugin/index.js +177 -0
- 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;
|