dsh-connect-workbuddy 4.13.2 → 4.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +381 -54
- package/lib/{host-heartbeat-BJAL9pEe.js → host-heartbeat-D1WnqQY2.js} +409 -61
- package/lib/index.d.ts +234 -21
- package/lib/index.js +6 -66
- package/package.json +1 -1
|
@@ -962,6 +962,71 @@ function clearAtRestKeyCache() {
|
|
|
962
962
|
cachedAppExecutable = void 0;
|
|
963
963
|
}
|
|
964
964
|
//#endregion
|
|
965
|
+
//#region src/native-modality.ts
|
|
966
|
+
/**
|
|
967
|
+
* Reviewed vendor classifications, keyed by EXACT model id (never a prefix).
|
|
968
|
+
* Ids absent from this record are `unknown` — see {@link nativeModalityOf}.
|
|
969
|
+
*/
|
|
970
|
+
const NATIVE_MODALITY_BY_MODEL_ID = {
|
|
971
|
+
"deepseek-v4-pro": "text",
|
|
972
|
+
"glm-5.1": "text",
|
|
973
|
+
"glm-5.2": "text",
|
|
974
|
+
"glm-5.3": "text",
|
|
975
|
+
"hy3": "text",
|
|
976
|
+
"hy4-preview": "text",
|
|
977
|
+
"deepseek-v4.1-flash": "multimodal",
|
|
978
|
+
"glm-5.3-flash": "multimodal",
|
|
979
|
+
"glm-5v-turbo": "multimodal",
|
|
980
|
+
"kimi-k2.6": "multimodal",
|
|
981
|
+
"kimi-k2.7": "multimodal",
|
|
982
|
+
"kimi-k3-1": "multimodal",
|
|
983
|
+
"minimax-m3": "multimodal"
|
|
984
|
+
};
|
|
985
|
+
/**
|
|
986
|
+
* Ids the upstream lists in the model roster that are ROUTERS, not models.
|
|
987
|
+
*
|
|
988
|
+
* `auto` (CN) is the one observed today: the gateway resolves it to whichever
|
|
989
|
+
* model it considers the best fit (measured: CN `auto` → `hy4-preview`), so its
|
|
990
|
+
* price, context window and behaviour are decided at request time by something
|
|
991
|
+
* else. Offering it as a row advertises a model whose terms the plugin cannot
|
|
992
|
+
* state, and it duplicates a real row already in the same roster.
|
|
993
|
+
*
|
|
994
|
+
* Kept as an explicit table rather than a heuristic: a router is an upstream
|
|
995
|
+
* concept with no declared flag, and guessing from the name would eventually
|
|
996
|
+
* hide a real model. Add an id here only after the same kind of evidence.
|
|
997
|
+
*/
|
|
998
|
+
const ROUTER_MODEL_IDS = /* @__PURE__ */ new Set(["auto"]);
|
|
999
|
+
/** Whether this id is an upstream router rather than a model. */
|
|
1000
|
+
function isRouterModelId(modelId) {
|
|
1001
|
+
return ROUTER_MODEL_IDS.has(modelId);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Native modality of one model id. Exact match only: a new model never
|
|
1005
|
+
* inherits a classification from its family, its display name, or the
|
|
1006
|
+
* platform's image flag.
|
|
1007
|
+
*/
|
|
1008
|
+
function nativeModalityOf(modelId) {
|
|
1009
|
+
if (isRouterModelId(modelId)) return "router";
|
|
1010
|
+
return NATIVE_MODALITY_BY_MODEL_ID[modelId] ?? "unknown";
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Whether a model refresh should PRE-CHECK this model's image box.
|
|
1014
|
+
*
|
|
1015
|
+
* Only a documented `multimodal` is pre-checked. `text` is documented as
|
|
1016
|
+
* unable to read images, and `unknown` is deliberately left unchecked rather
|
|
1017
|
+
* than guessed — the whole reason this table exists is that the platform flag
|
|
1018
|
+
* guessed wrong. A platform `supportsImages: false` still vetoes, so a
|
|
1019
|
+
* documented-multimodal id can never be pre-checked against the platform's
|
|
1020
|
+
* explicit "no".
|
|
1021
|
+
*
|
|
1022
|
+
* The user can always tick a box by hand; this decides a DEFAULT only, and the
|
|
1023
|
+
* effective runtime flag remains the saved `imageModelIds`.
|
|
1024
|
+
*/
|
|
1025
|
+
function imageDefaultFor(info) {
|
|
1026
|
+
if (info.supportsImages === false) return false;
|
|
1027
|
+
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
965
1030
|
//#region src/off-thinking.ts
|
|
966
1031
|
/**
|
|
967
1032
|
* Ids observed to REFUSE `reasoning_effort: "off"` with HTTP 400 despite
|
|
@@ -1096,6 +1161,198 @@ function withEffectiveOff(models, selection) {
|
|
|
1096
1161
|
});
|
|
1097
1162
|
}
|
|
1098
1163
|
//#endregion
|
|
1164
|
+
//#region src/promo.ts
|
|
1165
|
+
/** A record-ish value, or undefined for anything else. */
|
|
1166
|
+
function record(value) {
|
|
1167
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
1168
|
+
}
|
|
1169
|
+
/** A trimmed non-empty string, or undefined. */
|
|
1170
|
+
function text(value) {
|
|
1171
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* `'HH:mm'` → minutes since midnight, or undefined for anything else.
|
|
1175
|
+
*
|
|
1176
|
+
* `24:00` is accepted and means exactly 1440 — the END of a day. The upstream
|
|
1177
|
+
* writes "all day" as `0:00`–`23:59` today, which leaves a one-minute hole
|
|
1178
|
+
* between 23:59 and midnight where a standing campaign reads as not running.
|
|
1179
|
+
* Accepting `24:00` lets such a window be expressed (and lets a future upstream
|
|
1180
|
+
* fix it) without that hole; as a START it is rejected, because a day that
|
|
1181
|
+
* begins at 24:00 is the next day and that is a different statement.
|
|
1182
|
+
*
|
|
1183
|
+
* Any other malformed value is left to the caller to drop — a wrong window
|
|
1184
|
+
* would hide a live promotion, and guessing is not this parser's job.
|
|
1185
|
+
*/
|
|
1186
|
+
function minutesOfClock(value) {
|
|
1187
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
1188
|
+
if (match === null) return void 0;
|
|
1189
|
+
const hours = Number(match[1]);
|
|
1190
|
+
const minutes = Number(match[2]);
|
|
1191
|
+
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return void 0;
|
|
1192
|
+
if (minutes > 59) return void 0;
|
|
1193
|
+
if (hours > 24) return void 0;
|
|
1194
|
+
if (hours === 24) return minutes === 0 ? 1440 : void 0;
|
|
1195
|
+
return hours * 60 + minutes;
|
|
1196
|
+
}
|
|
1197
|
+
/** An ISO-8601 instant → epoch milliseconds. Numbers are refused on purpose. */
|
|
1198
|
+
function parseInstant(value) {
|
|
1199
|
+
const raw = text(value);
|
|
1200
|
+
if (raw === void 0) return void 0;
|
|
1201
|
+
const ms = Date.parse(raw);
|
|
1202
|
+
return Number.isNaN(ms) ? void 0 : ms;
|
|
1203
|
+
}
|
|
1204
|
+
/** The upstream's `modelIds` → the model's ids, de-duplicated. */
|
|
1205
|
+
function parseModelIds(value) {
|
|
1206
|
+
if (!Array.isArray(value)) return void 0;
|
|
1207
|
+
const ids = [];
|
|
1208
|
+
for (const entry of value) {
|
|
1209
|
+
const id = text(entry);
|
|
1210
|
+
if (id !== void 0 && !ids.includes(id)) ids.push(id);
|
|
1211
|
+
}
|
|
1212
|
+
return ids.length === 0 ? void 0 : ids;
|
|
1213
|
+
}
|
|
1214
|
+
/** The upstream's `badge` → this plugin's badge shape. */
|
|
1215
|
+
function parseBadge(value) {
|
|
1216
|
+
const raw = record(value);
|
|
1217
|
+
if (raw === void 0) return void 0;
|
|
1218
|
+
const label = text(raw["label"]) ?? text(raw["labelEn"]) ?? text(raw["shortLabel"]) ?? text(raw["shortLabelEn"]);
|
|
1219
|
+
if (label === void 0) return void 0;
|
|
1220
|
+
const labelEn = text(raw["labelEn"]);
|
|
1221
|
+
const shortLabel = text(raw["shortLabel"]);
|
|
1222
|
+
const shortLabelEn = text(raw["shortLabelEn"]);
|
|
1223
|
+
const color = text(raw["color"]);
|
|
1224
|
+
const display = raw["display"] === "activeOnly" ? "activeOnly" : void 0;
|
|
1225
|
+
return {
|
|
1226
|
+
label,
|
|
1227
|
+
...labelEn === void 0 ? {} : { labelEn },
|
|
1228
|
+
...shortLabel === void 0 ? {} : { shortLabel },
|
|
1229
|
+
...shortLabelEn === void 0 ? {} : { shortLabelEn },
|
|
1230
|
+
...color === void 0 ? {} : { color },
|
|
1231
|
+
...display === void 0 ? {} : { display }
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/** The upstream's `hover` → this plugin's copy shape. */
|
|
1235
|
+
function parseHover(value) {
|
|
1236
|
+
const raw = record(value);
|
|
1237
|
+
if (raw === void 0) return void 0;
|
|
1238
|
+
const textZh = text(raw["textZh"]);
|
|
1239
|
+
const textEn = text(raw["textEn"]);
|
|
1240
|
+
if (textZh === void 0 && textEn === void 0) return void 0;
|
|
1241
|
+
return {
|
|
1242
|
+
...textZh === void 0 ? {} : { textZh },
|
|
1243
|
+
...textEn === void 0 ? {} : { textEn }
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
/** The upstream's `schedule.daily` → the windows this plugin can evaluate. */
|
|
1247
|
+
function parseDaily(value) {
|
|
1248
|
+
if (!Array.isArray(value)) return void 0;
|
|
1249
|
+
const windows = [];
|
|
1250
|
+
for (const entry of value) {
|
|
1251
|
+
const raw = record(entry);
|
|
1252
|
+
if (raw === void 0) continue;
|
|
1253
|
+
const start = text(raw["start"]);
|
|
1254
|
+
const end = text(raw["end"]);
|
|
1255
|
+
if (start === void 0 || end === void 0) continue;
|
|
1256
|
+
if (minutesOfClock(start) === void 0 || minutesOfClock(end) === void 0) continue;
|
|
1257
|
+
windows.push({
|
|
1258
|
+
start,
|
|
1259
|
+
end
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
return windows.length === 0 ? void 0 : windows;
|
|
1263
|
+
}
|
|
1264
|
+
/** The upstream's `schedule` → this plugin's schedule shape. */
|
|
1265
|
+
function parseSchedule(value) {
|
|
1266
|
+
const raw = record(value);
|
|
1267
|
+
if (raw === void 0) return void 0;
|
|
1268
|
+
const timezone = text(raw["timezone"]);
|
|
1269
|
+
const validFrom = parseInstant(raw["validFrom"]);
|
|
1270
|
+
const validUntil = parseInstant(raw["validUntil"]);
|
|
1271
|
+
const daily = parseDaily(raw["daily"]);
|
|
1272
|
+
const dailyUnusable = daily === void 0 && Array.isArray(raw["daily"]) && raw["daily"].length > 0;
|
|
1273
|
+
if (timezone === void 0 && validFrom === void 0 && validUntil === void 0 && daily === void 0 && !dailyUnusable) return void 0;
|
|
1274
|
+
return {
|
|
1275
|
+
...timezone === void 0 ? {} : { timezone },
|
|
1276
|
+
...validFrom === void 0 ? {} : { validFrom },
|
|
1277
|
+
...validUntil === void 0 ? {} : { validUntil },
|
|
1278
|
+
...daily === void 0 ? {} : { daily },
|
|
1279
|
+
...dailyUnusable ? { dailyUnusable: true } : {}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
/** One upstream entry → this plugin's promo shape, or undefined if it is not one. */
|
|
1283
|
+
function parsePromo(value, source) {
|
|
1284
|
+
const raw = record(value);
|
|
1285
|
+
if (raw === void 0) return void 0;
|
|
1286
|
+
if (raw["enabled"] === false) return void 0;
|
|
1287
|
+
const modelIds = parseModelIds(raw["modelIds"]);
|
|
1288
|
+
if (modelIds === void 0) return void 0;
|
|
1289
|
+
const badge = parseBadge(raw["badge"]);
|
|
1290
|
+
if (badge === void 0) return void 0;
|
|
1291
|
+
const hover = parseHover(raw["hover"]);
|
|
1292
|
+
const schedule = parseSchedule(raw["schedule"]);
|
|
1293
|
+
const priority = typeof raw["priority"] === "number" && Number.isFinite(raw["priority"]) ? raw["priority"] : 0;
|
|
1294
|
+
return {
|
|
1295
|
+
id: text(raw["id"]) ?? `${source}:${modelIds.join(",")}`,
|
|
1296
|
+
source,
|
|
1297
|
+
modelIds,
|
|
1298
|
+
badge,
|
|
1299
|
+
...hover === void 0 ? {} : { hover },
|
|
1300
|
+
...schedule === void 0 ? {} : { schedule },
|
|
1301
|
+
priority
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Parse the upstream's promotion and tier lists.
|
|
1306
|
+
*
|
|
1307
|
+
* Both lists are read from the SAME document the roster comes from, so this
|
|
1308
|
+
* never costs a request. Entries that are not promotions — no `modelIds`, no
|
|
1309
|
+
* `badge`, or `enabled: false` — are dropped here rather than downstream, so
|
|
1310
|
+
* that everything downstream can assume a promo is drawable.
|
|
1311
|
+
*
|
|
1312
|
+
* The promotions come first, so that a TIE on `priority` is broken in the
|
|
1313
|
+
* promotions' favour: a price badge ("Free now") is more actionable to a user
|
|
1314
|
+
* than an access-tier badge ("Pro Only"), and the upstream numbers them
|
|
1315
|
+
* equally (both 200 on the observed feed).
|
|
1316
|
+
*/
|
|
1317
|
+
function parseWorkBuddyPromos(rawPromotions, rawTiers) {
|
|
1318
|
+
const promos = [];
|
|
1319
|
+
for (const entry of Array.isArray(rawPromotions) ? rawPromotions : []) {
|
|
1320
|
+
const promo = parsePromo(entry, "promotion");
|
|
1321
|
+
if (promo !== void 0) promos.push(promo);
|
|
1322
|
+
}
|
|
1323
|
+
for (const entry of Array.isArray(rawTiers) ? rawTiers : []) {
|
|
1324
|
+
const tier = parsePromo(entry, "tier");
|
|
1325
|
+
if (tier !== void 0) promos.push(tier);
|
|
1326
|
+
}
|
|
1327
|
+
return promos;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Attach the promotions that name each model.
|
|
1331
|
+
*
|
|
1332
|
+
* The result is UNRESOLVED on purpose: every candidate that names the model
|
|
1333
|
+
* rides along, so the badge can be chosen at render time against the clock
|
|
1334
|
+
* (see {@link selectPromoBadge}). Resolving here would freeze the answer at
|
|
1335
|
+
* fetch time — the defect that makes a "night discount" badge keep claiming
|
|
1336
|
+
* the night after the night is over.
|
|
1337
|
+
*/
|
|
1338
|
+
function attachPromos(models, promos) {
|
|
1339
|
+
if (promos.length === 0) return [...models];
|
|
1340
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
1341
|
+
for (const promo of promos) for (const modelId of promo.modelIds) {
|
|
1342
|
+
const list = byModel.get(modelId);
|
|
1343
|
+
if (list === void 0) byModel.set(modelId, [promo]);
|
|
1344
|
+
else list.push(promo);
|
|
1345
|
+
}
|
|
1346
|
+
return models.map((model) => {
|
|
1347
|
+
const list = byModel.get(model.id);
|
|
1348
|
+
if (list === void 0) return model;
|
|
1349
|
+
return {
|
|
1350
|
+
...model,
|
|
1351
|
+
promos: [...list].sort((a, b) => b.priority - a.priority)
|
|
1352
|
+
};
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
//#endregion
|
|
1099
1356
|
//#region src/upstream.ts
|
|
1100
1357
|
/**
|
|
1101
1358
|
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
@@ -1145,55 +1402,80 @@ const GLOBAL_BASE = "https://www.workbuddy.ai";
|
|
|
1145
1402
|
* Legacy model-catalog path, kept only as the CN region's FALLBACK.
|
|
1146
1403
|
*
|
|
1147
1404
|
* 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
|
-
*
|
|
1405
|
+
* document the WorkBuddy *app* reads: the gateway answers it with the CLI
|
|
1406
|
+
* channel's roster, which carries neither the app's `auto` slot nor any
|
|
1407
|
+
* promotion feed. The free Hy4 tier that used to live here as a separate
|
|
1408
|
+
* `hy4-preview-f` record is gone as a record — measured 2026-10-21, both
|
|
1409
|
+
* channels list the paid `hy4-preview` (`x0.29`) — and the free tier is now
|
|
1410
|
+
* expressed as a promotion badge (`夜间免费`) in the app-channel document only.
|
|
1411
|
+
* So this path is strictly poorer than {@link GLOBAL_CONFIG_PATH}; it stays
|
|
1412
|
+
* only so that a gateway which stops serving the modern document degrades
|
|
1413
|
+
* instead of emptying the region.
|
|
1155
1414
|
*/
|
|
1156
1415
|
const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
1157
1416
|
/**
|
|
1158
|
-
* Remote product-config path
|
|
1159
|
-
*
|
|
1417
|
+
* Remote product-config path — the document the WorkBuddy app itself reads for
|
|
1418
|
+
* its model list, its prices AND its promotion badges.
|
|
1160
1419
|
*
|
|
1161
1420
|
* 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.
|
|
1421
|
+
* {@link parseUpstreamModel} reads, so one parser still serves both, and it
|
|
1422
|
+
* additionally carries `modelPromotions` / `modelTiers` — the promotion feed
|
|
1423
|
+
* no other path publishes (see {@link withPromos}).
|
|
1424
|
+
*
|
|
1425
|
+
* Which document this path answers depends on the CHANNEL the request claims
|
|
1426
|
+
* to be (see {@link PRODUCT_UA}); it does not depend on the host: `www.workbuddy.cn`,
|
|
1427
|
+
* `copilot.tencent.com`, `wb.tencentbuddy.com` and `www.codebuddy.cn` answer
|
|
1428
|
+
* byte-identically under the same header.
|
|
1173
1429
|
*/
|
|
1174
1430
|
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
1431
|
+
/**
|
|
1432
|
+
* The chat channel's user agent — the identity the plugin's adapter speaks as.
|
|
1433
|
+
*
|
|
1434
|
+
* Chat is a different contract from configuration: the conversation-identity
|
|
1435
|
+
* triple (conversation id, session id, device id) is bound to this channel, and
|
|
1436
|
+
* the upstream bills and rate-limits it as the CLI agent. The plugin chats as
|
|
1437
|
+
* the CLI agent on purpose, so this stays {@link CLIENT_UA} everywhere the
|
|
1438
|
+
* request is a CHAT.
|
|
1439
|
+
*/
|
|
1175
1440
|
const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
1176
1441
|
/**
|
|
1177
|
-
*
|
|
1442
|
+
* The product-config channel's user agent: the WorkBuddy APP's own product
|
|
1443
|
+
* identity, byte-for-byte the header the app sends.
|
|
1444
|
+
*
|
|
1445
|
+
* The app builds it in `buildVlmGatewayHeaders` (WorkBuddy 5.8.3) as
|
|
1446
|
+
*
|
|
1447
|
+
* `User-Agent: WorkBuddy/<app> WorkBuddy/<app> CLI/<cli>`
|
|
1178
1448
|
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
* exist at all:
|
|
1449
|
+
* — the app version twice, the CLI version once — and sends it to
|
|
1450
|
+
* `/v3/config`; the app's own log reads
|
|
1451
|
+
* `fetch cloud product from url:https://www.workbuddy.cn/v3/config success`
|
|
1452
|
+
* with `{"user-agent":"WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0", …}`.
|
|
1184
1453
|
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
* billed `x0.00`);
|
|
1189
|
-
* - desktop channel → the account's real 20-model chat roster including both.
|
|
1454
|
+
* It matters, because `/v3/config` answers a DIFFERENT product configuration
|
|
1455
|
+
* per client channel. Measured 2026-10-21, same token, same URL, only this
|
|
1456
|
+
* header differing (CN gateway):
|
|
1190
1457
|
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1458
|
+
* | User-Agent | `/v3/config` answer |
|
|
1459
|
+
* | --- | --- |
|
|
1460
|
+
* | `CLI/2.63.2 CodeBuddy/2.63.2` (CLI channel) | 5 keys, 35 models, NO promotion feed |
|
|
1461
|
+
* | `WorkBuddy/5.8.3` (app channel, no CLI part) | 18 keys, 37 models, no promotion feed |
|
|
1462
|
+
* | `WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0` | 22 keys, 62 models, 8 promos + 1 tier |
|
|
1463
|
+
*
|
|
1464
|
+
* The CLI channel's answer is a strict SUBSET of the app channel's: it
|
|
1465
|
+
* publishes neither `modelPromotions` nor `modelTiers`. That is precisely why
|
|
1466
|
+
* the CN card showed no promotion badge while the WorkBuddy app showed one on
|
|
1467
|
+
* the very same account — the plugin was reading a document the upstream
|
|
1468
|
+
* publishes WITHOUT the feed, not a feed that does not exist.
|
|
1469
|
+
*
|
|
1470
|
+
* The version suffixes are inert — `WorkBuddy/1.0.0 CLI/2.63.2
|
|
1471
|
+
* CodeBuddy/2.63.2` answers identically — so what selects the document is the
|
|
1472
|
+
* CHANNEL the header claims, not the version in it.
|
|
1473
|
+
*
|
|
1474
|
+
* Global answers the same document under either app-shaped header (17 keys,
|
|
1475
|
+
* 28 models, 5 promotions + 1 tier, identical roster), so both regions read
|
|
1476
|
+
* `/v3/config` as {@link PRODUCT_UA}.
|
|
1195
1477
|
*/
|
|
1196
|
-
const
|
|
1478
|
+
const PRODUCT_UA = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0";
|
|
1197
1479
|
const JSON_TIMEOUT_MS = 3e4;
|
|
1198
1480
|
const ERROR_BODY_LIMIT = 4096;
|
|
1199
1481
|
/**
|
|
@@ -2110,8 +2392,28 @@ function parseUpstreamModel(value) {
|
|
|
2110
2392
|
* selector serves them. Without a usable `cli` roster the whole parsed catalog
|
|
2111
2393
|
* is exposed rather than nothing: the roster is an upstream detail that may
|
|
2112
2394
|
* change, and an empty answer would silently disarm the provider.
|
|
2395
|
+
*
|
|
2396
|
+
* ROUTERS ARE NOT MODELS. The upstream lists `auto` (CN) and its global
|
|
2397
|
+
* equivalents alongside real models, but those entries are ROUTING decisions,
|
|
2398
|
+
* not models: the gateway resolves them to whichever model it thinks fits, so
|
|
2399
|
+
* a row for one would offer a name whose price, context window and behaviour
|
|
2400
|
+
* are all decided at request time by something else. Selected this way it also
|
|
2401
|
+
* duplicates a real row — upstream resolves CN `auto` to `hy4-preview`, which
|
|
2402
|
+
* is already in the same roster. They are dropped here, at the single place
|
|
2403
|
+
* the roster is built, so the card, the picker, the probe and the discovery
|
|
2404
|
+
* route all agree. A user who already had one enabled keeps it through
|
|
2405
|
+
* `keepIds` below: this removes the OFFER, never a running model (issue #32's
|
|
2406
|
+
* failure mode).
|
|
2407
|
+
*
|
|
2408
|
+
* `keepIds` is the user's own selection riding along with the refresh. The
|
|
2409
|
+
* upstream roster may drop a model the user is actually running — the app
|
|
2410
|
+
* channel's `cli` roster dropped `minimax-m2.7` on 2026-10-21 while the SAME
|
|
2411
|
+
* document still publishes it (`x0.26`) and the chat endpoint still answers for
|
|
2412
|
+
* it (measured HTTP 200). A roster read is therefore the upstream's roster PLUS
|
|
2413
|
+
* the kept ids this document still lists: a refresh may add or withdraw
|
|
2414
|
+
* upstream models, but it never silently withdraws a model the user is on.
|
|
2113
2415
|
*/
|
|
2114
|
-
function selectCliModels(rawModels, agents) {
|
|
2416
|
+
function selectCliModels(rawModels, agents, keepIds = []) {
|
|
2115
2417
|
const byId = /* @__PURE__ */ new Map();
|
|
2116
2418
|
for (const model of Array.isArray(rawModels) ? rawModels : []) {
|
|
2117
2419
|
const parsed = parseUpstreamModel(model);
|
|
@@ -2125,9 +2427,33 @@ function selectCliModels(rawModels, agents) {
|
|
|
2125
2427
|
break;
|
|
2126
2428
|
}
|
|
2127
2429
|
}
|
|
2128
|
-
const
|
|
2129
|
-
|
|
2130
|
-
|
|
2430
|
+
const ids = cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()];
|
|
2431
|
+
const models = ids.filter((id) => !isRouterModelId(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2432
|
+
const kept = keepIds.filter((id) => !ids.includes(id)).map((id) => byId.get(id)).filter((model) => model !== void 0);
|
|
2433
|
+
const resolved = [...models, ...kept];
|
|
2434
|
+
if (resolved.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
|
|
2435
|
+
return resolved;
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* The roster plus the promotion badges the SAME document carries.
|
|
2439
|
+
*
|
|
2440
|
+
* `data.modelPromotions` and `data.modelTiers` sit beside `data.models` in
|
|
2441
|
+
* the upstream's `/v3/config` document, so the badges cost no extra request:
|
|
2442
|
+
* they are read out of the response the roster was read from. Which models
|
|
2443
|
+
* they name is resolved here; WHICH badge holds at a given moment is not
|
|
2444
|
+
* (see `src/promo.ts` — a badge is a function of the clock).
|
|
2445
|
+
*
|
|
2446
|
+
* BOTH regions publish the feed, and they publish it in the app channel's
|
|
2447
|
+
* document: measured 2026-10-21 on the same accounts the app reads, CN
|
|
2448
|
+
* `/v3/config` carries 8 promotions + 1 tier badge (Chinese labels such as
|
|
2449
|
+
* `夜间折扣` / `限时免费` / `订阅优先`, with `schedule.daily` night windows),
|
|
2450
|
+
* and the global gateway carries 5 promotions + 1 tier badge. The feed is
|
|
2451
|
+
* absent only from the CLI channel's answer, which the plugin never reads a
|
|
2452
|
+
* product configuration as (see {@link PRODUCT_UA}).
|
|
2453
|
+
*/
|
|
2454
|
+
function withPromos(models, data) {
|
|
2455
|
+
const promos = parseWorkBuddyPromos(data["modelPromotions"], data["modelTiers"]);
|
|
2456
|
+
return promos.length === 0 ? models : attachPromos(models, promos);
|
|
2131
2457
|
}
|
|
2132
2458
|
/**
|
|
2133
2459
|
* Upstream HTTP client. One instance serves the whole plugin; requests take
|
|
@@ -2269,13 +2595,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2269
2595
|
/**
|
|
2270
2596
|
* Read the model directory for the credential's region.
|
|
2271
2597
|
*
|
|
2272
|
-
* Both regions
|
|
2273
|
-
* consumes,
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2598
|
+
* Both regions read `/v3/config`, the document the WorkBuddy app itself
|
|
2599
|
+
* consumes, and both read it as the APP's product identity
|
|
2600
|
+
* ({@link PRODUCT_UA}) — the header the app sends, not the CLI channel's.
|
|
2601
|
+
* That is load-bearing, not cosmetic: the CLI channel's answer is a strict
|
|
2602
|
+
* subset of the app channel's and publishes neither `modelPromotions` nor
|
|
2603
|
+
* `modelTiers`, so reading `/v3/config` as the CLI channel is what left the
|
|
2604
|
+
* CN card without a single promotion badge while the app showed one on the
|
|
2605
|
+
* same account (see {@link PRODUCT_UA}).
|
|
2279
2606
|
*
|
|
2280
2607
|
* CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
|
|
2281
2608
|
* returns a non-zero envelope, or resolves to no usable model, so a gateway
|
|
@@ -2286,8 +2613,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2286
2613
|
*
|
|
2287
2614
|
* No user-side toggle is involved: the region comes from the credential's
|
|
2288
2615
|
* `domain`.
|
|
2616
|
+
*
|
|
2617
|
+
* `keepModelIds` is the user's own selection, passed through to
|
|
2618
|
+
* {@link selectCliModels}: a model the upstream roster dropped but the SAME
|
|
2619
|
+
* document still publishes stays in the roster, so reading the directory
|
|
2620
|
+
* never withdraws a model the user is running (issue #32's failure mode,
|
|
2621
|
+
* one step upstream of it).
|
|
2289
2622
|
*/
|
|
2290
|
-
async fetchModels(credential, signal) {
|
|
2623
|
+
async fetchModels(credential, signal, keepModelIds = []) {
|
|
2291
2624
|
const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
|
|
2292
2625
|
if (regionOf(credential.domain) === "global") {
|
|
2293
2626
|
const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
|
|
@@ -2299,14 +2632,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2299
2632
|
"X-Product": "SaaS",
|
|
2300
2633
|
"X-Requested-With": "XMLHttpRequest",
|
|
2301
2634
|
"Connection": "close",
|
|
2302
|
-
"User-Agent":
|
|
2635
|
+
"User-Agent": PRODUCT_UA
|
|
2303
2636
|
},
|
|
2304
2637
|
signal: timeout
|
|
2305
2638
|
});
|
|
2306
2639
|
const envelope = await readEnvelope(response);
|
|
2307
2640
|
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
2308
2641
|
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
2309
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
2642
|
+
return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
|
|
2310
2643
|
}
|
|
2311
2644
|
const readCatalog = async (path) => {
|
|
2312
2645
|
const response = await fetch(`${chatBase(credential)}${path}`, {
|
|
@@ -2315,14 +2648,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2315
2648
|
"Accept": "application/json",
|
|
2316
2649
|
"Origin": originReferer(credential),
|
|
2317
2650
|
"Referer": `${originReferer(credential)}/`,
|
|
2318
|
-
"User-Agent":
|
|
2651
|
+
"User-Agent": PRODUCT_UA
|
|
2319
2652
|
},
|
|
2320
2653
|
signal: timeout
|
|
2321
2654
|
});
|
|
2322
2655
|
const envelope = await readEnvelope(response);
|
|
2323
2656
|
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
2324
2657
|
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
2325
|
-
return selectCliModels(data["models"], data["agents"]);
|
|
2658
|
+
return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
|
|
2326
2659
|
};
|
|
2327
2660
|
try {
|
|
2328
2661
|
return await readCatalog(GLOBAL_CONFIG_PATH);
|
|
@@ -4000,14 +4333,14 @@ var WorkBuddyCredentialStore = class {
|
|
|
4000
4333
|
* Static CLI models captured from the CN endpoint (2026-08-30). The upstream
|
|
4001
4334
|
* refresh replaces this list at startup; it exists so the provider registers
|
|
4002
4335
|
* with a usable catalog even while the first fetch is in flight or offline.
|
|
4336
|
+
*
|
|
4337
|
+
* The upstream's `auto` ROUTER is deliberately absent, matching what
|
|
4338
|
+
* `selectCliModels` now does to the live roster: it resolves to a different
|
|
4339
|
+
* model per request, so it is not a row this plugin can describe. Keeping it
|
|
4340
|
+
* here would have reintroduced it on the offline/第一帧 path only, which is the
|
|
4341
|
+
* hardest kind of inconsistency to notice.
|
|
4003
4342
|
*/
|
|
4004
4343
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
4005
|
-
{
|
|
4006
|
-
id: "auto",
|
|
4007
|
-
name: "Auto",
|
|
4008
|
-
contextWindow: 168e3,
|
|
4009
|
-
maxTokens: 32e3
|
|
4010
|
-
},
|
|
4011
4344
|
{
|
|
4012
4345
|
id: "hy3",
|
|
4013
4346
|
name: "Hy3",
|
|
@@ -4255,8 +4588,23 @@ function applyContextBudgets(catalog, budgets = {}) {
|
|
|
4255
4588
|
contextWindow: budgets[model.id] === void 0 ? model.contextWindow : Math.min(model.contextWindow, budgets[model.id])
|
|
4256
4589
|
}));
|
|
4257
4590
|
}
|
|
4591
|
+
/**
|
|
4592
|
+
* Derive the runtime catalog from the last-refreshed directory plus the user's
|
|
4593
|
+
* selection.
|
|
4594
|
+
*
|
|
4595
|
+
* An empty selection means "never configured" and serves the whole directory.
|
|
4596
|
+
* But a NON-empty selection that intersects the directory in nothing must NOT
|
|
4597
|
+
* serve nothing: that is the issue #32 failure mode (the picker empties out),
|
|
4598
|
+
* and it now has a realistic trigger — a user whose only ticked model was the
|
|
4599
|
+
* upstream's `auto` router, which this version stops offering. Such a selection
|
|
4600
|
+
* degrades to the whole directory rather than to silence: the user asked for
|
|
4601
|
+
* "some models to be on", and the alternative is a region with no models at
|
|
4602
|
+
* all. An empty directory still yields an empty result; the guard is only about
|
|
4603
|
+
* a selection that no longer matches anything.
|
|
4604
|
+
*/
|
|
4258
4605
|
function deriveCatalog(catalog, enabled, budgets = {}) {
|
|
4259
|
-
|
|
4606
|
+
const selected = enabled.size === 0 ? catalog : catalog.filter((model) => enabled.has(model.id));
|
|
4607
|
+
return applyContextBudgets(selected.length === 0 && enabled.size > 0 ? catalog : selected, budgets);
|
|
4260
4608
|
}
|
|
4261
4609
|
/**
|
|
4262
4610
|
* Mutable catalog shared by the shim's `/v1/models` and the adapter.
|
|
@@ -4515,7 +4863,7 @@ function generationOf(version) {
|
|
|
4515
4863
|
//#endregion
|
|
4516
4864
|
//#region src/version.ts
|
|
4517
4865
|
/** The npm package version this build was produced from. */
|
|
4518
|
-
const WORKBUDDY_CONNECT_VERSION = "4.
|
|
4866
|
+
const WORKBUDDY_CONNECT_VERSION = "4.14.1";
|
|
4519
4867
|
//#endregion
|
|
4520
4868
|
//#region src/host-heartbeat.ts
|
|
4521
4869
|
/**
|
|
@@ -4673,4 +5021,4 @@ async function clearHostHeartbeat() {
|
|
|
4673
5021
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
4674
5022
|
}
|
|
4675
5023
|
//#endregion
|
|
4676
|
-
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 _,
|
|
5024
|
+
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 _, hostPeersInfo as _t, readHostHeartbeat as a, deriveAtRestKey as at, WorkBuddyEncryptedCredentialError as b, writeFileAtomic as bt, WORKBUDDY_CONNECT_VERSION as c, findWorkbuddyAppExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as dt, NATIVE_MODALITY_BY_MODEL_ID as et, WorkBuddyCatalog as f, macosBundleExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, workbuddyAppExecutableCandidates as gt, fallbackModelsFor as h, readAtRestKey as ht, processStartTimeMs as i, clearAtRestKeyCache as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, findWorkbuddyAppExecutableWithSource as lt, deriveCatalog as m, openEncryptedField as mt, clearHostHeartbeat as n, nativeModalityOf as nt, workbuddyHostHeartbeatPath as o, deriveAtRestKeyId as ot, applyContextBudgets as p, macosNestedAppBundles as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, WORKBUDDY_APP_EXECUTABLE_ENV as rt, writeHostHeartbeat as s, fetchAtRestKeyPayload as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, imageDefaultFor as tt, FALLBACK_WORKBUDDY_MODELS as u, isEncryptedFieldWrapper as ut, WORKBUDDY_AUTH_FILE_ENV as v, resolveDshHome as vt, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, withFileLock as yt, knownRegionOf as z };
|