dsh-connect-workbuddy 4.14.1 → 5.0.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 +183 -0
- package/README.en.md +22 -4
- package/README.md +22 -4
- package/lib/bin.js +1 -1
- package/lib/client.js +463 -109
- package/lib/{host-heartbeat-D1WnqQY2.js → host-heartbeat-D313ENPV.js} +952 -53
- package/lib/index.d.ts +182 -1
- package/lib/index.js +434 -10
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +3 -1
|
@@ -1026,6 +1026,269 @@ function imageDefaultFor(info) {
|
|
|
1026
1026
|
if (info.supportsImages === false) return false;
|
|
1027
1027
|
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
1028
1028
|
}
|
|
1029
|
+
/** Everything the app treats as a queue condition (`isQueueError`). */
|
|
1030
|
+
const WORKBUDDY_QUEUE_CODES = [
|
|
1031
|
+
6020,
|
|
1032
|
+
6021,
|
|
1033
|
+
6022
|
|
1034
|
+
];
|
|
1035
|
+
/** The app's default when the upstream sends no `retry_after` (`?? 5`). */
|
|
1036
|
+
const WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC = 5;
|
|
1037
|
+
/** True when a business code is one of the app's three queue codes. */
|
|
1038
|
+
function isQueueCode(value) {
|
|
1039
|
+
return typeof value === "number" && WORKBUDDY_QUEUE_CODES.includes(value);
|
|
1040
|
+
}
|
|
1041
|
+
/** Read one queue condition out of a `data`-shaped object, or `undefined`. */
|
|
1042
|
+
function reportFrom(code, data) {
|
|
1043
|
+
if (!isQueueCode(code)) return void 0;
|
|
1044
|
+
const body = typeof data === "object" && data !== null ? data : {};
|
|
1045
|
+
const numberField = (key) => typeof body[key] === "number" && Number.isFinite(body[key]) ? body[key] : void 0;
|
|
1046
|
+
const position = numberField("queue_position");
|
|
1047
|
+
const size = numberField("queue_size");
|
|
1048
|
+
const retryAfter = numberField("retry_after");
|
|
1049
|
+
return {
|
|
1050
|
+
code,
|
|
1051
|
+
...position === void 0 ? {} : { position },
|
|
1052
|
+
...size === void 0 ? {} : { size },
|
|
1053
|
+
retryAfterSec: retryAfter !== void 0 && retryAfter > 0 ? retryAfter : WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Read a queue condition out of one upstream body.
|
|
1058
|
+
*
|
|
1059
|
+
* Three shapes are accepted, because the gateway wraps the same fact three ways
|
|
1060
|
+
* depending on which channel answered:
|
|
1061
|
+
*
|
|
1062
|
+
* 1. the chat endpoint's own error envelope — `{"code":6020,"data":{…}}`;
|
|
1063
|
+
* 2. the app's agent-protocol envelope, where the condition rides as a JSON
|
|
1064
|
+
* STRING inside `_meta['codebuddy.ai/errorMessage']`
|
|
1065
|
+
* (`{"code":6020,"data":{…},"message":"…"}` — see the app's
|
|
1066
|
+
* `extractQueueInfoFromPromptResult`);
|
|
1067
|
+
* 3. a nested `data.code`, for a gateway that wraps one envelope in another.
|
|
1068
|
+
*
|
|
1069
|
+
* Anything else — a 14003 throttle, a quota refusal, a 500 — is NOT a queue and
|
|
1070
|
+
* returns `undefined`, so this can sit in the error path without changing it.
|
|
1071
|
+
*/
|
|
1072
|
+
function parseQueueAnswer(text) {
|
|
1073
|
+
let parsed;
|
|
1074
|
+
try {
|
|
1075
|
+
parsed = JSON.parse(text);
|
|
1076
|
+
} catch {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const direct = readEnvelope$1(parsed);
|
|
1080
|
+
if (direct !== void 0) return direct;
|
|
1081
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1082
|
+
const meta = parsed["_meta"];
|
|
1083
|
+
if (typeof meta === "object" && meta !== null) {
|
|
1084
|
+
const message = meta["codebuddy.ai/errorMessage"];
|
|
1085
|
+
if (typeof message === "string") try {
|
|
1086
|
+
return readEnvelope$1(JSON.parse(message));
|
|
1087
|
+
} catch {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/** `{code, data}` at the top level, or one `data` level down. */
|
|
1094
|
+
function readEnvelope$1(value) {
|
|
1095
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1096
|
+
const envelope = value;
|
|
1097
|
+
const top = reportFrom(envelope["code"], envelope["data"]);
|
|
1098
|
+
if (top !== void 0) return top;
|
|
1099
|
+
const data = envelope["data"];
|
|
1100
|
+
if (typeof data === "object" && data !== null) {
|
|
1101
|
+
const inner = data;
|
|
1102
|
+
return reportFrom(inner["code"], inner["data"] ?? inner);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The sentence a queue condition is reported with — the app's own vocabulary
|
|
1107
|
+
* ("排队", "位"), and never the upstream's untranslated `message` key.
|
|
1108
|
+
*
|
|
1109
|
+
* `waiting` names both numbers because they answer different questions: the
|
|
1110
|
+
* POSITION is how long the wait is, the SIZE is whether it is moving at all.
|
|
1111
|
+
*/
|
|
1112
|
+
function queueNoticeText(report) {
|
|
1113
|
+
if (report.code === 6020) return `模型排队中:你排在第 ${report.position === void 0 ? "未知" : `${report.position}`} 位${report.size === void 0 ? "" : `,队列共 ${report.size} 个请求`};约 ${report.retryAfterSec} 秒后自动重试`;
|
|
1114
|
+
if (report.code === 6021) return `模型队列已满${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},现在无法排队;请稍后重试或换一个模型`;
|
|
1115
|
+
return `你的排队任务已达上限${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},请等待已排队请求完成后再试`;
|
|
1116
|
+
}
|
|
1117
|
+
//#endregion
|
|
1118
|
+
//#region src/image-gen.ts
|
|
1119
|
+
/**
|
|
1120
|
+
* WorkBuddy 生图的纯逻辑层:媒体模型筛选、请求体构造、响应解析、失败描述。
|
|
1121
|
+
*
|
|
1122
|
+
* 这一层刻意不做任何网络 I/O、不引用宿主的任何包。`src/upstream.ts` 会被
|
|
1123
|
+
* `src/bin.ts` 静态可达(见 tests/host-peers.spec.ts 对 lib/bin.js 可达图的断言:
|
|
1124
|
+
* 除 `node:` 外不得出现任何裸说明符),所以任何被它引用的模块都必须保持零外部依赖。
|
|
1125
|
+
*
|
|
1126
|
+
* 上游契约照抄官方 CLI `codebuddy-lite-wb.mjs` 的 ImageService(实测 2026-10-21,
|
|
1127
|
+
* 国内区 `copilot.tencent.com` 与 `www.workbuddy.cn` 两个入口逐字段同答):
|
|
1128
|
+
*
|
|
1129
|
+
* POST {endpoint}/v2/images/generations
|
|
1130
|
+
* body = { model, prompt, size, ...按 model 前缀分支的字段 }
|
|
1131
|
+
* resp = { code, msg, request_id?, data: { created, data: [{ url } | { b64_json }],
|
|
1132
|
+
* usage: { credit } } }
|
|
1133
|
+
*
|
|
1134
|
+
* 前缀分支(`buildBaseRequestBody` 原文行为):
|
|
1135
|
+
* - `hunyuan-*`:额外只带 `n`(默认 1)/ `footnote` / `revise`
|
|
1136
|
+
* - `gemini-*` :什么都不加
|
|
1137
|
+
* - 其它 :`response_format: 'b64_json'` + 可选 `n` / `quality` / `style` / `background`
|
|
1138
|
+
*
|
|
1139
|
+
* 国内区产品配置里带 `text-to-image` 标签的只有 `hunyuan-image-alpha`,
|
|
1140
|
+
* 带 `image-to-image` 的只有 `hunyuan-image-alpha-edit`,因此实际走的就是
|
|
1141
|
+
* hunyuan 分支、响应里是 COS 签名 URL(24h 有效),单张约 5.71 credits。
|
|
1142
|
+
*
|
|
1143
|
+
* @module dsh-connect-workbuddy/image-gen
|
|
1144
|
+
*/
|
|
1145
|
+
/** 产品配置里「文生图」模型的标签。 */
|
|
1146
|
+
const IMAGE_TAG_TEXT_TO_IMAGE = "text-to-image";
|
|
1147
|
+
/** 官方 CLI 的默认尺寸。 */
|
|
1148
|
+
const DEFAULT_IMAGE_SIZE = "1024x1024";
|
|
1149
|
+
/** 生图路由。 */
|
|
1150
|
+
const IMAGE_GENERATIONS_PATH = "/v2/images/generations";
|
|
1151
|
+
/** 生图链路的错误。带一个稳定的 `code`,避免调用方去匹配文案。 */
|
|
1152
|
+
var WorkBuddyImageError = class extends Error {
|
|
1153
|
+
code;
|
|
1154
|
+
constructor(message, code) {
|
|
1155
|
+
super(message);
|
|
1156
|
+
this.name = "WorkBuddyImageError";
|
|
1157
|
+
this.code = code;
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
function asString(value) {
|
|
1161
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
1162
|
+
}
|
|
1163
|
+
function stringList(value) {
|
|
1164
|
+
if (!Array.isArray(value)) return [];
|
|
1165
|
+
const out = [];
|
|
1166
|
+
for (const item of value) {
|
|
1167
|
+
const text = asString(item);
|
|
1168
|
+
if (text !== void 0) out.push(text);
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* 从产品配置的 `data.models` 里挑出带指定 tag 的媒体模型。
|
|
1174
|
+
*
|
|
1175
|
+
* 去重按 `id`:上游目录里同一个 id 只应出现一次,但目录是外部数据,
|
|
1176
|
+
* 重复条目不该让卡片或工具参数里出现两个同名选项。
|
|
1177
|
+
*/
|
|
1178
|
+
function selectMediaModels(rawModels, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
|
|
1179
|
+
if (!Array.isArray(rawModels)) return [];
|
|
1180
|
+
const out = [];
|
|
1181
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1182
|
+
for (const entry of rawModels) {
|
|
1183
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1184
|
+
const record = entry;
|
|
1185
|
+
const id = asString(record["id"]);
|
|
1186
|
+
if (id === void 0 || seen.has(id)) continue;
|
|
1187
|
+
const tags = stringList(record["tags"]);
|
|
1188
|
+
if (!tags.includes(tag)) continue;
|
|
1189
|
+
seen.add(id);
|
|
1190
|
+
out.push({
|
|
1191
|
+
id,
|
|
1192
|
+
name: asString(record["name"]) ?? id,
|
|
1193
|
+
tags
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
return out;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* 选出生图模型:优先用调用方点名的那个,否则用目录里的第一个。
|
|
1200
|
+
*
|
|
1201
|
+
* 点名的模型必须在目录里——不在目录里就直接报错,而不是把它发上去让上游
|
|
1202
|
+
* 回一个「route config not found」。这样报错信息能指出真正的问题(模型名写错
|
|
1203
|
+
* 或该区没有这个模型),而不是一句上游 404。
|
|
1204
|
+
*/
|
|
1205
|
+
function pickImageModel(models, requested) {
|
|
1206
|
+
if (requested !== void 0 && requested !== "") {
|
|
1207
|
+
const found = models.find((model) => model.id === requested);
|
|
1208
|
+
if (found === void 0) {
|
|
1209
|
+
const known = models.map((model) => model.id).join(", ");
|
|
1210
|
+
throw new WorkBuddyImageError(`workbuddy image: 目录里没有生图模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
|
|
1211
|
+
}
|
|
1212
|
+
return found;
|
|
1213
|
+
}
|
|
1214
|
+
const first = models[0];
|
|
1215
|
+
if (first === void 0) throw new WorkBuddyImageError("workbuddy image: 该区账号的产品配置里没有任何带 text-to-image 标签的模型。国内区应有 hunyuan-image-alpha;国际区当前没有生图模型。", "NO_IMAGE_MODEL");
|
|
1216
|
+
return first;
|
|
1217
|
+
}
|
|
1218
|
+
/** 按官方 CLI 的 `buildBaseRequestBody` 分支规则拼请求体。 */
|
|
1219
|
+
function buildImageBody(request) {
|
|
1220
|
+
const model = request.model;
|
|
1221
|
+
const body = {
|
|
1222
|
+
model,
|
|
1223
|
+
prompt: request.prompt,
|
|
1224
|
+
size: request.size ?? "1024x1024"
|
|
1225
|
+
};
|
|
1226
|
+
if (model.startsWith("hunyuan-")) {
|
|
1227
|
+
body["n"] = request.n ?? 1;
|
|
1228
|
+
return body;
|
|
1229
|
+
}
|
|
1230
|
+
if (model.startsWith("gemini-")) return body;
|
|
1231
|
+
body["response_format"] = "b64_json";
|
|
1232
|
+
if (request.n !== void 0) body["n"] = request.n;
|
|
1233
|
+
return body;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* 解析生图响应。
|
|
1237
|
+
*
|
|
1238
|
+
* 信封是 `{code, msg, request_id, data}`;`code !== 0` 是业务失败(与 HTTP 状态
|
|
1239
|
+
* 无关,上游可能 HTTP 200 但 code 非 0)。`data.data` 是数组,元素要么带 `url`
|
|
1240
|
+
* 要么带 `b64_json`;`data.usage.credit` 是本次实际扣的积分。
|
|
1241
|
+
*/
|
|
1242
|
+
function parseImageResponse(payload) {
|
|
1243
|
+
if (typeof payload !== "object" || payload === null) throw new WorkBuddyImageError("workbuddy image: 上游返回的不是 JSON 对象", "UPSTREAM");
|
|
1244
|
+
const envelope = payload;
|
|
1245
|
+
const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
|
|
1246
|
+
if (code !== void 0 && code !== 0) {
|
|
1247
|
+
const message = asString(envelope["msg"]) ?? "unknown error";
|
|
1248
|
+
const requestId = asString(envelope["request_id"]);
|
|
1249
|
+
throw new WorkBuddyImageError(`workbuddy image: 上游拒绝(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
|
|
1250
|
+
}
|
|
1251
|
+
const data = envelope["data"];
|
|
1252
|
+
if (typeof data !== "object" || data === null) throw new WorkBuddyImageError("workbuddy image: 响应缺少 data 字段", "UPSTREAM");
|
|
1253
|
+
const record = data;
|
|
1254
|
+
const items = Array.isArray(record["data"]) ? record["data"] : [];
|
|
1255
|
+
const first = items.length > 0 ? items[0] : void 0;
|
|
1256
|
+
let url;
|
|
1257
|
+
let b64;
|
|
1258
|
+
if (typeof first === "object" && first !== null) {
|
|
1259
|
+
const item = first;
|
|
1260
|
+
url = asString(item["url"]);
|
|
1261
|
+
b64 = asString(item["b64_json"]);
|
|
1262
|
+
}
|
|
1263
|
+
if (url === void 0 && b64 === void 0) throw new WorkBuddyImageError("workbuddy image: 上游返回成功但 data.data 里既没有 url 也没有 b64_json", "EMPTY_RESULT");
|
|
1264
|
+
const usage = typeof record["usage"] === "object" && record["usage"] !== null ? record["usage"] : {};
|
|
1265
|
+
const credit = typeof usage["credit"] === "number" && Number.isFinite(usage["credit"]) ? usage["credit"] : void 0;
|
|
1266
|
+
const created = typeof record["created"] === "number" ? record["created"] : void 0;
|
|
1267
|
+
return {
|
|
1268
|
+
...url === void 0 ? {} : { url },
|
|
1269
|
+
...b64 === void 0 ? {} : { b64 },
|
|
1270
|
+
...created === void 0 ? {} : { created },
|
|
1271
|
+
...credit === void 0 ? {} : { credit }
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
/** 把一次非 2xx 的响应翻成带 `code` 的错误,好让上层决定怎么提示。 */
|
|
1275
|
+
function describeImageFailure(status, bodyText) {
|
|
1276
|
+
const text = bodyText.trim();
|
|
1277
|
+
let detail = text.slice(0, 240);
|
|
1278
|
+
if (text.startsWith("{")) try {
|
|
1279
|
+
const parsed = JSON.parse(text);
|
|
1280
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1281
|
+
const record = parsed;
|
|
1282
|
+
const message = asString(record["msg"]);
|
|
1283
|
+
const code = typeof record["code"] === "number" ? record["code"] : void 0;
|
|
1284
|
+
if (message !== void 0) detail = code === void 0 ? message : `code ${code}: ${message}`;
|
|
1285
|
+
}
|
|
1286
|
+
} catch {}
|
|
1287
|
+
if (status === 401 || status === 403) return new WorkBuddyImageError(`workbuddy image: 凭据被上游拒绝(HTTP ${status}),请在插件设置里重新登录 WorkBuddy。${detail === "" ? "" : ` ${detail}`}`, "AUTH");
|
|
1288
|
+
if (status === 429) return new WorkBuddyImageError(`workbuddy image: 触发上游限流(HTTP 429),稍后再试。${detail === "" ? "" : ` ${detail}`}`, "RATE_LIMIT");
|
|
1289
|
+
if (status === 404) return new WorkBuddyImageError(`workbuddy image: 上游没有这条生图路由(HTTP 404)。全球区没有生图模型,请用国内区账号。${detail === "" ? "" : ` ${detail}`}`, "NO_ROUTE");
|
|
1290
|
+
return new WorkBuddyImageError(`workbuddy image: 上游失败(HTTP ${status})${detail === "" ? "" : `: ${detail}`}`, "UPSTREAM");
|
|
1291
|
+
}
|
|
1029
1292
|
//#endregion
|
|
1030
1293
|
//#region src/off-thinking.ts
|
|
1031
1294
|
/**
|
|
@@ -1353,6 +1616,126 @@ function attachPromos(models, promos) {
|
|
|
1353
1616
|
});
|
|
1354
1617
|
}
|
|
1355
1618
|
//#endregion
|
|
1619
|
+
//#region src/busy.ts
|
|
1620
|
+
/** The upstream's enum, in the order its own bundle declares it. */
|
|
1621
|
+
const WORKBUDDY_BUSY_LEVELS = [
|
|
1622
|
+
"idle",
|
|
1623
|
+
"normal",
|
|
1624
|
+
"busy",
|
|
1625
|
+
"congested",
|
|
1626
|
+
"unavailable",
|
|
1627
|
+
"unknown"
|
|
1628
|
+
];
|
|
1629
|
+
/** Coerce one `busy_level` value; everything the enum does not name is `unknown`. */
|
|
1630
|
+
function normalizeBusyLevel(value) {
|
|
1631
|
+
return typeof value === "string" && WORKBUDDY_BUSY_LEVELS.includes(value) ? value : "unknown";
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Read one probe answer into a model-id → level map.
|
|
1635
|
+
*
|
|
1636
|
+
* Tolerant by design: an entry that is not an object, or that names no model, is
|
|
1637
|
+
* skipped; a level the enum does not know becomes `unknown` rather than being
|
|
1638
|
+
* dropped, because "the upstream answered about this model but said something
|
|
1639
|
+
* we do not understand" is still a fact about that model.
|
|
1640
|
+
*/
|
|
1641
|
+
function parseBusyLevels(entries) {
|
|
1642
|
+
const levels = /* @__PURE__ */ new Map();
|
|
1643
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
1644
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1645
|
+
const raw = entry;
|
|
1646
|
+
const id = typeof raw["model"] === "string" ? raw["model"] : "";
|
|
1647
|
+
if (id === "") continue;
|
|
1648
|
+
levels.set(id, normalizeBusyLevel(raw["busy_level"]));
|
|
1649
|
+
}
|
|
1650
|
+
return levels;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Split ids into probe-sized batches, de-duplicated and order-preserving.
|
|
1654
|
+
*
|
|
1655
|
+
* De-duplication matters: the roster the plugin sends is the `cli` agent's
|
|
1656
|
+
* roster PLUS the user's kept ids, so the same id can legitimately appear twice,
|
|
1657
|
+
* and the upstream counts what it is asked for.
|
|
1658
|
+
*/
|
|
1659
|
+
function batchModelIds(ids, size = 50) {
|
|
1660
|
+
const unique = [...new Set(ids.filter((id) => id !== ""))];
|
|
1661
|
+
const batches = [];
|
|
1662
|
+
for (let index = 0; index < unique.length; index += size) batches.push(unique.slice(index, index + size));
|
|
1663
|
+
return batches;
|
|
1664
|
+
}
|
|
1665
|
+
/** The app's own Chinese wording, per level. `normal` and `unknown` have none. */
|
|
1666
|
+
const BUSY_LABELS_ZH = {
|
|
1667
|
+
idle: "空闲",
|
|
1668
|
+
busy: "轻微拥堵",
|
|
1669
|
+
congested: "极度拥堵",
|
|
1670
|
+
unavailable: "不可用"
|
|
1671
|
+
};
|
|
1672
|
+
/** The app's own English wording, per level. */
|
|
1673
|
+
const BUSY_LABELS_EN = {
|
|
1674
|
+
idle: "Idle",
|
|
1675
|
+
busy: "Slightly congested",
|
|
1676
|
+
congested: "Extremely congested",
|
|
1677
|
+
unavailable: "Unavailable"
|
|
1678
|
+
};
|
|
1679
|
+
/**
|
|
1680
|
+
* The word this level is drawn with, or `undefined` when it is not worth drawing.
|
|
1681
|
+
*
|
|
1682
|
+
* `normal` returns `undefined` on purpose: the app's selector shows a busy line
|
|
1683
|
+
* only for a level that differs from the healthy default, and a badge that reads
|
|
1684
|
+
* "正常" on fifteen rows out of twenty is noise, not information. `unknown`
|
|
1685
|
+
* returns `undefined` for the same reason plus one more — the plugin must not
|
|
1686
|
+
* invent a claim the upstream never made.
|
|
1687
|
+
*/
|
|
1688
|
+
function busyLabel(level, locale = "zh") {
|
|
1689
|
+
return (locale === "en" ? BUSY_LABELS_EN : BUSY_LABELS_ZH)[level];
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Suffix appended to a model's DSH-facing display name, prefixed with a middle
|
|
1693
|
+
* dot, or `''` when the level is not worth drawing.
|
|
1694
|
+
*
|
|
1695
|
+
* This is the "badge to the RIGHT of the model name" the DSH model selector
|
|
1696
|
+
* already does for its own variants (`deepseek-v4.1-flash · 1017`): DSH renders
|
|
1697
|
+
* `name` verbatim and offers no separate badge slot to a provider, so the only
|
|
1698
|
+
* way to put load beside the name — instead of in a second line, or inside the
|
|
1699
|
+
* name — is to extend the name. The name stays display-only: every DSH-side join
|
|
1700
|
+
* keys on the model ID, and `workBuddyDisplayName` is the single place that
|
|
1701
|
+
* builds it.
|
|
1702
|
+
*
|
|
1703
|
+
* ONLY A LEVEL THAT ASKS FOR A DECISION IS DRAWN — `busy`, `congested`,
|
|
1704
|
+
* `unavailable`. `idle` deliberately gets no suffix here even though it has a
|
|
1705
|
+
* word: on the measured CN roster seven of twenty models are idle, and a picker
|
|
1706
|
+
* whose every row carries a badge reads as decoration rather than as a warning.
|
|
1707
|
+
* That is also how the app's own selector behaves in its compact mode, where only
|
|
1708
|
+
* `congested` is drawn at all. The card's detail panel is where the full scale
|
|
1709
|
+
* belongs.
|
|
1710
|
+
*/
|
|
1711
|
+
function busyDisplaySuffix(level, locale = "zh") {
|
|
1712
|
+
if (level === void 0 || !DRAWN_IN_PICKER.has(level)) return "";
|
|
1713
|
+
const label = busyLabel(level, locale);
|
|
1714
|
+
return label === void 0 ? "" : ` · ${label}`;
|
|
1715
|
+
}
|
|
1716
|
+
/** The levels that change a model-picking decision; see {@link busyDisplaySuffix}. */
|
|
1717
|
+
const DRAWN_IN_PICKER = /* @__PURE__ */ new Set([
|
|
1718
|
+
"busy",
|
|
1719
|
+
"congested",
|
|
1720
|
+
"unavailable"
|
|
1721
|
+
]);
|
|
1722
|
+
/**
|
|
1723
|
+
* Attach each model's level to its catalog entry.
|
|
1724
|
+
*
|
|
1725
|
+
* A model the probe did not answer for keeps whatever it had — which, for a
|
|
1726
|
+
* freshly discovered roster, is nothing. Models are copied, never mutated: the
|
|
1727
|
+
* catalog entry is shared with the settings card's saved snapshot.
|
|
1728
|
+
*/
|
|
1729
|
+
function attachBusyLevels(models, levels) {
|
|
1730
|
+
return models.map((model) => {
|
|
1731
|
+
const level = levels.get(model.id);
|
|
1732
|
+
return level === void 0 || level === model.busyLevel ? model : {
|
|
1733
|
+
...model,
|
|
1734
|
+
busyLevel: level
|
|
1735
|
+
};
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
//#endregion
|
|
1356
1739
|
//#region src/upstream.ts
|
|
1357
1740
|
/**
|
|
1358
1741
|
* WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
|
|
@@ -1429,6 +1812,22 @@ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
|
|
|
1429
1812
|
*/
|
|
1430
1813
|
const GLOBAL_CONFIG_PATH = "/v3/config";
|
|
1431
1814
|
/**
|
|
1815
|
+
* The app's model-load probe (see `src/busy.ts`), read-only and free.
|
|
1816
|
+
*
|
|
1817
|
+
* Not part of any catalog document: this endpoint answers ONE fact per model id
|
|
1818
|
+
* — how busy it is right now — and answers it for up to 50 ids at a time. It is
|
|
1819
|
+
* host- and identity-insensitive on the CN side (measured 2026-10-21 on all
|
|
1820
|
+
* three CN hosts, under both the CLI and the app user agent: byte-identical),
|
|
1821
|
+
* so it is asked as the chat channel's own identity.
|
|
1822
|
+
*/
|
|
1823
|
+
const MODEL_BUSY_PATH = "/v2/chat/busy";
|
|
1824
|
+
/**
|
|
1825
|
+
* The app's own ceiling for one probe (`timeout: 5e3` in its
|
|
1826
|
+
* `useModelBusyLevels`). Short on purpose: a slow load probe is worth less than
|
|
1827
|
+
* the refresh it is attached to, and the catalog must not wait on it.
|
|
1828
|
+
*/
|
|
1829
|
+
const MODEL_BUSY_TIMEOUT_MS = 5e3;
|
|
1830
|
+
/**
|
|
1432
1831
|
* The chat channel's user agent — the identity the plugin's adapter speaks as.
|
|
1433
1832
|
*
|
|
1434
1833
|
* Chat is a different contract from configuration: the conversation-identity
|
|
@@ -1492,6 +1891,15 @@ const ERROR_BODY_LIMIT = 4096;
|
|
|
1492
1891
|
*/
|
|
1493
1892
|
const PROBE_TIMEOUT_MS = 1e4;
|
|
1494
1893
|
/**
|
|
1894
|
+
* 生图请求的超时。
|
|
1895
|
+
*
|
|
1896
|
+
* 实测一张 1024x1024 的图在官方网关要 17–39 秒(`hunyuan-image-alpha`,
|
|
1897
|
+
* 2026-10-21,两个国内入口各测一次),比任何 JSON 接口都慢一个数量级,
|
|
1898
|
+
* 因此不能复用 {@link JSON_TIMEOUT_MS}。三分钟足够覆盖一次正常的排队+推理,
|
|
1899
|
+
* 又不至于让一个卡死的连接占住工具槽位太久。
|
|
1900
|
+
*/
|
|
1901
|
+
const IMAGE_TIMEOUT_MS = 18e4;
|
|
1902
|
+
/**
|
|
1495
1903
|
* Insufficient-credit markers.
|
|
1496
1904
|
*
|
|
1497
1905
|
* ENGLISH IS MATCHED CASE-INSENSITIVELY (see {@link classifyUpstreamError}) so
|
|
@@ -1598,8 +2006,15 @@ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
|
|
|
1598
2006
|
* be read as the prompt rule: a confidently wrong diagnosis is worse here than
|
|
1599
2007
|
* the generic `client` reply, because it sends the user to rewrite a system
|
|
1600
2008
|
* prompt that is not at fault.
|
|
2009
|
+
*
|
|
2010
|
+
* Each is also exported as its own predicate ({@link isConversationIdGateBody},
|
|
2011
|
+
* {@link isFirstMessageGateBody}) so the reply can NAME the gate instead of
|
|
2012
|
+
* relaying the upstream's `displayTips` — which is the SAME misleading
|
|
2013
|
+
* sentence for all three members of the `11128` family.
|
|
1601
2014
|
*/
|
|
1602
|
-
const
|
|
2015
|
+
const CONVERSATION_ID_GATE_MARKER = "conversation id missing";
|
|
2016
|
+
const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
|
|
2017
|
+
const OTHER_CHANNEL_CODE_GATES = [CONVERSATION_ID_GATE_MARKER, FIRST_MESSAGE_GATE_MARKER];
|
|
1603
2018
|
/**
|
|
1604
2019
|
* Whether a failure body is the MODEL-scoped throttle rather than an account
|
|
1605
2020
|
* limit.
|
|
@@ -1637,52 +2052,57 @@ function isChannelRejectBody(body) {
|
|
|
1637
2052
|
return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
|
|
1638
2053
|
}
|
|
1639
2054
|
/**
|
|
1640
|
-
*
|
|
2055
|
+
* Whether a failure body is the CONVERSATION-ID gate (`11128`).
|
|
1641
2056
|
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
2057
|
+
* One of the two gates that share the channel gate's business code, and one
|
|
2058
|
+
* of the two this plugin's diagnosis EXCLUDES: the prompt is not at fault
|
|
2059
|
+
* here, the request headers are — which is why it must never be reported as
|
|
2060
|
+
* "the gateway refused your system prompt".
|
|
1646
2061
|
*
|
|
1647
|
-
*
|
|
1648
|
-
*
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
2062
|
+
* It is exported anyway, so a reply can NAME it. The upstream's `displayTips`
|
|
2063
|
+
* for all three `11128` gates is the identical sentence ("这次调用没通过渠道
|
|
2064
|
+
* 校验,可能不是官方客户端发出的"), so the tips cannot tell them apart — only
|
|
2065
|
+
* `msg` can. Hence the wording is the criterion and the code is the
|
|
2066
|
+
* corroboration, never the other way round.
|
|
1652
2067
|
*
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
2068
|
+
* BOTH halves are required, and the code is matched in its quoted-key form
|
|
2069
|
+
* (`"code":11128`): a request id is hexadecimal and can contain the digits
|
|
2070
|
+
* `11128` by coincidence, and a bare substring search over an arbitrary
|
|
2071
|
+
* failure body is how a red herring gets promoted into a diagnosis.
|
|
1655
2072
|
*/
|
|
1656
|
-
function
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
parsed = JSON.parse(bodyJson);
|
|
1660
|
-
} catch {
|
|
1661
|
-
return [];
|
|
1662
|
-
}
|
|
1663
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
1664
|
-
const messages = parsed["messages"];
|
|
1665
|
-
if (!Array.isArray(messages)) return [];
|
|
1666
|
-
const openings = [];
|
|
1667
|
-
for (const value of messages) {
|
|
1668
|
-
if (openings.length >= 3) break;
|
|
1669
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1670
|
-
const message = value;
|
|
1671
|
-
const role = message["role"];
|
|
1672
|
-
if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
|
|
1673
|
-
const content = message["content"];
|
|
1674
|
-
if (typeof content !== "string") continue;
|
|
1675
|
-
const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
|
|
1676
|
-
if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
|
|
1677
|
-
}
|
|
1678
|
-
return openings;
|
|
2073
|
+
function isConversationIdGateBody(body) {
|
|
2074
|
+
const lower = body.toLowerCase();
|
|
2075
|
+
return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
|
|
1679
2076
|
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Whether a failure body is the FIRST-MESSAGE gate (`11128`).
|
|
2079
|
+
*
|
|
2080
|
+
* The global gateway's own rule: a chat request must OPEN with a system
|
|
2081
|
+
* message, and a user-first body is refused with `11128` / "first message is
|
|
2082
|
+
* not system prompt". The plugin's `ensureSystemHead()` satisfies it, so a
|
|
2083
|
+
* body carrying this wording says the request never had a system head — not
|
|
2084
|
+
* that a system prompt was fingerprinted.
|
|
2085
|
+
*
|
|
2086
|
+
* Excluded from the prompt diagnosis for the same reason as
|
|
2087
|
+
* {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
|
|
2088
|
+
* the WORDING, case-insensitively, code in its quoted-key form.
|
|
2089
|
+
*/
|
|
2090
|
+
function isFirstMessageGateBody(body) {
|
|
2091
|
+
const lower = body.toLowerCase();
|
|
2092
|
+
return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* The OPENINGS of the `system` messages in a prepared request body.
|
|
2096
|
+
*
|
|
2097
|
+
* REMOVED in favour of {@link systemOpeningEvidenceOfBody}, which answers the
|
|
2098
|
+
* same question ("what did the gate actually see?") for BOTH string and
|
|
2099
|
+
* array-shaped content and can also say whether an opening is the plugin's own
|
|
2100
|
+
* neutral head. Two functions that both claim to describe what the gate read
|
|
2101
|
+
* are two truths that drift — and the array-shaped case is exactly where the
|
|
2102
|
+
* old one was blind.
|
|
2103
|
+
*/
|
|
1680
2104
|
/** Longest system-prompt opening echoed back to the user, in characters. */
|
|
1681
2105
|
const SYSTEM_OPENING_LIMIT = 120;
|
|
1682
|
-
function firstLineOf(content) {
|
|
1683
|
-
const newline = content.indexOf("\n");
|
|
1684
|
-
return newline === -1 ? content : content.slice(0, newline);
|
|
1685
|
-
}
|
|
1686
2106
|
function clip(text, limit) {
|
|
1687
2107
|
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
|
|
1688
2108
|
}
|
|
@@ -1696,6 +2116,7 @@ function classifyUpstreamError(status, body) {
|
|
|
1696
2116
|
for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
|
|
1697
2117
|
if (isChannelRejectBody(body)) return "channel_reject";
|
|
1698
2118
|
if (isModelBusyBody(body)) return "model_busy";
|
|
2119
|
+
if (parseQueueAnswer(body) !== void 0) return "model_queue";
|
|
1699
2120
|
if (status === 429) return "soft_rate";
|
|
1700
2121
|
if (status === 404) return "not_found";
|
|
1701
2122
|
if (status >= 500) return "server";
|
|
@@ -1923,6 +2344,31 @@ function billingHeaders(credential) {
|
|
|
1923
2344
|
return headers;
|
|
1924
2345
|
}
|
|
1925
2346
|
/**
|
|
2347
|
+
* 生图请求头。
|
|
2348
|
+
*
|
|
2349
|
+
* 实测口径(2026-10-21,国内区):`Authorization: Bearer` + `X-Product: SaaS` +
|
|
2350
|
+
* 用户/企业/域三连 + 会话身份三连 + `PRODUCT_UA`,在 `copilot.tencent.com` 与
|
|
2351
|
+
* `www.workbuddy.cn` 两个入口都返回 200。刻意**不带** `Origin` / `Referer`:
|
|
2352
|
+
* 探针当时没带,带了只是多一个可能被网关拒掉的变量,没有收益。
|
|
2353
|
+
*
|
|
2354
|
+
* 复用 {@link conversationHeaders}:官方 CLI 的 ImageService 也是每次现生成
|
|
2355
|
+
* 一组会话 UUID,且两个区走同一条代码路径(见该函数的注释)。
|
|
2356
|
+
*/
|
|
2357
|
+
function imageHeaders(credential) {
|
|
2358
|
+
return {
|
|
2359
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
2360
|
+
"Accept": "application/json, text/plain, */*",
|
|
2361
|
+
"Content-Type": "application/json",
|
|
2362
|
+
"User-Agent": PRODUCT_UA,
|
|
2363
|
+
"X-Product": "SaaS",
|
|
2364
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
2365
|
+
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
2366
|
+
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
2367
|
+
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
2368
|
+
...conversationHeaders()
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
1926
2372
|
* Stand-in system message for a request that reached the wire carrying none.
|
|
1927
2373
|
*
|
|
1928
2374
|
* Not a stylistic default: both WorkBuddy gateways want the conversation to
|
|
@@ -1980,31 +2426,389 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
|
1980
2426
|
* snapshot, not a theory of what the gate "means"; a gateway adding a new
|
|
1981
2427
|
* opening is not caught until a body carries one, and the prepend only wins
|
|
1982
2428
|
* while the gate keeps matching at offset 0.
|
|
2429
|
+
*
|
|
2430
|
+
* THE LIST CANNOT BE COMPLETE, AND THAT IS WHY {@link forceNeutralHead}
|
|
2431
|
+
* EXISTS. This is a snapshot of the upstream's own list, taken by measuring
|
|
2432
|
+
* openings one at a time; the upstream can extend it at any time and the
|
|
2433
|
+
* plugin cannot see it. So the plugin's own prepend is a best-effort filter,
|
|
2434
|
+
* not a guarantee — and the self-healing retry is what covers the openings
|
|
2435
|
+
* nobody has measured yet.
|
|
1983
2436
|
*/
|
|
1984
2437
|
const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
|
|
1985
2438
|
/**
|
|
1986
|
-
*
|
|
1987
|
-
*
|
|
2439
|
+
* Whether this text OPENS with an opening the gateways refuse.
|
|
2440
|
+
*
|
|
2441
|
+
* The gate is a PREFIX match on the START of a `system` message, so this is
|
|
2442
|
+
* the one predicate that decides it — used by the request path (which prepends
|
|
2443
|
+
* the neutral head on a hit) and by the failure path (which names the
|
|
2444
|
+
* offending line).
|
|
2445
|
+
*
|
|
2446
|
+
* THREE MEASURED RULES are folded in here, all from the live CN gateway on
|
|
2447
|
+
* 2026-10-21 (account `ba217455…`, `deepseek-v4.1-flash`, every other request
|
|
2448
|
+
* field held fixed):
|
|
2449
|
+
*
|
|
2450
|
+
* 1. Only the START counts — the same sentence mid-content answers 200.
|
|
2451
|
+
* 2. LEADING WHITESPACE is trimmed before the comparison — " <banned>" and
|
|
2452
|
+
* "\n<banned>" both answer 200 today.
|
|
2453
|
+
* 3. The comparison is CASE-INSENSITIVE — the lower-cased sentence fails too.
|
|
2454
|
+
*
|
|
2455
|
+
* (2) is a gift that can be withdrawn, so the comparison is made the way a
|
|
2456
|
+
* trimming gate would make it even while the gateway is lenient: a rule that
|
|
2457
|
+
* only holds while the gateway forgets to trim is a rule waiting to break.
|
|
2458
|
+
*
|
|
2459
|
+
* The list itself stays the measured snapshot. It is deliberately NOT
|
|
2460
|
+
* extended by guesswork: an opening nobody measured is an opening nobody knows
|
|
2461
|
+
* the gateway refuses, and inventing entries would neutralise prompts that
|
|
2462
|
+
* answer 200 today.
|
|
2463
|
+
*/
|
|
2464
|
+
function isUnapprovedChannelOpening(text) {
|
|
2465
|
+
const opening = text.replace(/^\s+/u, "").toLowerCase();
|
|
2466
|
+
return UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix));
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Whether this text already begins with the plugin's own neutral head.
|
|
1988
2470
|
*
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
2471
|
+
* The question the failure path has to answer per system message: "is the
|
|
2472
|
+
* line the gateway refused OURS, or the HOST's?" — the plugin's head is the
|
|
2473
|
+
* one line it has ever added to a prompt, so it is the only opening that
|
|
2474
|
+
* cannot be a third-party-agent fingerprint.
|
|
2475
|
+
*/
|
|
2476
|
+
function startsWithNeutralHead(text) {
|
|
2477
|
+
return text.replace(/^\s+/u, "").toLowerCase().startsWith(WORKBUDDY_FALLBACK_SYSTEM_PROMPT.toLowerCase());
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* The roles whose message OPENINGS the gateway scans.
|
|
2481
|
+
*
|
|
2482
|
+
* MEASURED on the live CN gateway (2026-10-21, every other request field held
|
|
2483
|
+
* fixed): `system` and `assistant` are scanned; `user` and `tool` are not (a
|
|
2484
|
+
* body whose `user` or `tool` content opens with the banned identity answers
|
|
2485
|
+
* 200). That second role is the one the plugin missed until 4.15.0, and it is
|
|
2486
|
+
* why issue #98's sessions stayed broken: an `assistant` history message that
|
|
2487
|
+
* opens with the fingerprint poisons EVERY later turn of that session, on
|
|
2488
|
+
* every account and in every region.
|
|
2489
|
+
*
|
|
2490
|
+
* `developer` is not listed because `prepareChatBody` rewrites it to `system`
|
|
2491
|
+
* before this module looks at the body — and because both gateways refuse
|
|
2492
|
+
* `developer` outright, so neutralising its content would not save the request.
|
|
2493
|
+
*/
|
|
2494
|
+
function scansOpening(role) {
|
|
2495
|
+
if (typeof role !== "string") return false;
|
|
2496
|
+
const name = role.trim().toLowerCase();
|
|
2497
|
+
return name === "system" || name === "assistant";
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Move every scanned message (`system` and `assistant`) off the content
|
|
2501
|
+
* fingerprint above by prepending the placeholder head.
|
|
2502
|
+
*
|
|
2503
|
+
* A no-op for a body whose messages start with anything else, so a request the
|
|
2504
|
+
* gateways already answer keeps its prompt byte-for-byte — only the messages
|
|
2505
|
+
* the gate would refuse grow, by one short line.
|
|
2506
|
+
*
|
|
2507
|
+
* BOTH ROLES, because the gate reads both. Until 4.15.0 this only handled
|
|
2508
|
+
* `system`, and the failure that left behind is issue #98: a conversation
|
|
2509
|
+
* whose HISTORY contains an assistant message opening with the banned identity
|
|
2510
|
+
* (the model echoing its own prompt, a restored session, a compaction
|
|
2511
|
+
* artefact) was refused on every single turn, and no account or region could
|
|
2512
|
+
* change a byte of it.
|
|
2513
|
+
*
|
|
2514
|
+
* ARRAY CONTENT IS HANDLED PART BY PART, and that is not a nicety. Measured on
|
|
2515
|
+
* the live CN gateway (2026-10-21): when a scanned message's `content` is an
|
|
2516
|
+
* array of parts, the gate reads EVERY TEXT PART of it — a banned opening in
|
|
2517
|
+
* the FIRST part is refused, and a banned opening in the SECOND part is
|
|
2518
|
+
* refused just the same. Prepending to the first part only would therefore
|
|
2519
|
+
* fix the first half of the failure and leave the second half to the users who
|
|
2520
|
+
* report it again.
|
|
1993
2521
|
*/
|
|
1994
2522
|
function neutralizeChannelFingerprints(obj) {
|
|
1995
2523
|
const messages = obj["messages"];
|
|
1996
2524
|
if (!Array.isArray(messages)) return;
|
|
1997
2525
|
for (const value of messages) {
|
|
2526
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2527
|
+
const message = value;
|
|
2528
|
+
if (!scansOpening(message["role"])) continue;
|
|
2529
|
+
const content = message["content"];
|
|
2530
|
+
if (typeof content === "string") {
|
|
2531
|
+
if (!isUnapprovedChannelOpening(content)) continue;
|
|
2532
|
+
message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
if (!Array.isArray(content)) continue;
|
|
2536
|
+
for (const part of content) {
|
|
2537
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2538
|
+
const record = part;
|
|
2539
|
+
const type = record["type"];
|
|
2540
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2541
|
+
const text = record["text"];
|
|
2542
|
+
if (typeof text !== "string" || !isUnapprovedChannelOpening(text)) continue;
|
|
2543
|
+
record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
/**
|
|
2548
|
+
* A body in which EVERY scanned opening (`system` and `assistant`) is moved
|
|
2549
|
+
* behind the neutral head.
|
|
2550
|
+
*
|
|
2551
|
+
* THE ONE-SHOT SELF-HEALING RETRY'S BODY BUILDER. The gateway's fingerprint
|
|
2552
|
+
* list is a SNAPSHOT the upstream can extend at any time, and the plugin
|
|
2553
|
+
* cannot see it: the two entries in {@link UNAPPROVED_CHANNEL_OPENINGS} are
|
|
2554
|
+
* what was MEASURED on 2026-10-21, not a contract. So the day the gateway
|
|
2555
|
+
* adds an entry, this plugin's own prepend stops matching and the banned
|
|
2556
|
+
* opening goes out on the wire unchanged — exactly the dead end issue #98
|
|
2557
|
+
* describes, and exactly what a longer guess-list cannot prevent (nobody can
|
|
2558
|
+
* measure the next entry before it bites).
|
|
2559
|
+
*
|
|
2560
|
+
* The remedy is a second attempt that does not depend on knowing the
|
|
2561
|
+
* fingerprint at all: this builds a body in which EVERY scanned message,
|
|
2562
|
+
* whatever it opens with, gets the neutral head in front. The gateway matches
|
|
2563
|
+
* the START of a scanned message, so a neutral first line moves the whole
|
|
2564
|
+
* opening out of the matched region — whether the banned text is a known
|
|
2565
|
+
* entry, a new one, or a fingerprint this plugin has never heard of.
|
|
2566
|
+
*
|
|
2567
|
+
* `assistant` messages are included for the measured reason
|
|
2568
|
+
* {@link neutralizeChannelFingerprints} documents: a history message that opens
|
|
2569
|
+
* with a refused identity is what keeps a whole session failing, so a retry
|
|
2570
|
+
* that only moved the system prompt would spend a request to be refused for the
|
|
2571
|
+
* same reason.
|
|
2572
|
+
*
|
|
2573
|
+
* WHEN IT IS USED: only AFTER the gateway refused the first attempt with the
|
|
2574
|
+
* channel gate. A request the gateway already answers keeps its bytes
|
|
2575
|
+
* unchanged, because this function is never called for it — the normal path
|
|
2576
|
+
* is {@link prepareChatBody}, which only touches the openings it has MEASURED
|
|
2577
|
+
* as fingerprinted.
|
|
2578
|
+
*
|
|
2579
|
+
* `undefined` means "this body cannot change": every scanned opening already
|
|
2580
|
+
* starts with the neutral head, so a retry would send the same bytes and
|
|
2581
|
+
* spend a request for the same refusal. The caller uses that to avoid
|
|
2582
|
+
* spending the round trip.
|
|
2583
|
+
*
|
|
2584
|
+
* Empty contents are skipped: an empty string cannot open with anything, so it
|
|
2585
|
+
* cannot be the culprit, and an assistant message that carries `tool_calls`
|
|
2586
|
+
* with `content: ""` is a replay shape this function has no reason to touch.
|
|
2587
|
+
*
|
|
2588
|
+
* Never throws. A body it cannot read is the upstream's to reject, and a
|
|
2589
|
+
* parse error must not turn a retry into a crash.
|
|
2590
|
+
*/
|
|
2591
|
+
function forceNeutralHead(bodyJson) {
|
|
2592
|
+
let parsed;
|
|
2593
|
+
try {
|
|
2594
|
+
parsed = JSON.parse(bodyJson);
|
|
2595
|
+
} catch {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2599
|
+
const obj = parsed;
|
|
2600
|
+
const messages = obj["messages"];
|
|
2601
|
+
if (!Array.isArray(messages)) return void 0;
|
|
2602
|
+
let changed = false;
|
|
2603
|
+
for (const value of messages) {
|
|
2604
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2605
|
+
const message = value;
|
|
2606
|
+
if (!scansOpening(message["role"])) continue;
|
|
2607
|
+
const content = message["content"];
|
|
2608
|
+
if (typeof content === "string") {
|
|
2609
|
+
if (content.trim() === "" || startsWithNeutralHead(content)) continue;
|
|
2610
|
+
message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
|
|
2611
|
+
changed = true;
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
2614
|
+
if (!Array.isArray(content)) continue;
|
|
2615
|
+
for (const part of content) {
|
|
2616
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2617
|
+
const record = part;
|
|
2618
|
+
const type = record["type"];
|
|
2619
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2620
|
+
const text = record["text"];
|
|
2621
|
+
if (typeof text !== "string" || text.trim() === "" || startsWithNeutralHead(text)) continue;
|
|
2622
|
+
record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
|
|
2623
|
+
changed = true;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if (!changed) return void 0;
|
|
2627
|
+
return JSON.stringify(obj);
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* The invisible character used to break a content fingerprint.
|
|
2631
|
+
*
|
|
2632
|
+
* U+200B ZERO WIDTH SPACE: it renders as nothing, it is not whitespace for the
|
|
2633
|
+
* gateway's own matching, and a model reads straight through it. Measured on
|
|
2634
|
+
* the live CN gateway: putting one inside the refused span turns 400 into 200,
|
|
2635
|
+
* while the text the model reads is unchanged.
|
|
2636
|
+
*/
|
|
2637
|
+
const ZERO_WIDTH_SPACE = "";
|
|
2638
|
+
/**
|
|
2639
|
+
* A body whose scanned message TEXT is perturbed just enough to break an exact
|
|
2640
|
+
* content fingerprint.
|
|
2641
|
+
*
|
|
2642
|
+
* THE SECOND HALF OF ISSUE #98, found on 2026-10-21 after 4.15.0 shipped. The
|
|
2643
|
+
* gateway does not only match the OPENING of a scanned message; it also matches
|
|
2644
|
+
* one long substring INSIDE the system prompt. Measured byte-exactly on a real
|
|
2645
|
+
* failed request (CN gateway, `deepseek-v4.1-flash`, the host's own body):
|
|
2646
|
+
*
|
|
2647
|
+
* - the refusal is reproducible, and it is not the opening: the plugin had
|
|
2648
|
+
* already prepended the neutral head, and the request still answered 400;
|
|
2649
|
+
* - bisecting the host's 10,956-character system prompt by lines and then by
|
|
2650
|
+
* sentences found a 221-character span: the DSH Web-GUI paragraph's first
|
|
2651
|
+
* two sentences ("You are interacting with the user through the DeepSeek
|
|
2652
|
+
* Harness Web GUI … they mean this GUI.");
|
|
2653
|
+
* - that span ALONE reproduces the 400, and the SAME span with ANY change
|
|
2654
|
+
* inside it answers 200 — one character rewritten, one quoted word
|
|
2655
|
+
* replaced, even a zero-width space inserted. An outer prefix or suffix
|
|
2656
|
+
* does not help, so the match is an exact substring, not an opening rule;
|
|
2657
|
+
* - the paragraph's individual sentences each answer 200, which is why the
|
|
2658
|
+
* plugin's opening-based normalisation never saw this.
|
|
2659
|
+
*
|
|
2660
|
+
* SO THE RESCUE CANNOT BE "prepend a line" (that is {@link forceNeutralHead},
|
|
2661
|
+
* and it is a no-op here). Nor can it be a list of refused spans: the list is
|
|
2662
|
+
* the upstream's own data, it already grew once without notice, and it will
|
|
2663
|
+
* grow again. What DOES generalise is the observation that an exact-substring
|
|
2664
|
+
* match dies on any perturbation inside it — so this inserts a zero-width space
|
|
2665
|
+
* at every sentence and clause boundary of the scanned messages, which is
|
|
2666
|
+
* invisible to the reader, meaningless to the model, and inside the refused
|
|
2667
|
+
* span with near-certainty for any prose fingerprint.
|
|
2668
|
+
*
|
|
2669
|
+
* WHEN IT IS USED: only as the last retry, after the gateway already refused
|
|
2670
|
+
* the request AND the neutral head could not change anything. Requests the
|
|
2671
|
+
* gateway answers keep their bytes untouched.
|
|
2672
|
+
*
|
|
2673
|
+
* Idempotent: a body that already carries U+200B in a scanned message is left
|
|
2674
|
+
* alone, so a retry never stacks perturbations. Returns `undefined` when
|
|
2675
|
+
* nothing would change, which is the caller's signal not to spend a request.
|
|
2676
|
+
*
|
|
2677
|
+
* Never throws: a malformed body is the upstream's to reject, and a parse error
|
|
2678
|
+
* must not turn a rescue into a crash.
|
|
2679
|
+
*/
|
|
2680
|
+
function perturbChannelFingerprints(bodyJson) {
|
|
2681
|
+
let parsed;
|
|
2682
|
+
try {
|
|
2683
|
+
parsed = JSON.parse(bodyJson);
|
|
2684
|
+
} catch {
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2688
|
+
const obj = parsed;
|
|
2689
|
+
const messages = obj["messages"];
|
|
2690
|
+
if (!Array.isArray(messages)) return void 0;
|
|
2691
|
+
let changed = false;
|
|
2692
|
+
const perturb = (text) => {
|
|
2693
|
+
if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
|
|
2694
|
+
const next = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`);
|
|
2695
|
+
return next === text ? void 0 : next;
|
|
2696
|
+
};
|
|
2697
|
+
for (const value of messages) {
|
|
2698
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
2699
|
+
const message = value;
|
|
2700
|
+
if (!scansOpening(message["role"])) continue;
|
|
2701
|
+
const content = message["content"];
|
|
2702
|
+
if (typeof content === "string") {
|
|
2703
|
+
const next = perturb(content);
|
|
2704
|
+
if (next !== void 0) {
|
|
2705
|
+
message["content"] = next;
|
|
2706
|
+
changed = true;
|
|
2707
|
+
}
|
|
2708
|
+
continue;
|
|
2709
|
+
}
|
|
2710
|
+
if (!Array.isArray(content)) continue;
|
|
2711
|
+
for (const part of content) {
|
|
2712
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2713
|
+
const record = part;
|
|
2714
|
+
const type = record["type"];
|
|
2715
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2716
|
+
const text = record["text"];
|
|
2717
|
+
if (typeof text !== "string") continue;
|
|
2718
|
+
const next = perturb(text);
|
|
2719
|
+
if (next !== void 0) {
|
|
2720
|
+
record["text"] = next;
|
|
2721
|
+
changed = true;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
if (!changed) return void 0;
|
|
2726
|
+
return JSON.stringify(obj);
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* The openings of every scanned message in a body, one entry per TEXT PART.
|
|
2730
|
+
*
|
|
2731
|
+
* `system` AND `assistant`, because both are read by the gate (measured; see
|
|
2732
|
+
* {@link scansOpening}). `developer` counts as a system message because the
|
|
2733
|
+
* plugin rewrites `developer` → `system` on the wire.
|
|
2734
|
+
*
|
|
2735
|
+
* Array content contributes ONE ENTRY PER PART, for the same reason the request
|
|
2736
|
+
* path neutralises every part: the gate reads every text part, so "which line
|
|
2737
|
+
* tripped it" can be any of them.
|
|
2738
|
+
*
|
|
2739
|
+
* Capped at {@link SYSTEM_EVIDENCE_LIMIT} entries, and never throws — losing
|
|
2740
|
+
* the failure measurement is not an acceptable price for reading an excerpt.
|
|
2741
|
+
*/
|
|
2742
|
+
function openingEvidenceOfBody(bodyJson) {
|
|
2743
|
+
let parsed;
|
|
2744
|
+
try {
|
|
2745
|
+
parsed = JSON.parse(bodyJson);
|
|
2746
|
+
} catch {
|
|
2747
|
+
return [];
|
|
2748
|
+
}
|
|
2749
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
2750
|
+
const messages = parsed["messages"];
|
|
2751
|
+
if (!Array.isArray(messages)) return [];
|
|
2752
|
+
const evidence = [];
|
|
2753
|
+
for (let index = 0; index < messages.length; index++) {
|
|
2754
|
+
if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
|
|
2755
|
+
const value = messages[index];
|
|
1998
2756
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1999
2757
|
const message = value;
|
|
2000
2758
|
const role = message["role"];
|
|
2001
|
-
if (typeof role !== "string"
|
|
2759
|
+
if (typeof role !== "string") continue;
|
|
2760
|
+
const roleName = role.trim().toLowerCase();
|
|
2761
|
+
if (roleName !== "system" && roleName !== "assistant" && roleName !== "developer") continue;
|
|
2762
|
+
const roleOfEvidence = roleName === "assistant" ? "assistant" : "system";
|
|
2002
2763
|
const content = message["content"];
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
if (
|
|
2006
|
-
|
|
2764
|
+
const texts = [];
|
|
2765
|
+
if (typeof content === "string") texts.push(content);
|
|
2766
|
+
else if (Array.isArray(content)) for (const part of content) {
|
|
2767
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
2768
|
+
const record = part;
|
|
2769
|
+
const type = record["type"];
|
|
2770
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2771
|
+
const text = record["text"];
|
|
2772
|
+
if (typeof text === "string") texts.push(text);
|
|
2773
|
+
}
|
|
2774
|
+
for (const text of texts) {
|
|
2775
|
+
if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
|
|
2776
|
+
const opening = firstNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
|
|
2777
|
+
if (opening === "") continue;
|
|
2778
|
+
evidence.push({
|
|
2779
|
+
opening: clip(opening, SYSTEM_OPENING_LIMIT),
|
|
2780
|
+
role: roleOfEvidence,
|
|
2781
|
+
neutralized: startsWithNeutralHead(opening) || isUnapprovedChannelOpening(opening),
|
|
2782
|
+
messageIndex: index
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2007
2785
|
}
|
|
2786
|
+
return evidence;
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* How many openings the failure path reports.
|
|
2790
|
+
*
|
|
2791
|
+
* Eight is generous: the gate scans every system message and every text part
|
|
2792
|
+
* of it, so a body with more than a handful of openings is not a prompt the
|
|
2793
|
+
* reader can act on anyway — the reply names the first ones and the failure
|
|
2794
|
+
* body carries the rest.
|
|
2795
|
+
*
|
|
2796
|
+
* The cap is HERE and not in the reply: this function reports everything it
|
|
2797
|
+
* found (up to eight) and the reply renders as many of them as fit, saying
|
|
2798
|
+
* how many were left out. Truncating the evidence before the reply sees it
|
|
2799
|
+
* would make "N more" unanswerable.
|
|
2800
|
+
*/
|
|
2801
|
+
const SYSTEM_EVIDENCE_LIMIT = 8;
|
|
2802
|
+
/**
|
|
2803
|
+
* The first line of a text that carries something.
|
|
2804
|
+
*
|
|
2805
|
+
* Blank lines are SKIPPED: the neutral head is followed by an empty line, so
|
|
2806
|
+
* the head itself is the line worth reporting, and a content that opens with
|
|
2807
|
+
* blank lines still has an opening.
|
|
2808
|
+
*/
|
|
2809
|
+
function firstNonEmptyLineOf(content) {
|
|
2810
|
+
for (const line of content.split("\n")) if (line.trim() !== "") return line;
|
|
2811
|
+
return "";
|
|
2008
2812
|
}
|
|
2009
2813
|
/**
|
|
2010
2814
|
* Make the conversation open with a system message, prepending the fallback
|
|
@@ -2664,6 +3468,101 @@ var WorkBuddyUpstreamClient = class {
|
|
|
2664
3468
|
return await readCatalog(MODELS_CATALOG_PATH);
|
|
2665
3469
|
}
|
|
2666
3470
|
}
|
|
3471
|
+
/**
|
|
3472
|
+
* Read how busy each model is right now, from the upstream's own probe.
|
|
3473
|
+
*
|
|
3474
|
+
* Batched at the upstream's ceiling (`WORKBUDDY_BUSY_BATCH_SIZE`), because the
|
|
3475
|
+
* probe takes a list of ids and answers only about the ids it was given.
|
|
3476
|
+
*
|
|
3477
|
+
* Throws on anything that is not a clean `code: 0`, like every other reader in
|
|
3478
|
+
* this class — the CALLER decides that a failed load probe is survivable and
|
|
3479
|
+
* catches it. Nothing here is persisted: the level belongs to the refresh that
|
|
3480
|
+
* asked for it.
|
|
3481
|
+
*/
|
|
3482
|
+
async fetchModelBusy(credential, modelIds, signal) {
|
|
3483
|
+
const levels = /* @__PURE__ */ new Map();
|
|
3484
|
+
for (const batch of batchModelIds(modelIds)) {
|
|
3485
|
+
const response = await fetch(`${chatBase(credential)}${MODEL_BUSY_PATH}`, {
|
|
3486
|
+
method: "POST",
|
|
3487
|
+
headers: {
|
|
3488
|
+
...commonHeaders(credential),
|
|
3489
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
3490
|
+
"Content-Type": "application/json"
|
|
3491
|
+
},
|
|
3492
|
+
body: JSON.stringify({ models: batch }),
|
|
3493
|
+
signal: signal ?? AbortSignal.timeout(MODEL_BUSY_TIMEOUT_MS)
|
|
3494
|
+
});
|
|
3495
|
+
const envelope = await readEnvelope(response);
|
|
3496
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
3497
|
+
for (const [id, level] of parseBusyLevels(envelope.data)) levels.set(id, level);
|
|
3498
|
+
}
|
|
3499
|
+
return levels;
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* 读该账号所属区的产品配置,挑出带 `text-to-image` 标签的媒体模型。
|
|
3503
|
+
*
|
|
3504
|
+
* 走的是和 {@link fetchModels} 同一条 `/v3/config`(国内区与国际区都答),
|
|
3505
|
+
* 区别只在这里保留 `data.models` 的媒体条目、而不是投影成 CLI 聊天目录。
|
|
3506
|
+
* 国内区实测 62 条模型里只有 `hunyuan-image-alpha` 带 `text-to-image`;
|
|
3507
|
+
* 国际区一条都没有 —— 所以本方法对国际区返回空数组,而不是编一个模型出来。
|
|
3508
|
+
*
|
|
3509
|
+
* 这是一次 EXTRA 读取,不是「复用目录那一次」:`fetchModels` 的结果被投影成
|
|
3510
|
+
* 聊天目录就丢掉了媒体标签,所以这里必须再读一遍同一份文档。它是只读 GET、
|
|
3511
|
+
* 不花积分,且工具侧有 5 分钟缓存,但「零新增请求」的说法不成立,别那么写。
|
|
3512
|
+
* 国内区与 {@link fetchModels} 一样走 `/v3` → `/v2` 降级:上游一旦不答
|
|
3513
|
+
* `/v3`,聊天目录会降级而这里不能变成一个原始异常。
|
|
3514
|
+
*/
|
|
3515
|
+
async fetchMediaModels(credential, signal) {
|
|
3516
|
+
const readMedia = async (path) => {
|
|
3517
|
+
const response = await fetch(`${chatBase(credential)}${path}`, {
|
|
3518
|
+
headers: {
|
|
3519
|
+
"Authorization": `Bearer ${credential.accessToken}`,
|
|
3520
|
+
"Accept": "application/json",
|
|
3521
|
+
"Origin": originReferer(credential),
|
|
3522
|
+
"Referer": `${originReferer(credential)}/`,
|
|
3523
|
+
"User-Agent": PRODUCT_UA
|
|
3524
|
+
},
|
|
3525
|
+
signal: signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
3526
|
+
});
|
|
3527
|
+
const envelope = await readEnvelope(response);
|
|
3528
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
3529
|
+
return selectMediaModels((typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {})["models"], IMAGE_TAG_TEXT_TO_IMAGE);
|
|
3530
|
+
};
|
|
3531
|
+
try {
|
|
3532
|
+
return await readMedia(GLOBAL_CONFIG_PATH);
|
|
3533
|
+
} catch (error) {
|
|
3534
|
+
if (regionOf(credential.domain) === "global") throw error;
|
|
3535
|
+
this.onFallback?.(`CN media catalog ${GLOBAL_CONFIG_PATH} failed; falling back to ${MODELS_CATALOG_PATH}: ` + (error instanceof Error ? error.message : String(error)));
|
|
3536
|
+
return await readMedia(MODELS_CATALOG_PATH);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* 发一次文生图请求。
|
|
3541
|
+
*
|
|
3542
|
+
* 端点、请求体、响应解析全部照抄官方 CLI 的 ImageService(见 `image-gen.ts`
|
|
3543
|
+
* 的模块注释)。实测单张 5.71 credits,产物是 24 小时有效的 COS 签名 URL。
|
|
3544
|
+
*
|
|
3545
|
+
* 失败分两层:HTTP 非 2xx 交给 {@link describeImageFailure} 翻成带 code 的
|
|
3546
|
+
* 错误(401/403 提示重新登录、404 提示该区没有生图路由、429 提示限流);
|
|
3547
|
+
* HTTP 200 但信封 `code !== 0` 由 {@link parseImageResponse} 处理。
|
|
3548
|
+
*/
|
|
3549
|
+
async generateImage(credential, request, signal) {
|
|
3550
|
+
const response = await fetch(`${chatBase(credential)}${IMAGE_GENERATIONS_PATH}`, {
|
|
3551
|
+
method: "POST",
|
|
3552
|
+
headers: imageHeaders(credential),
|
|
3553
|
+
body: JSON.stringify(buildImageBody(request)),
|
|
3554
|
+
signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
|
|
3555
|
+
});
|
|
3556
|
+
const text = await response.text();
|
|
3557
|
+
if (!response.ok) throw describeImageFailure(response.status, text);
|
|
3558
|
+
let payload;
|
|
3559
|
+
try {
|
|
3560
|
+
payload = JSON.parse(text);
|
|
3561
|
+
} catch {
|
|
3562
|
+
throw new WorkBuddyImageError(`workbuddy image: 上游返回的不是 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`, "UPSTREAM");
|
|
3563
|
+
}
|
|
3564
|
+
return parseImageResponse(payload);
|
|
3565
|
+
}
|
|
2667
3566
|
/** Query today's check-in status without changing account state. */
|
|
2668
3567
|
async fetchCheckinStatus(credential) {
|
|
2669
3568
|
const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
|
|
@@ -4863,7 +5762,7 @@ function generationOf(version) {
|
|
|
4863
5762
|
//#endregion
|
|
4864
5763
|
//#region src/version.ts
|
|
4865
5764
|
/** The npm package version this build was produced from. */
|
|
4866
|
-
const WORKBUDDY_CONNECT_VERSION = "
|
|
5765
|
+
const WORKBUDDY_CONNECT_VERSION = "5.0.0";
|
|
4867
5766
|
//#endregion
|
|
4868
5767
|
//#region src/host-heartbeat.ts
|
|
4869
5768
|
/**
|
|
@@ -5021,4 +5920,4 @@ async function clearHostHeartbeat() {
|
|
|
5021
5920
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
5022
5921
|
}
|
|
5023
5922
|
//#endregion
|
|
5024
|
-
export {
|
|
5923
|
+
export { REFUSES_OFF_MODEL_IDS as $, workbuddyAccountId as A, withFileLock as At, isCredentialRejectedError as B, defaultDesktopAuthDirs as C, macosBundleExecutable as Ct, isVaultAccountId as D, workbuddyAppExecutableCandidates as Dt, isEncryptedCredentialError as E, readAtRestKey as Et, WorkBuddyUpstreamClient as F, parseCreditMultiplier as G, knownRegionOf as H, classifyUpstreamError as I, perturbChannelFingerprints as J, parseReasoning as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, hostPeersInfo as Ot, WorkBuddyCredentialRejectedError as P, busyDisplaySuffix as Q, forceNeutralHead as R, defaultDesktopAuthCandidates as S, isWorkbuddyBundle as St, hasEncryptedCredentialFields as T, openEncryptedField as Tt, modelIdOfBody as U, isFirstMessageGateBody as V, openingEvidenceOfBody as W, regionOf as X, prepareChatBody as Y, attachBusyLevels as Z, WORKBUDDY_AUTH_FILENAME as _, deriveAtRestKeyId as _t, readHostHeartbeat as a, withEffectiveOff as at, WorkBuddyEncryptedCredentialError as b, findWorkbuddyAppExecutableWithSource as bt, WORKBUDDY_CONNECT_VERSION as c, pickImageModel as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, NATIVE_MODALITY_BY_MODEL_ID as dt, defaultOffModelIds as et, WorkBuddyCatalog as f, imageDefaultFor as ft, ENCRYPTED_CREDENTIAL_CODE as g, deriveAtRestKey as gt, fallbackModelsFor as h, clearAtRestKeyCache as ht, processStartTimeMs as i, recordRefusal as it, workbuddyOwnAuthPath as j, writeFileAtomic as jt, parseWorkBuddyAuth as k, resolveDshHome as kt, piAiRuntimeInfo as l, parseQueueAnswer as lt, deriveCatalog as m, WORKBUDDY_APP_EXECUTABLE_ENV as mt, clearHostHeartbeat as n, offDefaultFor as nt, workbuddyHostHeartbeatPath as o, DEFAULT_IMAGE_SIZE as ot, applyContextBudgets as p, nativeModalityOf as pt, parseUpstreamModel as q, isHeartbeatProcessAlive as r, offRefused as rt, writeHostHeartbeat as s, WorkBuddyImageError as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, lastRefusalFor as tt, FALLBACK_WORKBUDDY_MODELS as u, queueNoticeText as ut, WORKBUDDY_AUTH_FILE_ENV as v, fetchAtRestKeyPayload as vt, defaultDesktopAuthPath as w, macosNestedAppBundles as wt, authFileName as x, isEncryptedFieldWrapper as xt, WorkBuddyCredentialStore as y, findWorkbuddyAppExecutable as yt, isConversationIdGateBody as z };
|