dsh-connect-workbuddy 4.13.1 → 4.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/CHANGELOG.md +84 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +336 -16
- package/lib/{host-heartbeat-CwdR6teZ.js → host-heartbeat-B1I95fKS.js} +413 -53
- package/lib/index.d.ts +258 -26
- package/lib/index.js +33 -20
- package/package.json +1 -1
|
@@ -1096,6 +1096,198 @@ function withEffectiveOff(models, selection) {
|
|
|
1096
1096
|
});
|
|
1097
1097
|
}
|
|
1098
1098
|
//#endregion
|
|
1099
|
+
//#region src/promo.ts
|
|
1100
|
+
/** A record-ish value, or undefined for anything else. */
|
|
1101
|
+
function record(value) {
|
|
1102
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
1103
|
+
}
|
|
1104
|
+
/** A trimmed non-empty string, or undefined. */
|
|
1105
|
+
function text(value) {
|
|
1106
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* `'HH:mm'` → minutes since midnight, or undefined for anything else.
|
|
1110
|
+
*
|
|
1111
|
+
* `24:00` is accepted and means exactly 1440 — the END of a day. The upstream
|
|
1112
|
+
* writes "all day" as `0:00`–`23:59` today, which leaves a one-minute hole
|
|
1113
|
+
* between 23:59 and midnight where a standing campaign reads as not running.
|
|
1114
|
+
* Accepting `24:00` lets such a window be expressed (and lets a future upstream
|
|
1115
|
+
* fix it) without that hole; as a START it is rejected, because a day that
|
|
1116
|
+
* begins at 24:00 is the next day and that is a different statement.
|
|
1117
|
+
*
|
|
1118
|
+
* Any other malformed value is left to the caller to drop — a wrong window
|
|
1119
|
+
* would hide a live promotion, and guessing is not this parser's job.
|
|
1120
|
+
*/
|
|
1121
|
+
function minutesOfClock(value) {
|
|
1122
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
1123
|
+
if (match === null) return void 0;
|
|
1124
|
+
const hours = Number(match[1]);
|
|
1125
|
+
const minutes = Number(match[2]);
|
|
1126
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return void 0;
|
|
1127
|
+
if (minutes > 59) return void 0;
|
|
1128
|
+
if (hours > 24) return void 0;
|
|
1129
|
+
if (hours === 24) return minutes === 0 ? 1440 : void 0;
|
|
1130
|
+
return hours * 60 + minutes;
|
|
1131
|
+
}
|
|
1132
|
+
/** An ISO-8601 instant → epoch milliseconds. Numbers are refused on purpose. */
|
|
1133
|
+
function parseInstant(value) {
|
|
1134
|
+
const raw = text(value);
|
|
1135
|
+
if (raw === void 0) return void 0;
|
|
1136
|
+
const ms = Date.parse(raw);
|
|
1137
|
+
return Number.isNaN(ms) ? void 0 : ms;
|
|
1138
|
+
}
|
|
1139
|
+
/** The upstream's `modelIds` → the model's ids, de-duplicated. */
|
|
1140
|
+
function parseModelIds(value) {
|
|
1141
|
+
if (!Array.isArray(value)) return void 0;
|
|
1142
|
+
const ids = [];
|
|
1143
|
+
for (const entry of value) {
|
|
1144
|
+
const id = text(entry);
|
|
1145
|
+
if (id !== void 0 && !ids.includes(id)) ids.push(id);
|
|
1146
|
+
}
|
|
1147
|
+
return ids.length === 0 ? void 0 : ids;
|
|
1148
|
+
}
|
|
1149
|
+
/** The upstream's `badge` → this plugin's badge shape. */
|
|
1150
|
+
function parseBadge(value) {
|
|
1151
|
+
const raw = record(value);
|
|
1152
|
+
if (raw === void 0) return void 0;
|
|
1153
|
+
const label = text(raw["label"]) ?? text(raw["labelEn"]) ?? text(raw["shortLabel"]) ?? text(raw["shortLabelEn"]);
|
|
1154
|
+
if (label === void 0) return void 0;
|
|
1155
|
+
const labelEn = text(raw["labelEn"]);
|
|
1156
|
+
const shortLabel = text(raw["shortLabel"]);
|
|
1157
|
+
const shortLabelEn = text(raw["shortLabelEn"]);
|
|
1158
|
+
const color = text(raw["color"]);
|
|
1159
|
+
const display = raw["display"] === "activeOnly" ? "activeOnly" : void 0;
|
|
1160
|
+
return {
|
|
1161
|
+
label,
|
|
1162
|
+
...labelEn === void 0 ? {} : { labelEn },
|
|
1163
|
+
...shortLabel === void 0 ? {} : { shortLabel },
|
|
1164
|
+
...shortLabelEn === void 0 ? {} : { shortLabelEn },
|
|
1165
|
+
...color === void 0 ? {} : { color },
|
|
1166
|
+
...display === void 0 ? {} : { display }
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/** The upstream's `hover` → this plugin's copy shape. */
|
|
1170
|
+
function parseHover(value) {
|
|
1171
|
+
const raw = record(value);
|
|
1172
|
+
if (raw === void 0) return void 0;
|
|
1173
|
+
const textZh = text(raw["textZh"]);
|
|
1174
|
+
const textEn = text(raw["textEn"]);
|
|
1175
|
+
if (textZh === void 0 && textEn === void 0) return void 0;
|
|
1176
|
+
return {
|
|
1177
|
+
...textZh === void 0 ? {} : { textZh },
|
|
1178
|
+
...textEn === void 0 ? {} : { textEn }
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
/** The upstream's `schedule.daily` → the windows this plugin can evaluate. */
|
|
1182
|
+
function parseDaily(value) {
|
|
1183
|
+
if (!Array.isArray(value)) return void 0;
|
|
1184
|
+
const windows = [];
|
|
1185
|
+
for (const entry of value) {
|
|
1186
|
+
const raw = record(entry);
|
|
1187
|
+
if (raw === void 0) continue;
|
|
1188
|
+
const start = text(raw["start"]);
|
|
1189
|
+
const end = text(raw["end"]);
|
|
1190
|
+
if (start === void 0 || end === void 0) continue;
|
|
1191
|
+
if (minutesOfClock(start) === void 0 || minutesOfClock(end) === void 0) continue;
|
|
1192
|
+
windows.push({
|
|
1193
|
+
start,
|
|
1194
|
+
end
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
return windows.length === 0 ? void 0 : windows;
|
|
1198
|
+
}
|
|
1199
|
+
/** The upstream's `schedule` → this plugin's schedule shape. */
|
|
1200
|
+
function parseSchedule(value) {
|
|
1201
|
+
const raw = record(value);
|
|
1202
|
+
if (raw === void 0) return void 0;
|
|
1203
|
+
const timezone = text(raw["timezone"]);
|
|
1204
|
+
const validFrom = parseInstant(raw["validFrom"]);
|
|
1205
|
+
const validUntil = parseInstant(raw["validUntil"]);
|
|
1206
|
+
const daily = parseDaily(raw["daily"]);
|
|
1207
|
+
const dailyUnusable = daily === void 0 && Array.isArray(raw["daily"]) && raw["daily"].length > 0;
|
|
1208
|
+
if (timezone === void 0 && validFrom === void 0 && validUntil === void 0 && daily === void 0 && !dailyUnusable) return void 0;
|
|
1209
|
+
return {
|
|
1210
|
+
...timezone === void 0 ? {} : { timezone },
|
|
1211
|
+
...validFrom === void 0 ? {} : { validFrom },
|
|
1212
|
+
...validUntil === void 0 ? {} : { validUntil },
|
|
1213
|
+
...daily === void 0 ? {} : { daily },
|
|
1214
|
+
...dailyUnusable ? { dailyUnusable: true } : {}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/** One upstream entry → this plugin's promo shape, or undefined if it is not one. */
|
|
1218
|
+
function parsePromo(value, source) {
|
|
1219
|
+
const raw = record(value);
|
|
1220
|
+
if (raw === void 0) return void 0;
|
|
1221
|
+
if (raw["enabled"] === false) return void 0;
|
|
1222
|
+
const modelIds = parseModelIds(raw["modelIds"]);
|
|
1223
|
+
if (modelIds === void 0) return void 0;
|
|
1224
|
+
const badge = parseBadge(raw["badge"]);
|
|
1225
|
+
if (badge === void 0) return void 0;
|
|
1226
|
+
const hover = parseHover(raw["hover"]);
|
|
1227
|
+
const schedule = parseSchedule(raw["schedule"]);
|
|
1228
|
+
const priority = typeof raw["priority"] === "number" && Number.isFinite(raw["priority"]) ? raw["priority"] : 0;
|
|
1229
|
+
return {
|
|
1230
|
+
id: text(raw["id"]) ?? `${source}:${modelIds.join(",")}`,
|
|
1231
|
+
source,
|
|
1232
|
+
modelIds,
|
|
1233
|
+
badge,
|
|
1234
|
+
...hover === void 0 ? {} : { hover },
|
|
1235
|
+
...schedule === void 0 ? {} : { schedule },
|
|
1236
|
+
priority
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Parse the upstream's promotion and tier lists.
|
|
1241
|
+
*
|
|
1242
|
+
* Both lists are read from the SAME document the roster comes from, so this
|
|
1243
|
+
* never costs a request. Entries that are not promotions — no `modelIds`, no
|
|
1244
|
+
* `badge`, or `enabled: false` — are dropped here rather than downstream, so
|
|
1245
|
+
* that everything downstream can assume a promo is drawable.
|
|
1246
|
+
*
|
|
1247
|
+
* The promotions come first, so that a TIE on `priority` is broken in the
|
|
1248
|
+
* promotions' favour: a price badge ("Free now") is more actionable to a user
|
|
1249
|
+
* than an access-tier badge ("Pro Only"), and the upstream numbers them
|
|
1250
|
+
* equally (both 200 on the observed feed).
|
|
1251
|
+
*/
|
|
1252
|
+
function parseWorkBuddyPromos(rawPromotions, rawTiers) {
|
|
1253
|
+
const promos = [];
|
|
1254
|
+
for (const entry of Array.isArray(rawPromotions) ? rawPromotions : []) {
|
|
1255
|
+
const promo = parsePromo(entry, "promotion");
|
|
1256
|
+
if (promo !== void 0) promos.push(promo);
|
|
1257
|
+
}
|
|
1258
|
+
for (const entry of Array.isArray(rawTiers) ? rawTiers : []) {
|
|
1259
|
+
const tier = parsePromo(entry, "tier");
|
|
1260
|
+
if (tier !== void 0) promos.push(tier);
|
|
1261
|
+
}
|
|
1262
|
+
return promos;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Attach the promotions that name each model.
|
|
1266
|
+
*
|
|
1267
|
+
* The result is UNRESOLVED on purpose: every candidate that names the model
|
|
1268
|
+
* rides along, so the badge can be chosen at render time against the clock
|
|
1269
|
+
* (see {@link selectPromoBadge}). Resolving here would freeze the answer at
|
|
1270
|
+
* fetch time — the defect that makes a "night discount" badge keep claiming
|
|
1271
|
+
* the night after the night is over.
|
|
1272
|
+
*/
|
|
1273
|
+
function attachPromos(models, promos) {
|
|
1274
|
+
if (promos.length === 0) return [...models];
|
|
1275
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
1276
|
+
for (const promo of promos) for (const modelId of promo.modelIds) {
|
|
1277
|
+
const list = byModel.get(modelId);
|
|
1278
|
+
if (list === void 0) byModel.set(modelId, [promo]);
|
|
1279
|
+
else list.push(promo);
|
|
1280
|
+
}
|
|
1281
|
+
return models.map((model) => {
|
|
1282
|
+
const list = byModel.get(model.id);
|
|
1283
|
+
if (list === void 0) return model;
|
|
1284
|
+
return {
|
|
1285
|
+
...model,
|
|
1286
|
+
promos: [...list].sort((a, b) => b.priority - a.priority)
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
//#endregion
|
|
1099
1291
|
//#region src/upstream.ts
|
|
1100
1292
|
/**
|
|
1101
1293
|
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
@@ -1145,55 +1337,80 @@ const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
|
1145
1337
|
* Legacy model-catalog path, kept only as the CN region's FALLBACK.
|
|
1146
1338
|
*
|
|
1147
1339
|
* This is the document the WorkBuddy *plugin* used to read, and it is NOT the
|
|
1148
|
-
* document the WorkBuddy *app* reads
|
|
1149
|
-
* channel's roster,
|
|
1150
|
-
*
|
|
1151
|
-
* `hy4-preview-f`
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1340
|
+
* document the WorkBuddy *app* reads: the gateway answers it with the CLI
|
|
1341
|
+
* channel's roster, which carries neither the app's `auto` slot nor any
|
|
1342
|
+
* promotion feed. The free Hy4 tier that used to live here as a separate
|
|
1343
|
+
* `hy4-preview-f` record is gone as a record — measured 2026-10-21, both
|
|
1344
|
+
* channels list the paid `hy4-preview` (`x0.29`) — and the free tier is now
|
|
1345
|
+
* expressed as a promotion badge (`夜间免费`) in the app-channel document only.
|
|
1346
|
+
* So this path is strictly poorer than {@link GLOBAL_CONFIG_PATH}; it stays
|
|
1347
|
+
* only so that a gateway which stops serving the modern document degrades
|
|
1348
|
+
* instead of emptying the region.
|
|
1155
1349
|
*/
|
|
1156
1350
|
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
1157
1351
|
/**
|
|
1158
|
-
* Remote product-config path
|
|
1159
|
-
*
|
|
1352
|
+
* Remote product-config path — the document the WorkBuddy app itself reads for
|
|
1353
|
+
* its model list, its prices AND its promotion badges.
|
|
1160
1354
|
*
|
|
1161
1355
|
* It is field-compatible with {@link MODELS_CATALOG_PATH} for every key
|
|
1162
|
-
* {@link parseUpstreamModel} reads, so one parser still serves both
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
* `hy4-preview-f`(x0.00); desktop token = 29 models, no `hy4-preview-f` and no
|
|
1171
|
-
* cheap free tier. That is why the global branch below pairs this path with
|
|
1172
|
-
* {@link DESKTOP_UA} and the CN branch must NOT.
|
|
1356
|
+
* {@link parseUpstreamModel} reads, so one parser still serves both, and it
|
|
1357
|
+
* additionally carries `modelPromotions` / `modelTiers` — the promotion feed
|
|
1358
|
+
* no other path publishes (see {@link withPromos}).
|
|
1359
|
+
*
|
|
1360
|
+
* Which document this path answers depends on the CHANNEL the request claims
|
|
1361
|
+
* to be (see {@link PRODUCT_UA}); it does not depend on the host: `www.workbuddy.cn`,
|
|
1362
|
+
* `copilot.tencent.com`, `wb.tencentbuddy.com` and `www.codebuddy.cn` answer
|
|
1363
|
+
* byte-identically under the same header.
|
|
1173
1364
|
*/
|
|
1174
1365
|
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
1366
|
+
/**
|
|
1367
|
+
* The chat channel's user agent — the identity the plugin's adapter speaks as.
|
|
1368
|
+
*
|
|
1369
|
+
* Chat is a different contract from configuration: the conversation-identity
|
|
1370
|
+
* triple (conversation id, session id, device id) is bound to this channel, and
|
|
1371
|
+
* the upstream bills and rate-limits it as the CLI agent. The plugin chats as
|
|
1372
|
+
* the CLI agent on purpose, so this stays {@link CLIENT_UA} everywhere the
|
|
1373
|
+
* request is a CHAT.
|
|
1374
|
+
*/
|
|
1175
1375
|
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
1176
1376
|
/**
|
|
1177
|
-
*
|
|
1377
|
+
* The product-config channel's user agent: the WorkBuddy APP's own product
|
|
1378
|
+
* identity, byte-for-byte the header the app sends.
|
|
1379
|
+
*
|
|
1380
|
+
* The app builds it in `buildVlmGatewayHeaders` (WorkBuddy 5.8.3) as
|
|
1381
|
+
*
|
|
1382
|
+
* `User-Agent: WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`
|
|
1383
|
+
*
|
|
1384
|
+
* — the app version twice, the CLI version once — and sends it to
|
|
1385
|
+
* `/v3/config`; the app's own log reads
|
|
1386
|
+
* `fetch cloud product from url:https://www.workbuddy.cn/v3/config success`
|
|
1387
|
+
* with `{"user-agent":"WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0", …}`.
|
|
1388
|
+
*
|
|
1389
|
+
* It matters, because `/v3/config` answers a DIFFERENT product configuration
|
|
1390
|
+
* per client channel. Measured 2026-10-21, same token, same URL, only this
|
|
1391
|
+
* header differing (CN gateway):
|
|
1392
|
+
*
|
|
1393
|
+
* | User-Agent | `/v3/config` answer |
|
|
1394
|
+
* | --- | --- |
|
|
1395
|
+
* | `CLI/2.63.2 CodeBuddy/2.63.2` (CLI channel) | 5 keys, 35 models, NO promotion feed |
|
|
1396
|
+
* | `WorkBuddy/5.8.3` (app channel, no CLI part) | 18 keys, 37 models, no promotion feed |
|
|
1397
|
+
* | `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0` | 22 keys, 62 models, 8 promos + 1 tier |
|
|
1178
1398
|
*
|
|
1179
|
-
* The
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1399
|
+
* The CLI channel's answer is a strict SUBSET of the app channel's: it
|
|
1400
|
+
* publishes neither `modelPromotions` nor `modelTiers`. That is precisely why
|
|
1401
|
+
* the CN card showed no promotion badge while the WorkBuddy app showed one on
|
|
1402
|
+
* the very same account — the plugin was reading a document the upstream
|
|
1403
|
+
* publishes WITHOUT the feed, not a feed that does not exist.
|
|
1184
1404
|
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
* billed `x0.00`);
|
|
1189
|
-
* - desktop channel → the account's real 20-model chat roster including both.
|
|
1405
|
+
* The version suffixes are inert — `WorkBuddy/1.0.0 CLI/2.63.2
|
|
1406
|
+
* CodeBuddy/2.63.2` answers identically — so what selects the document is the
|
|
1407
|
+
* CHANNEL the header claims, not the version in it.
|
|
1190
1408
|
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
* CN keeps reading the shared `/v2/enterprises/personal/models` path.
|
|
1409
|
+
* Global answers the same document under either app-shaped header (17 keys,
|
|
1410
|
+
* 28 models, 5 promotions + 1 tier, identical roster), so both regions read
|
|
1411
|
+
* `/v3/config` as {@link PRODUCT_UA}.
|
|
1195
1412
|
*/
|
|
1196
|
-
const
|
|
1413
|
+
const PRODUCT_UA = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0";
|
|
1197
1414
|
const JSON_TIMEOUT_MS = 3e4;
|
|
1198
1415
|
const ERROR_BODY_LIMIT = 4096;
|
|
1199
1416
|
/**
|
|
@@ -1281,6 +1498,44 @@ const POLICY_REJECT_MARKERS = ["request illegal", "\"code\":11140"];
|
|
|
1281
1498
|
*/
|
|
1282
1499
|
const MODEL_BUSY_CODE = "\"code\":14003";
|
|
1283
1500
|
/**
|
|
1501
|
+
* Business code of the gateway's CHANNEL gate (`11128`, "Illegal API invocation
|
|
1502
|
+
* from an unapproved channel").
|
|
1503
|
+
*
|
|
1504
|
+
* The quoted-key form for the same reason as the markers above: a request id is
|
|
1505
|
+
* hexadecimal and can contain the digits `11128` by coincidence.
|
|
1506
|
+
*
|
|
1507
|
+
* A DIFFERENT gate from the one {@link conversationHeaders} works around, which
|
|
1508
|
+
* also answers `11128` (`conversation id missing`). The code is the same
|
|
1509
|
+
* because the gateway reuses it for every rule in that family; the BODY is what
|
|
1510
|
+
* tells them apart, so {@link isChannelRejectBody} requires both halves.
|
|
1511
|
+
*/
|
|
1512
|
+
const CHANNEL_REJECT_CODE = "\"code\":11128";
|
|
1513
|
+
/**
|
|
1514
|
+
* Wording that identifies the CHANNEL half of `11128` (the other halves being
|
|
1515
|
+
* `conversation id missing` and `first message is not system prompt`).
|
|
1516
|
+
*
|
|
1517
|
+
* Matched against the upstream's `msg`, NEVER against `displayTips`. That is not
|
|
1518
|
+
* a style choice: the tips are the SAME misleading sentence for every member of
|
|
1519
|
+
* the `11128` family — "这次调用没通过渠道校验,可能不是官方客户端发出的" — which is
|
|
1520
|
+
* why matching them would claim the conversation-id gate too and blame the
|
|
1521
|
+
* prompt's opening for a header problem this plugin already fixes.
|
|
1522
|
+
*
|
|
1523
|
+
* Lower-cased before the search. Two spellings are listed because the observed
|
|
1524
|
+
* bodies name the code and the phrase together
|
|
1525
|
+
* (`{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`),
|
|
1526
|
+
* and either half is enough to identify it.
|
|
1527
|
+
*/
|
|
1528
|
+
const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
|
|
1529
|
+
/**
|
|
1530
|
+
* The other gates that answer the SAME business code.
|
|
1531
|
+
*
|
|
1532
|
+
* Excluded explicitly so a body that also carries the channel wording can never
|
|
1533
|
+
* be read as the prompt rule: a confidently wrong diagnosis is worse here than
|
|
1534
|
+
* the generic `client` reply, because it sends the user to rewrite a system
|
|
1535
|
+
* prompt that is not at fault.
|
|
1536
|
+
*/
|
|
1537
|
+
const OTHER_CHANNEL_CODE_GATES = ["conversation id missing", "first message is not system prompt"];
|
|
1538
|
+
/**
|
|
1284
1539
|
* Whether a failure body is the MODEL-scoped throttle rather than an account
|
|
1285
1540
|
* limit.
|
|
1286
1541
|
*
|
|
@@ -1300,6 +1555,72 @@ const MODEL_BUSY_CODE = "\"code\":14003";
|
|
|
1300
1555
|
function isModelBusyBody(body) {
|
|
1301
1556
|
return body.includes(MODEL_BUSY_CODE);
|
|
1302
1557
|
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Whether a failure body is the gateway's CHANNEL gate rather than the other
|
|
1560
|
+
* `11128` ({@link conversationHeaders}' `conversation id missing`).
|
|
1561
|
+
*
|
|
1562
|
+
* Both halves are required. The code alone would claim the conversation-id gate
|
|
1563
|
+
* too, and its reply would then blame the prompt's opening for a request-header
|
|
1564
|
+
* problem the plugin already fixed — a confidently wrong diagnosis is worse
|
|
1565
|
+
* here than the generic `client` reply, because it sends the user to rewrite a
|
|
1566
|
+
* system prompt that is not at fault.
|
|
1567
|
+
*/
|
|
1568
|
+
function isChannelRejectBody(body) {
|
|
1569
|
+
if (!body.includes(CHANNEL_REJECT_CODE)) return false;
|
|
1570
|
+
const lower = body.toLowerCase();
|
|
1571
|
+
if (OTHER_CHANNEL_CODE_GATES.some((marker) => lower.includes(marker))) return false;
|
|
1572
|
+
return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* The OPENINGS of the `system` messages in a prepared request body.
|
|
1576
|
+
*
|
|
1577
|
+
* This is the evidence the channel gate actually reacts to, and echoing it is
|
|
1578
|
+
* what turns "the upstream refused" into "the upstream refused because of this
|
|
1579
|
+
* line" — the reader can then fix the prompt or report the new opening, instead
|
|
1580
|
+
* of cycling accounts that cannot change a byte of it.
|
|
1581
|
+
*
|
|
1582
|
+
* Each entry is the first non-empty line, clipped: a fingerprint lives at the
|
|
1583
|
+
* START of the content, so the rest of a 10 KB system prompt is noise that
|
|
1584
|
+
* would bury the one sentence worth reading. At most three entries are kept —
|
|
1585
|
+
* the gate scans every `system` message, but a body with more than three of them
|
|
1586
|
+
* is already worth showing compactly.
|
|
1587
|
+
*
|
|
1588
|
+
* Never throws: a malformed body is the upstream's to reject, and losing the
|
|
1589
|
+
* failure measurement is not an acceptable price for reading an excerpt.
|
|
1590
|
+
*/
|
|
1591
|
+
function systemOpeningsOfBody(bodyJson) {
|
|
1592
|
+
let parsed;
|
|
1593
|
+
try {
|
|
1594
|
+
parsed = JSON.parse(bodyJson);
|
|
1595
|
+
} catch {
|
|
1596
|
+
return [];
|
|
1597
|
+
}
|
|
1598
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
1599
|
+
const messages = parsed["messages"];
|
|
1600
|
+
if (!Array.isArray(messages)) return [];
|
|
1601
|
+
const openings = [];
|
|
1602
|
+
for (const value of messages) {
|
|
1603
|
+
if (openings.length >= 3) break;
|
|
1604
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1605
|
+
const message = value;
|
|
1606
|
+
const role = message["role"];
|
|
1607
|
+
if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
|
|
1608
|
+
const content = message["content"];
|
|
1609
|
+
if (typeof content !== "string") continue;
|
|
1610
|
+
const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
|
|
1611
|
+
if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
|
|
1612
|
+
}
|
|
1613
|
+
return openings;
|
|
1614
|
+
}
|
|
1615
|
+
/** Longest system-prompt opening echoed back to the user, in characters. */
|
|
1616
|
+
const SYSTEM_OPENING_LIMIT = 120;
|
|
1617
|
+
function firstLineOf(content) {
|
|
1618
|
+
const newline = content.indexOf("\n");
|
|
1619
|
+
return newline === -1 ? content : content.slice(0, newline);
|
|
1620
|
+
}
|
|
1621
|
+
function clip(text, limit) {
|
|
1622
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
1623
|
+
}
|
|
1303
1624
|
/** Classify an upstream failure from its HTTP status and body excerpt. */
|
|
1304
1625
|
function classifyUpstreamError(status, body) {
|
|
1305
1626
|
if (status === 402) return "hard_credit";
|
|
@@ -1308,6 +1629,7 @@ function classifyUpstreamError(status, body) {
|
|
|
1308
1629
|
if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
|
|
1309
1630
|
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
1310
1631
|
for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
|
|
1632
|
+
if (isChannelRejectBody(body)) return "channel_reject";
|
|
1311
1633
|
if (isModelBusyBody(body)) return "model_busy";
|
|
1312
1634
|
if (status === 429) return "soft_rate";
|
|
1313
1635
|
if (status === 404) return "not_found";
|
|
@@ -2005,8 +2327,16 @@ function parseUpstreamModel(value) {
|
|
|
2005
2327
|
* selector serves them. Without a usable `cli` roster the whole parsed catalog
|
|
2006
2328
|
* is exposed rather than nothing: the roster is an upstream detail that may
|
|
2007
2329
|
* change, and an empty answer would silently disarm the provider.
|
|
2330
|
+
*
|
|
2331
|
+
* `keepIds` is the user's own selection riding along with the refresh. The
|
|
2332
|
+
* upstream roster may drop a model the user is actually running — the app
|
|
2333
|
+
* channel's `cli` roster dropped `minimax-m2.7` on 2026-10-21 while the SAME
|
|
2334
|
+
* document still publishes it (`x0.26`) and the chat endpoint still answers for
|
|
2335
|
+
* it (measured HTTP 200). A roster read is therefore the upstream's roster PLUS
|
|
2336
|
+
* the kept ids this document still lists: a refresh may add or withdraw
|
|
2337
|
+
* upstream models, but it never silently withdraws a model the user is on.
|
|
2008
2338
|
*/
|
|
2009
|
-
function selectCliModels(rawModels, agents) {
|
|
2339
|
+
function selectCliModels(rawModels, agents, keepIds = []) {
|
|
2010
2340
|
const byId = /* @__PURE__ */ new Map();
|
|
2011
2341
|
for (const model of Array.isArray(rawModels) ? rawModels : []) {
|
|
2012
2342
|
const parsed = parseUpstreamModel(model);
|
|
@@ -2020,9 +2350,32 @@ function selectCliModels(rawModels, agents) {
|
|
|
2020
2350
|
break;
|
|
2021
2351
|
}
|
|
2022
2352
|
}
|
|
2023
|
-
const
|
|
2353
|
+
const ids = cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()];
|
|
2354
|
+
const models = ids.map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2024
2355
|
if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
2025
|
-
|
|
2356
|
+
const kept = keepIds.filter((id) => !ids.includes(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2357
|
+
return [...models, ...kept];
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* The roster plus the promotion badges the SAME document carries.
|
|
2361
|
+
*
|
|
2362
|
+
* `data.modelPromotions` and `data.modelTiers` sit beside `data.models` in
|
|
2363
|
+
* the upstream's `/v3/config` document, so the badges cost no extra request:
|
|
2364
|
+
* they are read out of the response the roster was read from. Which models
|
|
2365
|
+
* they name is resolved here; WHICH badge holds at a given moment is not
|
|
2366
|
+
* (see `src/promo.ts` — a badge is a function of the clock).
|
|
2367
|
+
*
|
|
2368
|
+
* BOTH regions publish the feed, and they publish it in the app channel's
|
|
2369
|
+
* document: measured 2026-10-21 on the same accounts the app reads, CN
|
|
2370
|
+
* `/v3/config` carries 8 promotions + 1 tier badge (Chinese labels such as
|
|
2371
|
+
* `夜间折扣` / `限时免费` / `订阅优先`, with `schedule.daily` night windows),
|
|
2372
|
+
* and the global gateway carries 5 promotions + 1 tier badge. The feed is
|
|
2373
|
+
* absent only from the CLI channel's answer, which the plugin never reads a
|
|
2374
|
+
* product configuration as (see {@link PRODUCT_UA}).
|
|
2375
|
+
*/
|
|
2376
|
+
function withPromos(models, data) {
|
|
2377
|
+
const promos = parseWorkBuddyPromos(data["modelPromotions"], data["modelTiers"]);
|
|
2378
|
+
return promos.length === 0 ? models : attachPromos(models, promos);
|
|
2026
2379
|
}
|
|
2027
2380
|
/**
|
|
2028
2381
|
* Upstream HTTP client. One instance serves the whole plugin; requests take
|
|
@@ -2164,13 +2517,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2164
2517
|
/**
|
|
2165
2518
|
* Read the model directory for the credential's region.
|
|
2166
2519
|
*
|
|
2167
|
-
* Both regions
|
|
2168
|
-
* consumes,
|
|
2169
|
-
*
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2520
|
+
* Both regions read `/v3/config`, the document the WorkBuddy app itself
|
|
2521
|
+
* consumes, and both read it as the APP's product identity
|
|
2522
|
+
* ({@link PRODUCT_UA}) — the header the app sends, not the CLI channel's.
|
|
2523
|
+
* That is load-bearing, not cosmetic: the CLI channel's answer is a strict
|
|
2524
|
+
* subset of the app channel's and publishes neither `modelPromotions` nor
|
|
2525
|
+
* `modelTiers`, so reading `/v3/config` as the CLI channel is what left the
|
|
2526
|
+
* CN card without a single promotion badge while the app showed one on the
|
|
2527
|
+
* same account (see {@link PRODUCT_UA}).
|
|
2174
2528
|
*
|
|
2175
2529
|
* CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
|
|
2176
2530
|
* returns a non-zero envelope, or resolves to no usable model, so a gateway
|
|
@@ -2181,8 +2535,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2181
2535
|
*
|
|
2182
2536
|
* No user-side toggle is involved: the region comes from the credential's
|
|
2183
2537
|
* `domain`.
|
|
2538
|
+
*
|
|
2539
|
+
* `keepModelIds` is the user's own selection, passed through to
|
|
2540
|
+
* {@link selectCliModels}: a model the upstream roster dropped but the SAME
|
|
2541
|
+
* document still publishes stays in the roster, so reading the directory
|
|
2542
|
+
* never withdraws a model the user is running (issue #32's failure mode,
|
|
2543
|
+
* one step upstream of it).
|
|
2184
2544
|
*/
|
|
2185
|
-
async fetchModels(credential, signal) {
|
|
2545
|
+
async fetchModels(credential, signal, keepModelIds = []) {
|
|
2186
2546
|
const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
|
|
2187
2547
|
if (regionOf(credential.domain) === "global") {
|
|
2188
2548
|
const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
|
|
@@ -2194,14 +2554,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2194
2554
|
"X-Product": "SaaS",
|
|
2195
2555
|
"X-Requested-With": "XMLHttpRequest",
|
|
2196
2556
|
"Connection": "close",
|
|
2197
|
-
"User-Agent":
|
|
2557
|
+
"User-Agent": PRODUCT_UA
|
|
2198
2558
|
},
|
|
2199
2559
|
signal: timeout
|
|
2200
2560
|
});
|
|
2201
2561
|
const envelope = await readEnvelope(response);
|
|
2202
2562
|
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
2203
2563
|
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
2204
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
2564
|
+
return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
|
|
2205
2565
|
}
|
|
2206
2566
|
const readCatalog = async (path) => {
|
|
2207
2567
|
const response = await fetch(`${chatBase(credential)}${path}`, {
|
|
@@ -2210,14 +2570,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2210
2570
|
"Accept": "application/json",
|
|
2211
2571
|
"Origin": originReferer(credential),
|
|
2212
2572
|
"Referer": `${originReferer(credential)}/`,
|
|
2213
|
-
"User-Agent":
|
|
2573
|
+
"User-Agent": PRODUCT_UA
|
|
2214
2574
|
},
|
|
2215
2575
|
signal: timeout
|
|
2216
2576
|
});
|
|
2217
2577
|
const envelope = await readEnvelope(response);
|
|
2218
2578
|
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
2219
2579
|
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
2220
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
2580
|
+
return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
|
|
2221
2581
|
};
|
|
2222
2582
|
try {
|
|
2223
2583
|
return await readCatalog(GLOBAL_CONFIG_PATH);
|
|
@@ -4410,7 +4770,7 @@ function generationOf(version) {
|
|
|
4410
4770
|
//#endregion
|
|
4411
4771
|
//#region src/version.ts
|
|
4412
4772
|
/** The npm package version this build was produced from. */
|
|
4413
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
4773
|
+
const WORKBUDDY_CONNECT_VERSION = "4.14.0";
|
|
4414
4774
|
//#endregion
|
|
4415
4775
|
//#region src/host-heartbeat.ts
|
|
4416
4776
|
/**
|
|
@@ -4568,4 +4928,4 @@ async function clearHostHeartbeat() {
|
|
|
4568
4928
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
4569
4929
|
}
|
|
4570
4930
|
//#endregion
|
|
4571
|
-
export {
|
|
4931
|
+
export { withEffectiveOff as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, writeFileAtomic as _t, readHostHeartbeat as a, findWorkbuddyAppExecutable as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, isWorkbuddyBundle as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, openEncryptedField as dt, WORKBUDDY_APP_EXECUTABLE_ENV as et, WorkBuddyCatalog as f, readAtRestKey as ft, ENCRYPTED_CREDENTIAL_CODE as g, withFileLock as gt, fallbackModelsFor as h, resolveDshHome as ht, processStartTimeMs as i, fetchAtRestKeyPayload as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, macosBundleExecutable as lt, deriveCatalog as m, hostPeersInfo as mt, clearHostHeartbeat as n, deriveAtRestKey as nt, workbuddyHostHeartbeatPath as o, findWorkbuddyAppExecutableWithSource as ot, applyContextBudgets as p, workbuddyAppExecutableCandidates as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, deriveAtRestKeyId as rt, writeHostHeartbeat as s, isEncryptedFieldWrapper as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, clearAtRestKeyCache as tt, FALLBACK_WORKBUDDY_MODELS as u, macosNestedAppBundles as ut, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
|