dsh-connect-workbuddy 4.13.2 → 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.
@@ -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. The gateway answers it with the CLI
1149
- * channel's roster, whose second slot is the paid `hy4-preview`
1150
- * (`credits: 'x0.29 credits'`), while the app's own config lists the free
1151
- * `hy4-preview-f` (`credits: 'x0.00 credits'`) in that slot under the SAME
1152
- * display name "Hy4 preview". Reading this path is therefore what makes the
1153
- * plugin's card disagree with the app about a model's price — both are
1154
- * correctly displaying a real record, just different ones.
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; the CN region's PRIMARY catalog source and the
1159
- * global region's only one.
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. On CN this
1163
- * path is what the app itself consumes, so the plugin's roster and the app's
1164
- * agree — that parity is the whole point of preferring it.
1165
- *
1166
- * The CLI user agent deliberately stays {@link CLIENT_UA} here. `/v3/config`
1167
- * serves a DIFFERENT roster per client channel: the desktop token yields a
1168
- * roster without the free `hy4-preview-f`, and the CLI token yields one with
1169
- * it. Measured 2026-10-04 — CN + `/v3/config`: CLI token = 17 models incl.
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
- * User agent of the WorkBuddy desktop app.
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", …}`.
1178
1388
  *
1179
- * The config service serves a DIFFERENT product configuration per client
1180
- * channel, selected by this product token — the version suffix is ignored
1181
- * (`WorkBuddy/5.5.2`, `WorkBuddy/1.0.0` and a bare `WorkBuddy` answer
1182
- * identically). On the INTERNATIONAL gateway the split decides which models
1183
- * exist at all:
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):
1184
1392
  *
1185
- * - CLI channel (`CLI/… CodeBuddy/…`) → 35 models that OMIT
1186
- * `deepseek-v4.1-flash` and `gpt-6-astra`, even though both are perfectly
1187
- * chat-usable (verified: `deepseek-v4.1-flash` streams HTTP 200 and is
1188
- * billed `x0.00`);
1189
- * - desktop channel → the account's real 20-model chat roster including both.
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 |
1190
1398
  *
1191
- * The plugin emulates the CLI channel for CHAT but reads the desktop channel's
1192
- * configuration to learn the account's actual model list. The CN gateway needs
1193
- * no such switch: its desktop config carries no `cli` agent roster at all, so
1194
- * CN keeps reading the shared `/v2/enterprises/personal/models` path.
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.
1404
+ *
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.
1408
+ *
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 DESKTOP_UA = "WorkBuddy/5.5.2";
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
  /**
@@ -2110,8 +2327,16 @@ function parseUpstreamModel(value) {
2110
2327
  * selector serves them. Without a usable `cli` roster the whole parsed catalog
2111
2328
  * is exposed rather than nothing: the roster is an upstream detail that may
2112
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.
2113
2338
  */
2114
- function selectCliModels(rawModels, agents) {
2339
+ function selectCliModels(rawModels, agents, keepIds = []) {
2115
2340
  const byId = /* @__PURE__ */ new Map();
2116
2341
  for (const model of Array.isArray(rawModels) ? rawModels : []) {
2117
2342
  const parsed = parseUpstreamModel(model);
@@ -2125,9 +2350,32 @@ function selectCliModels(rawModels, agents) {
2125
2350
  break;
2126
2351
  }
2127
2352
  }
2128
- const models = (cliIds !== void 0 && cliIds.length > 0 ? cliIds : [...byId.keys()]).map((id) => byId.get(id)).filter((model) => model !== void 0);
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);
2129
2355
  if (models.length === 0) throw new Error("workbuddy model catalog resolved to an empty list");
2130
- return models;
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);
2131
2379
  }
2132
2380
  /**
2133
2381
  * Upstream HTTP client. One instance serves the whole plugin; requests take
@@ -2269,13 +2517,14 @@ var WorkBuddyUpstreamClient = class {
2269
2517
  /**
2270
2518
  * Read the model directory for the credential's region.
2271
2519
  *
2272
- * Both regions prefer `/v3/config`, the document the WorkBuddy app itself
2273
- * consumes, so the plugin's roster and the app's agree. They differ in the
2274
- * user agent that requests it, and that difference is load-bearing: `/v3`
2275
- * serves a different roster per client channel. Global asks as the desktop
2276
- * channel (see {@link DESKTOP_UA}); CN asks as the CLI channel
2277
- * ({@link CLIENT_UA}), which is the only CN channel whose `/v3` answer
2278
- * contains the free `hy4-preview-f` (see {@link GLOBAL_CONFIG_PATH}).
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}).
2279
2528
  *
2280
2529
  * CN falls back to the legacy {@link MODELS_CATALOG_PATH} when `/v3` fails,
2281
2530
  * returns a non-zero envelope, or resolves to no usable model, so a gateway
@@ -2286,8 +2535,14 @@ var WorkBuddyUpstreamClient = class {
2286
2535
  *
2287
2536
  * No user-side toggle is involved: the region comes from the credential's
2288
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).
2289
2544
  */
2290
- async fetchModels(credential, signal) {
2545
+ async fetchModels(credential, signal, keepModelIds = []) {
2291
2546
  const timeout = signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS);
2292
2547
  if (regionOf(credential.domain) === "global") {
2293
2548
  const response = await fetch(`${globalBase(credential.domain)}${GLOBAL_CONFIG_PATH}`, {
@@ -2299,14 +2554,14 @@ var WorkBuddyUpstreamClient = class {
2299
2554
  "X-Product": "SaaS",
2300
2555
  "X-Requested-With": "XMLHttpRequest",
2301
2556
  "Connection": "close",
2302
- "User-Agent": DESKTOP_UA
2557
+ "User-Agent": PRODUCT_UA
2303
2558
  },
2304
2559
  signal: timeout
2305
2560
  });
2306
2561
  const envelope = await readEnvelope(response);
2307
2562
  if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2308
2563
  const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
2309
- return selectCliModels(data["models"], data["agents"]);
2564
+ return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
2310
2565
  }
2311
2566
  const readCatalog = async (path) => {
2312
2567
  const response = await fetch(`${chatBase(credential)}${path}`, {
@@ -2315,14 +2570,14 @@ var WorkBuddyUpstreamClient = class {
2315
2570
  "Accept": "application/json",
2316
2571
  "Origin": originReferer(credential),
2317
2572
  "Referer": `${originReferer(credential)}/`,
2318
- "User-Agent": CLIENT_UA
2573
+ "User-Agent": PRODUCT_UA
2319
2574
  },
2320
2575
  signal: timeout
2321
2576
  });
2322
2577
  const envelope = await readEnvelope(response);
2323
2578
  if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
2324
2579
  const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
2325
- return selectCliModels(data["models"], data["agents"]);
2580
+ return withPromos(selectCliModels(data["models"], data["agents"], keepModelIds), data);
2326
2581
  };
2327
2582
  try {
2328
2583
  return await readCatalog(GLOBAL_CONFIG_PATH);
@@ -4515,7 +4770,7 @@ function generationOf(version) {
4515
4770
  //#endregion
4516
4771
  //#region src/version.ts
4517
4772
  /** The npm package version this build was produced from. */
4518
- const WORKBUDDY_CONNECT_VERSION = "4.13.2";
4773
+ const WORKBUDDY_CONNECT_VERSION = "4.14.0";
4519
4774
  //#endregion
4520
4775
  //#region src/host-heartbeat.ts
4521
4776
  /**