dsh-llm-codebuddy-power 1.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.
Files changed (44) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +66 -0
  3. package/README.zh.md +66 -0
  4. package/cordis.patch.yml +32 -0
  5. package/lib/cli-login.js +2099 -0
  6. package/lib/client.js +2814 -0
  7. package/lib/index.js +3957 -0
  8. package/lib/types/adapter.d.ts +58 -0
  9. package/lib/types/atomic-file.d.ts +43 -0
  10. package/lib/types/auth-service.d.ts +195 -0
  11. package/lib/types/cli/login.d.ts +23 -0
  12. package/lib/types/client/AccountSwitcher.d.ts +53 -0
  13. package/lib/types/client/CodeBuddySection.d.ts +62 -0
  14. package/lib/types/client/ComposerControls.d.ts +29 -0
  15. package/lib/types/client/DrawToolCard.d.ts +24 -0
  16. package/lib/types/client/ModelPanel.d.ts +77 -0
  17. package/lib/types/client/TurnCreditPill.d.ts +33 -0
  18. package/lib/types/client/UsageIndicator.d.ts +21 -0
  19. package/lib/types/client/icons.d.ts +24 -0
  20. package/lib/types/client/index.d.ts +34 -0
  21. package/lib/types/client/locales.d.ts +90 -0
  22. package/lib/types/client/poll.d.ts +19 -0
  23. package/lib/types/client/store.d.ts +59 -0
  24. package/lib/types/client/wire.d.ts +147 -0
  25. package/lib/types/codebuddy.d.ts +187 -0
  26. package/lib/types/constants.d.ts +67 -0
  27. package/lib/types/credit-store.d.ts +25 -0
  28. package/lib/types/image-tool.d.ts +34 -0
  29. package/lib/types/index.d.ts +79 -0
  30. package/lib/types/login.d.ts +43 -0
  31. package/lib/types/model-enrich.d.ts +125 -0
  32. package/lib/types/prefs.d.ts +43 -0
  33. package/lib/types/rpc-route.d.ts +25 -0
  34. package/lib/types/selection.d.ts +35 -0
  35. package/lib/types/serialize.d.ts +37 -0
  36. package/lib/types/session-store.d.ts +63 -0
  37. package/lib/types/session.d.ts +415 -0
  38. package/lib/types/settings.d.ts +18 -0
  39. package/lib/types/sse.d.ts +22 -0
  40. package/lib/types/storage.d.ts +118 -0
  41. package/lib/types/translate.d.ts +61 -0
  42. package/lib/types/types.d.ts +345 -0
  43. package/lib/types/usage.d.ts +109 -0
  44. package/package.json +119 -0
package/lib/index.js ADDED
@@ -0,0 +1,3957 @@
1
+ import { gzipSync } from "node:zlib";
2
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, ToolCallId, contentHasImage, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import { promises } from "node:fs";
5
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
6
+ import { dirname } from "node:path";
7
+ import { EventSourceParserStream } from "eventsource-parser/stream";
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { spawn } from "node:child_process";
10
+ import z from "@deepseek-ai/schemastery";
11
+ //#region lib/types/constants.js
12
+ /**
13
+ * CodeBuddy 服务的固定事实。
14
+ *
15
+ * 这些是协议常量而非用户配置:endpoint 同时承载 OAuth 握手与模型目录,
16
+ * 版本字符串则是服务期望插件客户端用于表明自身身份的内容。
17
+ *
18
+ * @module dsh-llm-codebuddy-power/constants
19
+ */
20
+ /** 本插件在 `ctx.llm` 上注册的 provider 路由。 */
21
+ const CODEBUDDY_PROVIDER = "codebuddy";
22
+ /** 在模型选择器与设置界面中展示的显示名。 */
23
+ const CODEBUDDY_DISPLAY_NAME = "CodeBuddy";
24
+ /** CodeBuddy 服务根地址;认证握手与 `/v3/config` 都在此处。 */
25
+ const CODEBUDDY_ENDPOINT = "https://copilot.tencent.com";
26
+ /**
27
+ * 兼容 OpenAI 的对话基础地址。只有对话 wire 路由兼容;`/v3/config` 处的
28
+ * 模型目录并不兼容,这就是本插件自带目录读取器、而不使用 OpenAI
29
+ * `GET /models` 列表的原因。
30
+ */
31
+ const CODEBUDDY_CHAT_BASE = `${CODEBUDDY_ENDPOINT}/v2`;
32
+ /** 在对话与模型目录请求中上报的 IDE 版本,与官方 CodeBuddy CN 4.11.3 客户端一致。 */
33
+ const CODEBUDDY_IDE_VERSION = "4.11.3";
34
+ /** 官方 IDE 的产品段名称,用于拼接后的 `User-Agent`。 */
35
+ const CODEBUDDY_PRODUCT_NAME = "CodeBuddy";
36
+ /**
37
+ * 官方 IDE 主进程 auth/billing 客户端发送的 User-Agent。
38
+ *
39
+ * 登录与计量请求在 `main.js` 中使用裸 axios 实例(没有拦截器链),因此携带
40
+ * 的是 axios 默认 UA,而不是 CustomFetch 平面使用的 IDE 身份 UA。
41
+ * 实测抓包:`/v2/plugin/auth/*` 与 `/v2/billing/meter/*` 都发送 `axios/1.15.0`。
42
+ */
43
+ const CODEBUDDY_AXIOS_USER_AGENT = "axios/1.15.0";
44
+ /**
45
+ * 目录完全未描述的模型所假定的上下文容量。
46
+ *
47
+ * 这是约定值,不是 CodeBuddy 提供的数字:服务按模型披露 `maxAllowedSize`,
48
+ * 不提供可回退的全局默认值。因此已列出的模型绝不会按此定尺寸——隐去容量的
49
+ * 条目会直接从头列表中丢弃。它只适用于目录未列出、却被显式指名的 id,
50
+ * 此时必须有假定值才能解析出路由。
51
+ */
52
+ const DEFAULT_CONTEXT_WINDOW = 128e3;
53
+ /** 未列出模型所假定的输出上限;同上的约定值。 */
54
+ const DEFAULT_MAX_TOKENS = 8192;
55
+ /** 有一次流读取尚未完成时,provider 的默认最大空闲时间。 */
56
+ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
57
+ /**
58
+ * 单张请求图片在 base64 膨胀前的编码字节目标。
59
+ *
60
+ * 这是本插件对请求图片的唯一硬性约束:源图未超过它就不做任何转换。取值与
61
+ * 官方 CodeBuddy IDE 的拖放路径一致(`TARGET_SIZE_MB = 1`)。
62
+ */
63
+ const REQUEST_IMAGE_MAX_BYTES = 1024 * 1024;
64
+ /**
65
+ * 源图超过字节目标时逐档收紧的长边档位。
66
+ *
67
+ * 档位取自官方 CodeBuddy IDE 拖放路径的 `COMPRESS_ATTEMPTS`(1920、1440、1080)。
68
+ * 需要逐档重试是因为附件服务的编码阶梯只降质量、不再降尺寸,单靠一档无法保证
69
+ * 落到字节目标。
70
+ */
71
+ const REQUEST_IMAGE_DIMENSION_LADDER = [
72
+ 1920,
73
+ 1440,
74
+ 1080
75
+ ];
76
+ /** 浏览器登录流程等待用户完成的时长,单位 ms。 */
77
+ const LOGIN_TIMEOUT_MS = 600 * 1e3;
78
+ /** 等待浏览器登录完成时的轮询间隔,单位 ms。 */
79
+ const LOGIN_POLL_INTERVAL_MS = 1e3;
80
+ /** 含义为“浏览器登录尚未完成”的服务码。 */
81
+ const AUTH_PENDING_CODE = 11217;
82
+ //#endregion
83
+ //#region lib/types/codebuddy.js
84
+ /**
85
+ * CodeBuddy 控制面客户端:浏览器 OAuth 握手、令牌刷新,
86
+ * 以及非 OpenAI 模型目录。
87
+ *
88
+ * 这里的每次调用都以 `{code, msg, data}` 信封而非仅 HTTP 状态码
89
+ * 表达结果,因此返回 200 却带有非零 `code` 即为失败,并
90
+ * 按失败上报。本模块只负责传输:不持有状态,也不做
91
+ * 策略决策,因此登录流程、适配器与 CLI 得以共用
92
+ * 同一份实现。
93
+ *
94
+ * @module dsh-llm-codebuddy-power/codebuddy
95
+ */
96
+ /**
97
+ * 官方 CodeBuddy CN 4.11.3 客户端在其 CustomFetch 面(chat、catalog)上
98
+ * 发送的组合 IDE `User-Agent`。实测抓包为 `CodeBuddyIDE/4.11.3 CodeBuddy/4.11.3`。
99
+ * @returns IDE user-agent 字符串。
100
+ */
101
+ function ideUserAgent() {
102
+ return `CodeBuddyIDE/${CODEBUDDY_IDE_VERSION} ${CODEBUDDY_PRODUCT_NAME}/${CODEBUDDY_IDE_VERSION}`;
103
+ }
104
+ /**
105
+ * 官方 IDE 主进程的认证/计费客户端发送的 `User-Agent`。
106
+ *
107
+ * `main.js` 在一个裸 axios 实例上发起登录与计量请求(无拦截器
108
+ * 链),因此它们携带 axios 默认 UA —— 实测在 `/v2/plugin/auth/*` 与
109
+ * `/v2/billing/meter/*` 上抓到的值为 `axios/1.15.0`。
110
+ * @returns axios user-agent 字符串。
111
+ */
112
+ function axiosUserAgent() {
113
+ return CODEBUDDY_AXIOS_USER_AGENT;
114
+ }
115
+ /**
116
+ * 登录握手与计量共用的 axios 面请求头:
117
+ * axios 默认的 `Accept` 与 `User-Agent`,外加每次调用各自的头。
118
+ *
119
+ * 在官方 IDE 中这两个面运行在同一个裸 axios 客户端上,
120
+ * 因此尽管各自的额外头不同,帧结构仍相同。
121
+ * @param extra - 请求专属头(`X-No-*`、`Authorization` 等)。
122
+ * @returns 请求头。
123
+ */
124
+ function axiosRequestHeaders(extra) {
125
+ return {
126
+ "Accept": "application/json, text/plain, */*",
127
+ "User-Agent": axiosUserAgent(),
128
+ ...extra
129
+ };
130
+ }
131
+ function delay(ms, signal) {
132
+ return new Promise((resolve, reject) => {
133
+ if (signal?.aborted) {
134
+ reject(/* @__PURE__ */ new Error("aborted"));
135
+ return;
136
+ }
137
+ const timer = setTimeout(() => {
138
+ signal?.removeEventListener("abort", onAbort);
139
+ resolve();
140
+ }, ms);
141
+ const onAbort = () => {
142
+ clearTimeout(timer);
143
+ reject(/* @__PURE__ */ new Error("aborted"));
144
+ };
145
+ signal?.addEventListener("abort", onAbort, { once: true });
146
+ });
147
+ }
148
+ /**
149
+ * 发起一次浏览器登录握手。
150
+ * @param signal - 可选取消。
151
+ * @returns 握手状态,以及用户必须打开的 URL。
152
+ * @throws Error 服务拒绝或返回不可用的响应体时。
153
+ */
154
+ async function requestAuthState(signal) {
155
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/state?platform=ide`, {
156
+ method: "POST",
157
+ headers: axiosRequestHeaders({
158
+ "Content-Type": "application/x-www-form-urlencoded",
159
+ "X-No-Authorization": "true",
160
+ "X-No-User-Id": "true",
161
+ "X-No-Enterprise-Id": "true"
162
+ }),
163
+ ...signal === void 0 ? {} : { signal }
164
+ });
165
+ if (!response.ok) throw new Error(`CodeBuddy auth state request failed (HTTP ${response.status})`);
166
+ const body = await response.json();
167
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy auth state request failed: ${body.code} - ${body.msg}`);
168
+ return body.data;
169
+ }
170
+ /**
171
+ * 轮询,直到用户在浏览器中完成登录。
172
+ *
173
+ * 服务用 code {@link AUTH_PENDING_CODE} 表示“尚未完成”,
174
+ * 它是唯一让循环继续的 code;其他任何 code 都是已决结果,循环
175
+ * 就此结束。传输错误同样结束循环,因为状态可能已被消耗的
176
+ * 握手不得静默重试。
177
+ * @param state - 来自 {@link requestAuthState} 的握手 id。
178
+ * @param signal - 可选取消。
179
+ * @returns 签发的令牌;登录失败或超时时为 `undefined`。
180
+ */
181
+ async function pollAuthToken(state, signal) {
182
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
183
+ while (Date.now() < deadline) {
184
+ await delay(LOGIN_POLL_INTERVAL_MS, signal);
185
+ let response;
186
+ try {
187
+ response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/token?state=${encodeURIComponent(state)}`, {
188
+ method: "GET",
189
+ headers: axiosRequestHeaders({ "X-No-Authorization": "true" }),
190
+ ...signal === void 0 ? {} : { signal }
191
+ });
192
+ } catch {
193
+ return;
194
+ }
195
+ if (!response.ok) continue;
196
+ const body = await response.json();
197
+ if (body.code === 11217) continue;
198
+ if (body.code !== 0) return void 0;
199
+ return body.data;
200
+ }
201
+ }
202
+ /**
203
+ * 读取已登录账号,其 uid 与企业 id 会成为之后每个请求的必需
204
+ * 请求头。
205
+ * @param state - 令牌所对应的握手 id。
206
+ * @param accessToken - 刚签发的 access token。
207
+ * @param domain - 令牌所对应的租户 domain。
208
+ * @returns 账号信息。
209
+ * @throws Error 服务拒绝或返回不可用的响应体时。
210
+ */
211
+ async function getLoginAccount(state, accessToken, domain) {
212
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/login/account?state=${encodeURIComponent(state)}`, {
213
+ method: "GET",
214
+ headers: axiosRequestHeaders({
215
+ "Authorization": `Bearer ${accessToken}`,
216
+ "X-No-User-Id": "true",
217
+ "X-No-Enterprise-Id": "true",
218
+ "X-Domain": domain
219
+ })
220
+ });
221
+ if (!response.ok) throw new Error(`CodeBuddy login account request failed (HTTP ${response.status})`);
222
+ const body = await response.json();
223
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy login account request failed: ${body.code} - ${body.msg}`);
224
+ return normalizeAccount(body.data);
225
+ }
226
+ /**
227
+ * 归一化账号,使空字符串字段等同于缺失。
228
+ *
229
+ * 对于租户未披露的字段,CodeBuddy 的账号响应会返回空字符串
230
+ * (而非省略)→ 例如企业账号会带有 `uin: ""`。整条下游链路(storage、
231
+ * auth 状态、设置 UI)只把 `undefined` 当作“不存在”,因此空字符
232
+ * 串会渲染出一行空内容。在此统一裁剪一次即可覆盖所有消费者,
233
+ * 无需各自重复判断。
234
+ * @param account - 来自 wire 的原始账号。
235
+ * @returns 删除可选字符串字段为空值后的账号。
236
+ */
237
+ function normalizeAccount(account) {
238
+ const pick = (value) => value === void 0 || value.length === 0 ? void 0 : value;
239
+ return {
240
+ uid: account.uid,
241
+ nickname: account.nickname,
242
+ ...pick(account.uin) === void 0 ? {} : { uin: account.uin },
243
+ ...pick(account.enterpriseId) === void 0 ? {} : { enterpriseId: account.enterpriseId },
244
+ ...pick(account.enterpriseName) === void 0 ? {} : { enterpriseName: account.enterpriseName },
245
+ ...pick(account.enterpriseUserName) === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
246
+ ...pick(account.departmentFullName) === void 0 ? {} : { departmentFullName: account.departmentFullName }
247
+ };
248
+ }
249
+ /**
250
+ * 用 refresh token 换取新的 access token。
251
+ * @param identity - 当前身份,包含正被替换的 access token。
252
+ * @param refreshToken - 要使用的 refresh token。
253
+ * @returns 新令牌;刷新被拒绝时为 `undefined`。
254
+ */
255
+ async function refreshAccessToken(identity, refreshToken) {
256
+ const headers = axiosRequestHeaders({
257
+ "Authorization": `Bearer ${identity.accessToken}`,
258
+ "X-Domain": identity.domain,
259
+ "X-User-Id": identity.uid,
260
+ "X-Refresh-Token": refreshToken,
261
+ "X-Auth-Refresh-Source": "plugin"
262
+ });
263
+ if (identity.enterpriseId !== void 0) headers["X-Enterprise-Id"] = identity.enterpriseId;
264
+ let response;
265
+ try {
266
+ response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/plugin/auth/token/refresh`, {
267
+ method: "POST",
268
+ headers
269
+ });
270
+ } catch {
271
+ return;
272
+ }
273
+ if (!response.ok) return void 0;
274
+ const body = await response.json();
275
+ if (body.code !== 0 || body.data === void 0) return void 0;
276
+ return body.data;
277
+ }
278
+ /**
279
+ * 一个新的无连字符 uuid;CodeBuddy 各面用作请求 id 与
280
+ * 会话 id 的紧凑形式。
281
+ * @returns 32 个十六进制字符的 id。
282
+ */
283
+ function compactUuid() {
284
+ return randomUUID().replace(/-/g, "");
285
+ }
286
+ /**
287
+ * 一个新的 trace id:32 个小写十六进制字符,即官方客户端的
288
+ * OpenTelemetry span 为 `x-trace-id` / `x-b3-traceid` 生成的形态
289
+ * (16 个随机字节的十六进制)。
290
+ * @returns 32 个十六进制字符的 trace id。
291
+ */
292
+ function traceId() {
293
+ return randomBytes(16).toString("hex");
294
+ }
295
+ /**
296
+ * 一个新的 span id:16 个小写十六进制字符,即官方客户端的
297
+ * OpenTelemetry span 为 `x-b3-spanid` / 父 id 生成的形态
298
+ * (8 个随机字节的十六进制)。
299
+ * @returns 16 个十六进制字符的 span id。
300
+ */
301
+ function spanId() {
302
+ return randomBytes(8).toString("hex");
303
+ }
304
+ /**
305
+ * 每个 CustomFetch 面请求(chat、catalog)都携带的共享客户端身份
306
+ * 头,与官方 IDE 4.11.3 的抓包一致:
307
+ * IDE user-agent、SaaS 产品标记、XMLHttpRequest 来源,以及
308
+ * 每个请求一个新生成的紧凑 `X-Request-ID`。
309
+ *
310
+ * CodeBuddy 的安全策略会拒绝任何它不认作自家客户端的 User-Agent
311
+ * (实测:带 harness 标识的 User-Agent 会被以 HTTP 400 / code 11128
312
+ * 拒绝)。提供方自身的产品身份是唯一被接受的载体,因此本函数是
313
+ * chat 与 catalog 两条传输上该身份的唯一来源。
314
+ * @returns 共享身份头,其中 request id 为新生成。
315
+ */
316
+ function clientIdentityHeaders() {
317
+ return {
318
+ "User-Agent": ideUserAgent(),
319
+ "X-Product": "SaaS",
320
+ "X-Requested-With": "XMLHttpRequest",
321
+ "X-Request-ID": compactUuid()
322
+ };
323
+ }
324
+ /**
325
+ * 一次 chat 请求的 b3 / X-B3 trace 头,外加 CustomFetch 面同步
326
+ * 的原始 trace id 头。所有 id 每次调用都重新生成,
327
+ * 与官方客户端的每请求 span 一致。
328
+ * @returns trace 头,其中 trace/span id 为新生成。
329
+ */
330
+ function traceHeaders() {
331
+ const trace = traceId();
332
+ const span = spanId();
333
+ const parent = spanId();
334
+ return {
335
+ "X-Trace-ID": trace,
336
+ "X-B3-TraceId": trace,
337
+ "X-B3-SpanId": span,
338
+ "X-B3-ParentSpanId": parent,
339
+ "X-B3-Sampled": "1",
340
+ "b3": `${trace}-${span}-1-${parent}`,
341
+ "X-Request-Trace-Id": randomUUID()
342
+ };
343
+ }
344
+ /**
345
+ * 通过 CodeBuddy 的图像模型生成图像。
346
+ *
347
+ * 复刻官方 IDE 的 `ImageGenTool` wire 调用(`POST
348
+ * /v2/images/generations`,模型 `hunyuan-image-alpha`),并发送完整的
349
+ * IDE 身份头,从而让计费控制台按给定的会话 id 将调用方
350
+ * 记为 IDE 客户端。服务收取的 credits 通过 `usage.credit` 带出。
351
+ * @param identityHeaders - 已认证的身份头(`requestIdentity`)。
352
+ * @param model - 文生图模型 id,例如 `hunyuan-image-alpha`。
353
+ * @param args - 生成提示词与选项。
354
+ * @param conversationIds - 为本次请求生成的会话/轮次/消息 id
355
+ * (`X-Conversation-ID`、`X-Conversation-Request-ID`、
356
+ * `X-Conversation-Message-ID`)。
357
+ * @param signal - 可选取消。
358
+ * @returns 生成的图像与计费的 credits。
359
+ * @throws Error 服务拒绝或返回不可用的响应体时。
360
+ */
361
+ async function generateImage(identityHeaders, model, args, conversationIds, signal) {
362
+ const body = {
363
+ model,
364
+ prompt: args.prompt,
365
+ size: args.size ?? "1024x1024",
366
+ response_format: "b64_json"
367
+ };
368
+ if (args.n !== void 0) body.n = args.n;
369
+ if (args.quality !== void 0) body.quality = args.quality;
370
+ if (args.style !== void 0) body.style = args.style;
371
+ const headers = {
372
+ ...identityHeaders,
373
+ ...clientIdentityHeaders(),
374
+ "X-Request-ID": conversationIds.requestId,
375
+ "Content-Type": "application/json",
376
+ "X-Agent-Intent": "craft",
377
+ "X-Conversation-ID": conversationIds.conversationId,
378
+ "X-Conversation-Request-ID": conversationIds.requestId,
379
+ "X-Conversation-Message-ID": conversationIds.messageId,
380
+ "X-Env-ID": "production",
381
+ "X-IDE-Type": "CodeBuddyIDE",
382
+ "X-IDE-Name": "CodeBuddyIDE",
383
+ "X-IDE-Version": CODEBUDDY_IDE_VERSION,
384
+ "X-Product-Version": CODEBUDDY_IDE_VERSION,
385
+ "X-Model-ID": model,
386
+ ...traceHeaders(),
387
+ "Accept": "application/json, text/plain, */*"
388
+ };
389
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v2/images/generations`, {
390
+ method: "POST",
391
+ headers,
392
+ body: JSON.stringify(body),
393
+ ...signal === void 0 ? {} : { signal }
394
+ });
395
+ if (!response.ok) throw new Error(`CodeBuddy image generation failed (HTTP ${response.status})`);
396
+ const envelope = await response.json();
397
+ if (envelope.code !== 0 || envelope.data === void 0) throw new Error(`CodeBuddy image generation failed: ${envelope.code ?? "unknown"} - ${envelope.msg ?? "empty data"}`);
398
+ return {
399
+ images: (envelope.data.data ?? []).map((image) => ({
400
+ ...image.url === void 0 ? {} : { url: image.url },
401
+ ...image.b64_json === void 0 ? {} : { b64_json: image.b64_json }
402
+ })),
403
+ usedCredits: envelope.data.usage?.credit ?? 0
404
+ };
405
+ }
406
+ /**
407
+ * 读取 CodeBuddy 模型目录。
408
+ *
409
+ * 这是该服务中非 OpenAI 兼容的那一半,也是本插件无法被通用
410
+ * OpenAI 兼容路由取代的原因:该响应会披露每个模型的能力标记与
411
+ * 尺寸,而 `GET /models` 列表不会。
412
+ * @param identityHeaders - 已认证的身份头,由会话构建一次
413
+ * (`requestIdentity()`),使 chat 与目录共用同一套。
414
+ * @param signal - 可选取消。
415
+ * @returns 目录。
416
+ * @throws Error 服务拒绝或返回不可用的响应体时。
417
+ */
418
+ async function getConfig(identityHeaders, signal) {
419
+ const headers = {
420
+ ...identityHeaders,
421
+ ...clientIdentityHeaders(),
422
+ "Accept": "application/json"
423
+ };
424
+ const response = await fetch(`${CODEBUDDY_ENDPOINT}/v3/config`, {
425
+ method: "GET",
426
+ headers,
427
+ ...signal === void 0 ? {} : { signal }
428
+ });
429
+ if (!response.ok) throw new Error(`CodeBuddy config request failed (HTTP ${response.status})`);
430
+ const body = await response.json();
431
+ if (body.code !== 0 || body.data === void 0) throw new Error(`CodeBuddy config request failed: ${body.code} - ${body.msg}`);
432
+ return body.data;
433
+ }
434
+ //#endregion
435
+ //#region lib/types/usage.js
436
+ /**
437
+ * CodeBuddy 配额/用量计量器:获取并解析剩余额度。
438
+ *
439
+ * CodeBuddy 对计费面的拆分方式与 gproxy 参考实现相同:企业租户回应
440
+ * `get-enterprise-user-usage`(单个 limit/credit 对),而个人账户回应
441
+ * `get-user-resource`(每个生效套餐一个窗口)。两种结构除了每个 CodeBuddy
442
+ * 请求都携带的认证头之外没有任何共同点,因此传输与解析路径只在已登录账户是否
443
+ * 披露了 `enterpriseId` 这一点上分叉一次。
444
+ *
445
+ * 每个进入 Web 客户端的值都是普通 number/string,因此这里返回的
446
+ * {@link UsageSnapshot} 是自有数据 → 不会有活的会话对象逃出本模块。
447
+ *
448
+ * @module dsh-llm-codebuddy-power/usage
449
+ */
450
+ /**
451
+ * 每个 CodeBuddy 计量请求携带的认证头。
452
+ *
453
+ * 与官方 IDE 主进程的计量调用(`main.js` 中的 `getPersonalUsage`)一致:
454
+ * 在裸 axios 客户端上运行,请求只发送 `Authorization` + `X-User-Id` 以及
455
+ * axios 自身的帧头 — 不含 `X-Domain`/`X-Enterprise-Id`/`X-Tenant-Id`/
456
+ * `X-Department-Info`。这一点已针对 `/v2/billing/meter/get-user-resource`
457
+ * 做过线上验证(抓包恰好显示 `Accept`、`Content-Type`、`Authorization`、
458
+ * `X-User-Id`、`User-Agent: axios/1.15.0`)。
459
+ * @param identity - 已登录的身份。
460
+ * @returns 请求头。
461
+ */
462
+ function meterHeaders(identity) {
463
+ return axiosRequestHeaders({
464
+ "Content-Type": "application/json",
465
+ "Authorization": `Bearer ${identity.accessToken}`,
466
+ "X-User-Id": identity.uid
467
+ });
468
+ }
469
+ /** 读取一个可能以数字或数字字符串形式到达的数值字段。 */
470
+ function number(value, key) {
471
+ if (value === null || typeof value !== "object") return void 0;
472
+ const raw = value[key];
473
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
474
+ if (typeof raw === "string") {
475
+ const parsed = Number(raw);
476
+ return Number.isFinite(parsed) ? parsed : void 0;
477
+ }
478
+ }
479
+ /** 读取一个非空字符串字段。 */
480
+ function string(value, key) {
481
+ if (value === null || typeof value !== "object") return void 0;
482
+ const raw = value[key];
483
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
484
+ }
485
+ /** 沿一条对象键链穿过 JSON 值,返回叶子值或 undefined。 */
486
+ function pointer(value, path) {
487
+ let current = value;
488
+ for (const key of path) {
489
+ if (current === null || typeof current !== "object") return void 0;
490
+ current = current[key];
491
+ }
492
+ return current;
493
+ }
494
+ /**
495
+ * 把 Unix 时间戳按本地时区格式化为 `YYYY-MM-DD HH:mm:ss`,即个人计量面
496
+ * 为其 `SlicePeriod*` 边界所期望的形式。
497
+ *
498
+ * gproxy 参考实现按 UTC 格式化;只要两个边界采用同一约定,CodeBuddy 服务
499
+ * 都接受,而本地格式化与 IDE 客户端发送的一致,因此这次读取更不容易落在
500
+ * 服务端自身预期之外。
501
+ * @param timestamp - Unix 秒。
502
+ * @returns 格式化后的时间戳。
503
+ */
504
+ function formatTime(timestamp) {
505
+ const date = /* @__PURE__ */ new Date(timestamp * 1e3);
506
+ const pad = (n) => n < 10 ? `0${n}` : String(n);
507
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
508
+ }
509
+ /**
510
+ * 归一化个人套餐的重置时间戳。
511
+ *
512
+ * CodeBuddy 把套餐的 `CycleEndTime` 上报为其最后一个有效日的结束时刻
513
+ * (`23:59:59`)。这读起来像是“午夜前重置”,但配额实际在次日 `00:00:00`
514
+ * 重置,因此当该值以 `23:59:59` 结尾时,显示值向后一天顺延一秒。其他任何值
515
+ * 原样通过,无法解析的时间戳按原样返回而不是丢弃(有这个数字总比没有好)。
516
+ * @param raw - `CycleEndTime` 字符串,`YYYY-MM-DD HH:mm:ss`。
517
+ * @returns 归一化后的时间戳字符串。
518
+ */
519
+ function normalizeResetTime(raw) {
520
+ if (!raw.endsWith("23:59:59")) return raw;
521
+ const date = new Date(raw.replace(" ", "T"));
522
+ if (Number.isNaN(date.getTime())) return raw;
523
+ date.setSeconds(date.getSeconds() + 1);
524
+ const pad = (n) => n < 10 ? `0${n}` : String(n);
525
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
526
+ }
527
+ /**
528
+ * 判断某个套餐显示名是否表示一次性赠送额度。
529
+ *
530
+ * CodeBuddy 的个人计量面把推荐/裂变额度命名为“裂变包”,未来任何促销都可能
531
+ * 带有“赠送”;其他一切(试用/可续期主套餐、企业行)都视为主池。显示名是
532
+ * 计量面暴露的唯一区分依据,因此两种写法都会被匹配。
533
+ * @param name - 窗口的显示名。
534
+ * @returns 赠送套餐名返回 true。
535
+ */
536
+ function isGiftName(name) {
537
+ return /裂变|赠送/.test(name);
538
+ }
539
+ /**
540
+ * 把有可用容量的窗口聚合成一个池(used/limit 求和)。
541
+ * 容量未披露或非正数的窗口会退出 — 它们仍留在 `windows` 中。
542
+ * @param windows - 候选窗口。
543
+ * @returns 该池,或当没有可用容量的窗口匹配时不返回。
544
+ */
545
+ function poolOf(windows) {
546
+ let used = 0;
547
+ let limit = 0;
548
+ for (const window of windows) {
549
+ if (window.used === void 0 || window.limit === void 0 || window.limit <= 0) continue;
550
+ used += window.used;
551
+ limit += window.limit;
552
+ }
553
+ if (limit <= 0) return void 0;
554
+ return {
555
+ used,
556
+ limit
557
+ };
558
+ }
559
+ /** 有效的赠送窗口:仍有上限且尚未完全耗尽。 */
560
+ function liveGiftWindows(windows) {
561
+ return windows.filter((window) => window.used !== void 0 && window.limit !== void 0 && window.limit > 0 && window.used < window.limit);
562
+ }
563
+ /**
564
+ * 构建个人快照的主池/赠送池划分,以及提示框的赠送明细(最早过期的有效赠送
565
+ * 在前,最多 5 个)。
566
+ * @param windows - 已解析的窗口。
567
+ * @returns 该划分,仅当对应池有可用 limit 时才包含 `main`/`gift`。
568
+ */
569
+ function splitPools(windows) {
570
+ const mainWindows = [];
571
+ const giftWindows = [];
572
+ for (const window of windows) if (isGiftName(window.name)) giftWindows.push(window);
573
+ else mainWindows.push(window);
574
+ const main = poolOf(mainWindows);
575
+ const live = liveGiftWindows(giftWindows);
576
+ const giftPool = poolOf(live);
577
+ if (giftPool === void 0) return { ...main === void 0 ? {} : { main } };
578
+ const soonest = live.toSorted((a, b) => {
579
+ if (a.resetsAt !== void 0 && b.resetsAt !== void 0) return a.resetsAt.localeCompare(b.resetsAt);
580
+ if (a.resetsAt !== void 0) return -1;
581
+ return b.resetsAt === void 0 ? 0 : 1;
582
+ }).slice(0, 5);
583
+ return {
584
+ ...main === void 0 ? {} : { main },
585
+ gift: {
586
+ used: giftPool.used,
587
+ limit: giftPool.limit,
588
+ soonest
589
+ }
590
+ };
591
+ }
592
+ /**
593
+ * 解析个人账户的 `get-user-resource` 响应。
594
+ *
595
+ * 视请求经过的网关而定,accounts 数组可能位于多个指针根之下;会按顺序逐一
596
+ * 尝试,以找到的第一个数组为准。每个账户贡献一个以其套餐命名的
597
+ * {@link UsageWindow}(披露了显示名时用显示名,否则用套餐代码),其中 `used`
598
+ * 由 `limit - remaining` 推导(因此超过上限的剩余值会被钳制为零已用,而不是
599
+ * 负数)。
600
+ * @param accounts - 定位到的 accounts 数组。
601
+ * @returns 组装好的快照。
602
+ */
603
+ function personalUsage(accounts) {
604
+ const windows = accounts.map((resource, index) => {
605
+ const limit = number(resource, "CycleCapacitySizePrecise") ?? 0;
606
+ const left = number(resource, "CycleCapacityRemainPrecise") ?? 0;
607
+ const used = Math.max(limit - left, 0);
608
+ const name = string(resource, "PackageName") ?? string(resource, "PackageCode") ?? string(resource, "ResourceId") ?? `resource_${index}`;
609
+ const rawReset = string(resource, "CycleEndTime");
610
+ const resetsAt = rawReset === void 0 ? void 0 : normalizeResetTime(rawReset);
611
+ if (limit <= 0) return {
612
+ name,
613
+ ...resetsAt === void 0 ? {} : { resetsAt }
614
+ };
615
+ return {
616
+ name,
617
+ used,
618
+ limit,
619
+ ...resetsAt === void 0 ? {} : { resetsAt }
620
+ };
621
+ });
622
+ const split = splitPools(windows);
623
+ const first = windows[0];
624
+ return {
625
+ windows,
626
+ ...first?.resetsAt === void 0 ? {} : { primaryResetsAt: first.resetsAt },
627
+ ...split.main === void 0 ? {} : { main: split.main },
628
+ ...split.gift === void 0 ? {} : { gift: split.gift }
629
+ };
630
+ }
631
+ /**
632
+ * 解析企业租户的 `get-enterprise-user-usage` 响应。
633
+ *
634
+ * 企业计费面在 `data` 下(网关未包装时则在根上)上报单个 `limitNum`/`credit`
635
+ * 对,因此只构建一个窗口。
636
+ * @param data - 承载这些数字的 data 对象。
637
+ * @returns 组装好的快照,或未披露任何 limit 时为 `undefined`。
638
+ */
639
+ function enterpriseUsage(data) {
640
+ const limit = number(data, "limitNum");
641
+ if (limit === void 0) return void 0;
642
+ const used = number(data, "credit") ?? 0;
643
+ const reset = string(data, "cycleResetTime");
644
+ const window = {
645
+ name: "enterprise",
646
+ used,
647
+ limit,
648
+ ...reset === void 0 ? {} : { resetsAt: reset }
649
+ };
650
+ const main = poolOf([window]);
651
+ return {
652
+ windows: [window],
653
+ ...reset === void 0 ? {} : { primaryResetsAt: reset },
654
+ ...main === void 0 ? {} : { main }
655
+ };
656
+ }
657
+ /**
658
+ * 把一次计量响应解析为快照。
659
+ *
660
+ * gproxy 参考实现会在其已被观察到使用的每个指针根下尝试个人 `Accounts`
661
+ * 数组,只有在没有任何数组匹配时才回退到企业单窗口解析 → 与发出的请求路径
662
+ * 无关。沿用这一顺序,可以让恰好携带 `enterpriseId` 的个人账户(或反之)
663
+ * 按它实际回应的结构解析,而不是按其凭据暗示的结构解析。
664
+ * @param raw - 已解析的响应体。
665
+ * @returns 组装好的快照,或响应体不含任何可解析内容时为 `undefined`。
666
+ */
667
+ function parseUsage(raw) {
668
+ for (const path of [
669
+ [
670
+ "data",
671
+ "Response",
672
+ "Data",
673
+ "Accounts"
674
+ ],
675
+ [
676
+ "data",
677
+ "data",
678
+ "Response",
679
+ "Data",
680
+ "Accounts"
681
+ ],
682
+ [
683
+ "Response",
684
+ "Data",
685
+ "Accounts"
686
+ ]
687
+ ]) {
688
+ const candidate = pointer(raw, path);
689
+ if (Array.isArray(candidate)) return personalUsage(candidate);
690
+ if (candidate === null) return { windows: [] };
691
+ }
692
+ return enterpriseUsage(pointer(raw, ["data", "data"]) ?? pointer(raw, ["data"]) ?? raw);
693
+ }
694
+ /**
695
+ * 向计量端点 POST 并解析响应,所有失败模式都退化为 `undefined` 而不抛出。
696
+ *
697
+ * 用量是设置界面上的参考性读取,因此传输故障、非 2xx 状态、无法解析的响应体
698
+ * 或服务端非零 `code` 都意味着“不显示用量” → 绝不会出现坏掉的侧边栏底部。
699
+ * @param identity - 已登录的身份,由会话刷新。
700
+ * @param path - {@link CODEBUDDY_ENDPOINT} 下的计量路径。
701
+ * @param body - JSON 请求体。
702
+ * @param signal - 可选的取消信号。
703
+ * @returns 已解析的快照,或计费面不可达、回应了不可用响应体时为
704
+ * `undefined`。
705
+ */
706
+ async function postMeter(identity, path, body, signal) {
707
+ let response;
708
+ try {
709
+ response = await fetch(`${CODEBUDDY_ENDPOINT}${path}`, {
710
+ method: "POST",
711
+ headers: meterHeaders(identity),
712
+ body,
713
+ ...signal === void 0 ? {} : { signal }
714
+ });
715
+ } catch {
716
+ return;
717
+ }
718
+ if (!response.ok) return void 0;
719
+ let raw;
720
+ try {
721
+ raw = await response.json();
722
+ } catch {
723
+ return;
724
+ }
725
+ const envelope = raw;
726
+ if (envelope !== null && typeof envelope === "object" && envelope.code !== void 0 && envelope.code !== 0) return;
727
+ return parseUsage(raw);
728
+ }
729
+ /**
730
+ * 获取个人账户的用量:每个生效套餐一个窗口。
731
+ *
732
+ * 请求体与官方 IDE 的 `getPersonalUsage` 完全一致(线上抓取):
733
+ * `PageNumber:1, PageSize:100, ProductCode:"p_tcaca", Status:[0,3]`
734
+ * 以及从当前时刻起 101 年的 `PackageEndTimeRange` 窗口 — 起点是“现在”,
735
+ * 终点是 101 年后,依据 `main.js`(`s = new Date`,
736
+ * `n = s + 101*365*24*60*60*1000`)。
737
+ * @param identity - 已登录的身份,由会话刷新。
738
+ * @param signal - 可选的取消信号。
739
+ * @returns 已解析的快照,或计费面不可达时为 `undefined`。
740
+ */
741
+ async function fetchPersonalUsage(identity, signal) {
742
+ const now = /* @__PURE__ */ new Date();
743
+ const end = new Date(now.getTime() + 101 * 365 * 24 * 60 * 60 * 1e3);
744
+ return postMeter(identity, "/v2/billing/meter/get-user-resource", JSON.stringify({
745
+ PageNumber: 1,
746
+ PageSize: 100,
747
+ ProductCode: "p_tcaca",
748
+ Status: [0, 3],
749
+ PackageEndTimeRangeBegin: formatTime(now.getTime() / 1e3),
750
+ PackageEndTimeRangeEnd: formatTime(end.getTime() / 1e3)
751
+ }), signal);
752
+ }
753
+ /**
754
+ * 获取企业租户的用量:单个 `limitNum`/`credit` 对。
755
+ *
756
+ * 企业计费面接受空请求体并在 `data` 下回应,因此该请求就是一次带认证的 POST。
757
+ * @param identity - 已登录的身份,由会话刷新。
758
+ * @param signal - 可选的取消信号。
759
+ * @returns 已解析的快照,或计费面不可达时为 `undefined`。
760
+ */
761
+ async function fetchEnterpriseUsage(identity, signal) {
762
+ return postMeter(identity, "/v2/billing/meter/get-enterprise-user-usage", "{}", signal);
763
+ }
764
+ /**
765
+ * 获取并解析 CodeBuddy 用量快照,按账户类型分叉。
766
+ *
767
+ * 视已登录身份是否披露了 `enterpriseId`,委托给 {@link fetchPersonalUsage}
768
+ * 或 {@link fetchEnterpriseUsage}。所有失败模式都解析为 `undefined` 而不抛出;
769
+ * 是否重试由调用方决定。
770
+ * @param identity - 已登录的身份,由会话刷新。
771
+ * @param signal - 可选的取消信号。
772
+ * @returns 已解析的快照,或计费面不可达、回应了不可用响应体时为
773
+ * `undefined`。
774
+ */
775
+ async function fetchUsage(identity, signal) {
776
+ return identity.enterpriseId !== void 0 ? fetchEnterpriseUsage(identity, signal) : fetchPersonalUsage(identity, signal);
777
+ }
778
+ //#endregion
779
+ //#region lib/types/atomic-file.js
780
+ /**
781
+ * 插件自有 JSON 文件的写入底座:原子替换 + 按路径串行化。
782
+ *
783
+ * 三个存储(账号、会话选择、每轮积分)都是读-改-写的文件。原子替换让
784
+ * 残缺内容永远不会留在磁盘上被当成一份读不出的记录;按路径排队让并发写入
785
+ * 串行执行 —— 后一次读得到前一次的结果,而不是各自基于同一份旧内容覆盖对方。
786
+ * 集中在这里,使三个存储不会各自实现一遍,也不会各自漏掉队列。
787
+ *
788
+ * @module dsh-llm-codebuddy-power/atomic-file
789
+ */
790
+ /** 每个文件一条写队列,保存当前队尾。 */
791
+ const queues = /* @__PURE__ */ new Map();
792
+ /**
793
+ * 排队一次针对某文件的写:前一次写完成后才开始执行。
794
+ *
795
+ * 队尾保存的是已消化失败的 promise —— 它的拒绝无人 await,留着会变成未处理的
796
+ * rejection;调用方从返回的 promise 收到同一个失败。
797
+ * @param path - 被写的文件。
798
+ * @param write - 实际写入;在同一路径的写之间串行执行。
799
+ * @returns 本次写入的结果;失败时以该失败拒绝。
800
+ */
801
+ function queueWrite(path, write) {
802
+ const next = (queues.get(path) ?? Promise.resolve()).then(write);
803
+ const settled = next.then(() => void 0, () => void 0);
804
+ queues.set(path, settled);
805
+ settled.then(() => {
806
+ if (queues.get(path) === settled) queues.delete(path);
807
+ });
808
+ return next;
809
+ }
810
+ /**
811
+ * 以原子替换写入一个文件,并在支持的文件系统上收紧为仅属主可读。
812
+ *
813
+ * 先写同目录下的临时文件再改名:改名在同一文件系统内是原子的,因此读到的
814
+ * 要么是旧内容要么是新内容,不会是写了一半的内容。
815
+ *
816
+ * 内容是调用方给的字符串而不是由本函数序列化:`JSON.stringify` 的默认输出是一个
817
+ * 字节的重复都没有的紧凑格式,可读性全靠调用方自己插入的换行 —— 需要控制"哪里换行"
818
+ * 的存储因此各自决定格式(见 `session-store.ts` 的逐轮一行)。
819
+ * @param path - 目标文件。
820
+ * @param content - 要写入的完整内容。
821
+ */
822
+ async function writeFileAtomic(path, content) {
823
+ await promises.mkdir(dirname(path), { recursive: true });
824
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
825
+ try {
826
+ await promises.writeFile(temp, content, {
827
+ encoding: "utf-8",
828
+ mode: 384
829
+ });
830
+ await promises.rename(temp, path);
831
+ } catch (error) {
832
+ await promises.unlink(temp).catch(() => {});
833
+ throw error;
834
+ }
835
+ await promises.chmod(path, 384).catch(() => {});
836
+ }
837
+ /**
838
+ * 以原子替换写入一个 JSON 文件,缩进两格。
839
+ *
840
+ * 缩进是给需要肉眼排查的凭据文件用的(`storage.ts` 的 refresh token 与过期时间),
841
+ * 不是通用默认值:批量记录类文件改用 {@link writeFileAtomic} 自己排版。
842
+ * @param path - 目标文件。
843
+ * @param value - 要序列化的值。
844
+ */
845
+ async function writeJsonAtomic(path, value) {
846
+ await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
847
+ }
848
+ //#endregion
849
+ //#region lib/types/session-store.js
850
+ /**
851
+ * 插件自有的会话信息持久化:按会话一个文件,存该会话选用的账号、用过的账号表与
852
+ * 每轮消耗的积分。
853
+ *
854
+ * 这三件事都是"这个会话自己的事实",因此放在同一个文件里
855
+ * (`$DSH_HOME/data/plugins/llm-codebuddy-power/sessions/<会话id>.json`):读一次
856
+ * 就拿到它们,也不会出现两份记录各自指向不同账号。文件是纯增量记录 —— 缺文件等价于
857
+ * "没选过账号、没有积分"。写入是读-改-写的原子替换,并经 [atomic-file.ts](./atomic-file.ts)
858
+ * 按路径串行化:一轮里画图与聊天会各自累加一次积分,分开读-改-写会丢掉其中一次。
859
+ *
860
+ * 文件只增不删:会话被销毁(一次性子代理跑完即如此)不等于用户删除了会话。
861
+ *
862
+ * **账号在表里存一次,每轮记录只引用它的序号**:同一个账号会在几十轮里反复出现,逐轮
863
+ * 内联昵称是同一份事实的重复副本 —— 实测这让文件大了近一倍。键是文件内部自己编的序号
864
+ * 而不是 uid 的任何截断:一个只在本文件里做引用的键不必携带 uid 的信息,截断反而会
865
+ * 让两个同前缀的账号共用一条记录(把历史轮次张冠李戴),而序号不可能重复。
866
+ *
867
+ * 排版也在这里决定:`credits` 每条一行、`accounts` 每条一行,其余保持缩进。逐条一行
868
+ * 让追加一轮只改动两行,而整个文件一行虽然更小,却让文件不可读、改动无法辨别。
869
+ *
870
+ * @module dsh-llm-codebuddy-power/session-store
871
+ */
872
+ /** 空信息:文件缺失、损坏或字段不合法时的结果。 */
873
+ function emptyInfo() {
874
+ return {
875
+ accounts: {},
876
+ credits: {}
877
+ };
878
+ }
879
+ /** 会话信息文件路径:`$DSH_HOME/data/plugins/llm-codebuddy-power/sessions/<会话id>.json`。
880
+ * 插件数据统一放 `data/plugins/<插件名(不带dsh-前缀)>/` 下,`data` 是规范的数据目录。 */
881
+ function sessionInfoPath(sessionId) {
882
+ return dshHomePath("data", "plugins", "llm-codebuddy-power", "sessions", `${sessionId}.json`);
883
+ }
884
+ /**
885
+ * 账号在账号表里的序号。
886
+ *
887
+ * 序号从 0 起、按首次用到该账号的顺序分配,并保留在表里 —— 记录引用的是序号,因此
888
+ * 序号不能随表内增删而改变,否则已有轮次会指向别的账号。
889
+ * @param table - 当前的账号表。
890
+ * @param uid - 要引用的账号 uid。
891
+ * @returns 该账号的序号;从未用过时是下一个未占用的序号。
892
+ */
893
+ function accountKeyFor(table, uid) {
894
+ for (const [key, ref] of Object.entries(table)) if (ref.uid === uid) return key;
895
+ let next = 0;
896
+ for (const key of Object.keys(table)) {
897
+ const n = Number(key);
898
+ if (Number.isSafeInteger(n) && n >= next) next = n + 1;
899
+ }
900
+ return String(next);
901
+ }
902
+ /** 文件读出的账号是否合法(两个事实都得在)。 */
903
+ function isAccountRef(value) {
904
+ if (typeof value !== "object" || value === null) return false;
905
+ const ref = value;
906
+ return typeof ref.uid === "string" && ref.uid.length > 0 && typeof ref.nick === "string" && ref.nick.length > 0;
907
+ }
908
+ /**
909
+ * 文件读出的记录是否合法。
910
+ *
911
+ * `accounts` 与 `credit` 同为必填:一条记录说不出这一轮用的是哪个账号,悬停提示就会
912
+ * 缺一行,那与"没有这条记录"没有区别,因此不完整即整条丢弃。引用还必须能在同一份
913
+ * 文件的账号表里解析到 —— 一个悬空引用渲染不出账号,与缺字段是同一种残缺。
914
+ * 插件尚未发布,磁盘上不存在按旧格式写下的文件,故不保留渐进读取。
915
+ * @param value - 文件里的一条轮次记录。
916
+ * @param table - 同一份文件的账号表。
917
+ * @returns 该记录是否可用。
918
+ */
919
+ function isCreditRecord(value, table) {
920
+ if (typeof value !== "object" || value === null) return false;
921
+ const record = value;
922
+ return typeof record.credit === "number" && Number.isFinite(record.credit) && Array.isArray(record.accounts) && record.accounts.length > 0 && record.accounts.every((key) => typeof key === "string" && isAccountRef(table[key]));
923
+ }
924
+ /**
925
+ * 读取一个会话的信息。
926
+ * @param sessionId - dsh 会话 id。
927
+ * @returns 该会话选用的账号、用过的账号表与积分记录;缺文件或损坏时返回空信息。
928
+ */
929
+ async function loadSessionInfo(sessionId) {
930
+ try {
931
+ const parsed = JSON.parse(await promises.readFile(sessionInfoPath(sessionId), "utf-8"));
932
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return emptyInfo();
933
+ const record = parsed;
934
+ const accounts = {};
935
+ const rawAccounts = record.accounts;
936
+ if (typeof rawAccounts === "object" && rawAccounts !== null) {
937
+ for (const [key, value] of Object.entries(rawAccounts)) if (isAccountRef(value)) accounts[key] = value;
938
+ }
939
+ const credits = {};
940
+ const rawCredits = record.credits;
941
+ if (typeof rawCredits === "object" && rawCredits !== null) {
942
+ for (const [turn, value] of Object.entries(rawCredits)) if (isCreditRecord(value, accounts)) credits[turn] = value;
943
+ }
944
+ const uid = typeof record.uid === "string" && record.uid.length > 0 ? record.uid : void 0;
945
+ return {
946
+ ...uid === void 0 ? {} : { uid },
947
+ accounts,
948
+ credits
949
+ };
950
+ } catch {
951
+ return emptyInfo();
952
+ }
953
+ }
954
+ /**
955
+ * 把一份信息渲染成文件内容。
956
+ *
957
+ * 逐条一行而不是缩进展开:条目本身是单行记录,缩进只增加字节而不增加可读性。账号表与
958
+ * 轮次记录各自一行,于是新增一轮只改动两行(diff 可读),而文件整体仍然紧凑。
959
+ * @param info - 要渲染的信息。
960
+ * @returns 完整的文件内容,以换行结尾。
961
+ */
962
+ function renderSessionInfo(info) {
963
+ const lines = [];
964
+ if (info.uid !== void 0) lines.push(` "uid": ${JSON.stringify(info.uid)},`);
965
+ const accounts = Object.entries(info.accounts);
966
+ if (accounts.length === 0) lines.push(" \"accounts\": {},");
967
+ else {
968
+ lines.push(" \"accounts\": {");
969
+ accounts.forEach(([key, ref], index) => {
970
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(ref)}${index === accounts.length - 1 ? "" : ","}`);
971
+ });
972
+ lines.push(" },");
973
+ }
974
+ const credits = Object.entries(info.credits);
975
+ if (credits.length === 0) lines.push(" \"credits\": {}");
976
+ else {
977
+ lines.push(" \"credits\": {");
978
+ credits.forEach(([turn, record], index) => {
979
+ lines.push(` ${JSON.stringify(turn)}: ${JSON.stringify(record)}${index === credits.length - 1 ? "" : ","}`);
980
+ });
981
+ lines.push(" }");
982
+ }
983
+ return `{\n${lines.join("\n")}\n}\n`;
984
+ }
985
+ /**
986
+ * 读-改-写一个会话的信息(原子替换,文件不存在则新建)。
987
+ *
988
+ * 读发生在队列内部,因此排队中的后一次改动看得到前一次的结果,而不是各自基于同一份
989
+ * 旧内容。文件此刻不可读时按空信息起手,与 {@link loadSessionInfo} 对同一情况的判断一致。
990
+ * @param sessionId - dsh 会话 id。
991
+ * @param change - 由当前信息算出新信息;不得就地修改传入值。
992
+ */
993
+ async function updateSessionInfo(sessionId, change) {
994
+ const path = sessionInfoPath(sessionId);
995
+ await queueWrite(path, async () => {
996
+ await writeFileAtomic(path, renderSessionInfo(change(await loadSessionInfo(sessionId))));
997
+ });
998
+ }
999
+ //#endregion
1000
+ //#region lib/types/storage.js
1001
+ /**
1002
+ * 持久化 OAuth 账号存储,磁盘上仅属主可读。
1003
+ *
1004
+ * 存储位于 harness home 的规范数据目录
1005
+ * (`$DSH_HOME/data/plugins/llm-codebuddy-power/`,通过 harness 所用的同一个
1006
+ * `@deepseek-ai/dsh-home-paths` 解析),而不放在插件包内,因此重装不会让
1007
+ * 用户退出登录。写入经 [atomic-file.ts](./atomic-file.ts) 做原子替换并按路径串行化:
1008
+ * 多个账号的令牌刷新、设置页的增删与 CLI 的调用都在改同一份文件,不串行化时
1009
+ * 两个并发写入会各自基于自己读到的旧内容覆盖对方。残缺文件会让用户手里只有
1010
+ * 一份读不出的凭据,还无法与“从未登录过”区分开。
1011
+ *
1012
+ * @module dsh-llm-codebuddy-power/storage
1013
+ */
1014
+ /** 空存储:读取不到任何可用内容时的结果。 */
1015
+ function emptyStorage() {
1016
+ return { accounts: [] };
1017
+ }
1018
+ /**
1019
+ * 凭据文件的绝对路径。
1020
+ *
1021
+ * 通过 `@deepseek-ai/dsh-home-paths` 解析,以跟随 harness 自身的 home 优先级
1022
+ * (配置路径 > `$DSH_HOME` > `~/.dsh`),绝不会分叉到另行计算的 home。
1023
+ * `DSH_CODEBUDDY_AUTH_FILE` 保留作为测试与迁移用的显式逃生口。
1024
+ * @returns 绝对凭据路径,优先遵循环境变量覆盖。
1025
+ */
1026
+ function getStoragePath() {
1027
+ const override = process.env.DSH_CODEBUDDY_AUTH_FILE;
1028
+ if (override !== void 0 && override.length > 0) return override;
1029
+ return dshHomePath("data", "plugins", "llm-codebuddy-power", "codebuddy-auth.json");
1030
+ }
1031
+ /**
1032
+ * 路径是否仅其属主可读。
1033
+ *
1034
+ * 与 `@deepseek-ai/dsh-credentials-local` 在加载自己的凭据文档前所做的仅属主
1035
+ * 检查一致:只要 group 或 other 的读/写位被置上,即视为文件已暴露,检查
1036
+ * 失败。Windows 没有 POSIX 模式,因此在 Windows 上跳过该检查——防护强度
1037
+ * 取决于创建与替换 API 所表达的内容,与 dsh-credentials-local 相同。
1038
+ * @param path - 凭据文件路径。
1039
+ * @returns 文件不存在(尚无可保护内容)或以仅属主权限存在时返回 true;
1040
+ * 文件存在且已暴露时返回 false。
1041
+ */
1042
+ async function isOwnerOnly(path) {
1043
+ if (process.platform === "win32") return true;
1044
+ let mode;
1045
+ try {
1046
+ mode = (await promises.stat(path)).mode;
1047
+ } catch {
1048
+ return true;
1049
+ }
1050
+ return (mode & 63) === 0;
1051
+ }
1052
+ /** 账号条目是否携带了成对的令牌、uid 与刷新令牌的到期时刻。 */
1053
+ function isAccountEntry(value) {
1054
+ if (typeof value !== "object" || value === null) return false;
1055
+ const entry = value;
1056
+ return typeof entry.auth?.accessToken === "string" && typeof entry.auth.refreshToken === "string" && typeof entry.auth.refreshExpiresAt === "number" && Number.isFinite(entry.auth.refreshExpiresAt) && typeof entry.account?.uid === "string";
1057
+ }
1058
+ /**
1059
+ * 丢弃取值为空字符串的账号字符串字段,把它们当作不存在。
1060
+ *
1061
+ * 在 `normalizeAccount` 之前写入的较旧凭据可能从 wire 带来 `uin: ""`
1062
+ * (以及其他取值为空的可选字符串);这里把它们裁掉,使下游每个消费方的
1063
+ * `=== undefined` 判断都成立,而无需各自再加空字符串防护。
1064
+ * @param account - 解析后的账号字段。
1065
+ * @returns 已移除取值为空的可选字段的账号。
1066
+ */
1067
+ function trimEmptyAccountFields(account) {
1068
+ const pick = (v) => v === void 0 || v.length === 0 ? void 0 : v;
1069
+ return {
1070
+ uid: account.uid,
1071
+ nickname: account.nickname,
1072
+ ...pick(account.uin) === void 0 ? {} : { uin: account.uin },
1073
+ ...pick(account.enterpriseId) === void 0 ? {} : { enterpriseId: account.enterpriseId },
1074
+ ...pick(account.enterpriseName) === void 0 ? {} : { enterpriseName: account.enterpriseName },
1075
+ ...pick(account.enterpriseUserName) === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
1076
+ ...pick(account.departmentFullName) === void 0 ? {} : { departmentFullName: account.departmentFullName }
1077
+ };
1078
+ }
1079
+ /**
1080
+ * 把解析出的 JSON 归一化为账号集合。
1081
+ *
1082
+ * 同时接受当前形态与仅有一个账号的旧形态(顶层 `auth` + `account`):旧文件
1083
+ * 迁移成单元素列表,并让那个账号成为默认账号,于是升级不会改变用户下一次
1084
+ * 请求用的是谁。`defaultUid` 与 `lastUid` 指向已不存在的账号时丢弃——它们只是
1085
+ * 指针,留着会让“有没有默认账号”与实际情况相反。
1086
+ * @param parsed - 解析出的 JSON 值。
1087
+ * @returns 归一化后的账号集合。
1088
+ */
1089
+ function normalizeStorage(parsed) {
1090
+ if (typeof parsed !== "object" || parsed === null) return emptyStorage();
1091
+ const record = parsed;
1092
+ if (Array.isArray(record.accounts)) {
1093
+ const accounts = record.accounts.filter(isAccountEntry).map((entry) => ({
1094
+ auth: entry.auth,
1095
+ account: trimEmptyAccountFields(entry.account)
1096
+ }));
1097
+ const pick = (value) => {
1098
+ if (typeof value !== "string") return void 0;
1099
+ return accounts.some((a) => a.account.uid === value) ? value : void 0;
1100
+ };
1101
+ const defaultUid = pick(record.defaultUid);
1102
+ const lastUid = pick(record.lastUid);
1103
+ return {
1104
+ accounts,
1105
+ ...defaultUid === void 0 ? {} : { defaultUid },
1106
+ ...lastUid === void 0 ? {} : { lastUid }
1107
+ };
1108
+ }
1109
+ if (!isAccountEntry(record)) return emptyStorage();
1110
+ return {
1111
+ accounts: [{
1112
+ auth: record.auth,
1113
+ account: trimEmptyAccountFields(record.account)
1114
+ }],
1115
+ defaultUid: record.account.uid
1116
+ };
1117
+ }
1118
+ /**
1119
+ * 读取已存储的账号集合。
1120
+ *
1121
+ * 在读取任何字节之前先检查文件模式:主机上其他用户可读的凭据会被当作不存在
1122
+ * 而不是拿来使用,因此丢失仅属主模式的文件(手动 chmod 出错、从别处拷贝)
1123
+ * 绝不会被加载。当作不存在也能自愈——下次登录会用 `0o600` 重写该文件。
1124
+ * @returns 账号集合;没有可用内容时为空集合。文件缺失、文件损坏与权限不安全
1125
+ * 刻意给出相同答案:都表示“此处没有可安全用于认证的东西”,而登录流程是
1126
+ * 每一种情况的修复方式。
1127
+ */
1128
+ async function loadStorage() {
1129
+ const path = getStoragePath();
1130
+ try {
1131
+ if (!await isOwnerOnly(path)) return emptyStorage();
1132
+ const raw = await promises.readFile(path, "utf-8");
1133
+ return normalizeStorage(JSON.parse(raw));
1134
+ } catch {
1135
+ return emptyStorage();
1136
+ }
1137
+ }
1138
+ /**
1139
+ * 读-改-写账号集合。
1140
+ *
1141
+ * 读发生在队列内部,因此排队中的后一次改动看得到前一次的结果,而不是各自
1142
+ * 基于同一份旧内容。文件此刻无法安全读取时按空集合起手,与 {@link loadStorage}
1143
+ * 对同一情况的判断一致。
1144
+ * @param change - 由当前集合算出新集合;不得就地修改传入值。
1145
+ */
1146
+ async function updateStorage(change) {
1147
+ const path = getStoragePath();
1148
+ await queueWrite(path, async () => {
1149
+ await writeJsonAtomic(path, change(await loadStorage()));
1150
+ });
1151
+ }
1152
+ /**
1153
+ * 插入一个账号,或替换同 uid 账号的凭据。
1154
+ *
1155
+ * 同 uid 视为重新登录同一个账号:令牌被替换而不是追加成第二个条目,否则
1156
+ * 设置页会出现两个同名徽章,退出其中一个还会留下另一个。
1157
+ * @param entry - 刚登录得到的账号。
1158
+ */
1159
+ async function upsertAccount(entry) {
1160
+ await updateStorage((current) => {
1161
+ const uid = entry.account.uid;
1162
+ const accounts = current.accounts.some((a) => a.account.uid === uid) ? current.accounts.map((a) => a.account.uid === uid ? entry : a) : [...current.accounts, entry];
1163
+ const defaultUid = current.defaultUid ?? (current.accounts.length === 0 ? uid : void 0);
1164
+ return {
1165
+ accounts,
1166
+ ...defaultUid === void 0 ? {} : { defaultUid },
1167
+ ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
1168
+ };
1169
+ });
1170
+ }
1171
+ /**
1172
+ * 按给定顺序重排账号。
1173
+ *
1174
+ * 先按传入顺序取已知账号,再把没被提到的账号按原有相对顺序追加在后:因此落点算错、
1175
+ * 漏传某个 uid 或传入未知 uid 都不会让账号丢失或重复 —— 拖拽结果与磁盘之间只有这一处
1176
+ * 取舍,写错就是把用户已登录的账号弄丢。重复的 uid 只取第一次出现。
1177
+ * @param uids - 目标顺序上的账号 uid。
1178
+ */
1179
+ async function reorderAccounts(uids) {
1180
+ await updateStorage((current) => {
1181
+ const pending = new Map(current.accounts.map((account) => [account.account.uid, account]));
1182
+ const ordered = [];
1183
+ for (const uid of uids) {
1184
+ const account = pending.get(uid);
1185
+ if (account === void 0) continue;
1186
+ pending.delete(uid);
1187
+ ordered.push(account);
1188
+ }
1189
+ for (const account of current.accounts) {
1190
+ if (!pending.has(account.account.uid)) continue;
1191
+ pending.delete(account.account.uid);
1192
+ ordered.push(account);
1193
+ }
1194
+ return {
1195
+ ...current,
1196
+ accounts: ordered
1197
+ };
1198
+ });
1199
+ }
1200
+ /**
1201
+ * 移除若干账号,并清掉指向它们的默认账号与“最后一次手动切换”的账号。
1202
+ * @param uids - 要移除的账号 uid。
1203
+ */
1204
+ async function removeAccounts(uids) {
1205
+ const removed = new Set(uids);
1206
+ await updateStorage((current) => {
1207
+ const accounts = current.accounts.filter((a) => !removed.has(a.account.uid));
1208
+ const gone = (uid) => uid !== void 0 && removed.has(uid) ? void 0 : uid;
1209
+ const defaultUid = gone(current.defaultUid);
1210
+ const lastUid = gone(current.lastUid);
1211
+ return {
1212
+ accounts,
1213
+ ...defaultUid === void 0 ? {} : { defaultUid },
1214
+ ...lastUid === void 0 ? {} : { lastUid }
1215
+ };
1216
+ });
1217
+ }
1218
+ /**
1219
+ * 指定或取消默认账号。
1220
+ * @param uid - 默认账号的 uid;传 `undefined` 取消默认账号。
1221
+ */
1222
+ async function setDefaultAccount(uid) {
1223
+ await updateStorage((current) => {
1224
+ const next = {
1225
+ accounts: current.accounts,
1226
+ ...current.lastUid === void 0 ? {} : { lastUid: current.lastUid }
1227
+ };
1228
+ if (uid !== void 0 && current.accounts.some((a) => a.account.uid === uid)) next.defaultUid = uid;
1229
+ return next;
1230
+ });
1231
+ }
1232
+ /**
1233
+ * 记下最后一次被手动切换到的账号(没有默认账号时作为兜底)。
1234
+ * @param uid - 被切换到的账号 uid。
1235
+ */
1236
+ async function setLastAccount(uid) {
1237
+ await updateStorage((current) => ({
1238
+ ...current,
1239
+ lastUid: uid
1240
+ }));
1241
+ }
1242
+ /**
1243
+ * 凭据文件的不透明新鲜度令牌:文件每次被重写或删除时它都会变化,因此内存中
1244
+ * 的会话能察觉另一个进程(CLI)执行的登录与登出,而不必在什么都没变时
1245
+ * 每次调用都重新读取。
1246
+ * @returns 一个令牌;没有凭据文件时返回 `undefined`。
1247
+ */
1248
+ async function storageFreshness() {
1249
+ let stat;
1250
+ try {
1251
+ stat = await promises.stat(getStoragePath());
1252
+ } catch {
1253
+ return;
1254
+ }
1255
+ return `${stat.mtimeMs}:${stat.size}`;
1256
+ }
1257
+ //#endregion
1258
+ //#region lib/types/session.js
1259
+ /**
1260
+ * 已登录会话:按账号解析身份、token 新鲜度与缓存的模型目录。
1261
+ *
1262
+ * 一个对象同时持有它们,因为它们共享同一种故障模式 → token 过期会导致
1263
+ * 目录不可读 → 也因为它们都必须在构建请求之前解析完成。刷新是单飞的,而且是
1264
+ * **按账号**单飞:适配器每次流式调用解析一次身份,若不按账号分开,两个账号的
1265
+ * 并发调用会互相取到对方的刷新结果,其中一个的 token 永远停留过期。
1266
+ *
1267
+ * 账号由会话决定:某个会话显式选了账号就用它,否则顺延到派生它的会话(子代理、
1268
+ * fork)、默认账号、最后一次手动切换的账号,最后落到列表第一个。目录与计量快照
1269
+ * 也按账号分别缓存,因为不同账号的可用模型与额度本就可以不同。
1270
+ *
1271
+ * @module dsh-llm-codebuddy-power/session
1272
+ */
1273
+ /** 提前这么久刷新,而不是正好在记录的过期时刻刷新。 */
1274
+ const REFRESH_SKEW_MS = 6e4;
1275
+ /**
1276
+ * 读到的目录在被再次向服务查询之前复用多久。与官方 CLI 的
1277
+ * `PRODUCT_CONFIGURATION_CACHE_TIMEOUT`(48e4 ms)对齐。
1278
+ */
1279
+ const CATALOG_TTL_MS = 480 * 1e3;
1280
+ /**
1281
+ * 计量快照在被再次向真实 CodeBuddy 用量平面查询之前服务多久。浏览器以
1282
+ * 短得多的间隔轮询宿主;这个 TTL 就是让那些轮询不落到计量 API 上的那一层。
1283
+ */
1284
+ const METER_TTL_MS = 60 * 1e3;
1285
+ /** 未登录任何账号时抛出;消息中携带补救方法。 */
1286
+ var NotLoggedInError = class extends Error {
1287
+ constructor(detail) {
1288
+ super(detail);
1289
+ this.name = "NotLoggedInError";
1290
+ }
1291
+ };
1292
+ /**
1293
+ * 把一条文件记录里的账号引用按账号表展开。
1294
+ *
1295
+ * 引用解析不到时返回 `undefined` 而不是抛错:读取路径已经保证不会发生(校验时表已建好),
1296
+ * 这里只是让"残缺记录不交给界面"这条规则不依赖上游。
1297
+ * @param record - 文件里的一条轮次记录。
1298
+ * @param table - 同一份文件的账号表。
1299
+ * @returns 展开后的记录;有引用解析不到时为 `undefined`。
1300
+ */
1301
+ function resolveCreditRecord(record, table) {
1302
+ const accounts = [];
1303
+ for (const key of record.accounts) {
1304
+ const ref = table[key];
1305
+ if (ref === void 0) return void 0;
1306
+ accounts.push({
1307
+ nickname: ref.nick,
1308
+ uid: ref.uid
1309
+ });
1310
+ }
1311
+ return {
1312
+ credit: record.credit,
1313
+ accounts
1314
+ };
1315
+ }
1316
+ /**
1317
+ * 由 dsh 会话身份推导请求头所需的会话 id。
1318
+ *
1319
+ * 官方只在请求头里认紧凑 uuid(无 `session-` 前缀、无连字符),而 Web GUI 的会话
1320
+ * id 正是 `session-<uuid>`:去掉前缀与连字符后恰好 32 位,因此会话 id 可以直接
1321
+ * 换算得到,不必另铸一个随机值 —— 后者一旦承载它的内存状态被丢弃(会话销毁后
1322
+ * 又恢复),同一个会话就会换一个 id。
1323
+ *
1324
+ * 取**最后一个** `session-` 之后的部分,而不是去掉开头那一个:会话 id 还有
1325
+ * `<agent-id>-session-<uuid>` 形态,从开头截取会把 agent 名混进结果,产生非
1326
+ * 十六进制字符。超过 32 位截断,不足则在**前面**补 `0`,使非 uuid 形态(如计数器式
1327
+ * `session-<n>`)也得到定长的合法值;在前面补而不是在后面补,是因为数字只有在
1328
+ * 右对齐时才互不重合 —— 在后面补会让 `session-1` 与 `session-10` 得到同一个值。
1329
+ * @param sessionId - dsh 会话身份。
1330
+ * @returns 32 位紧凑 uuid。
1331
+ */
1332
+ function compactConversationId(sessionId) {
1333
+ const at = sessionId.lastIndexOf("session-");
1334
+ const compact = (at === -1 ? sessionId : sessionId.slice(at + 8)).replace(/-/g, "");
1335
+ return compact.length >= 32 ? compact.slice(0, 32) : compact.padStart(32, "0");
1336
+ }
1337
+ /** 账号状态投影:丢掉取值为空的可选字段,使调用方的 `=== undefined` 判断成立。 */
1338
+ function accountInfo(entry) {
1339
+ const a = entry.account;
1340
+ return {
1341
+ uid: a.uid,
1342
+ nickname: a.nickname,
1343
+ loginExpiresAt: entry.auth.refreshExpiresAt,
1344
+ ...a.uin === void 0 ? {} : { uin: a.uin },
1345
+ ...a.enterpriseId === void 0 ? {} : { enterpriseId: a.enterpriseId },
1346
+ ...a.enterpriseName === void 0 ? {} : { enterpriseName: a.enterpriseName },
1347
+ ...a.enterpriseUserName === void 0 ? {} : { enterpriseUserName: a.enterpriseUserName },
1348
+ ...a.departmentFullName === void 0 ? {} : { departmentFullName: a.departmentFullName }
1349
+ };
1350
+ }
1351
+ /**
1352
+ * 持有单个插件实例的已存账号、每个会话选用的账号与它们的缓存。
1353
+ *
1354
+ * 内存中没有凭据时会从磁盘重新读取,这正是
1355
+ * `dsh plugin --profile web codebuddy-power` 能在不重启的情况下
1356
+ * 让*运行中*的 harness 登录的原因。
1357
+ */
1358
+ var CodeBuddySession = class {
1359
+ logger;
1360
+ onModelsChanged;
1361
+ storage;
1362
+ /** 内存中 `storage` 所读自的凭据文件的新鲜度标记。 */
1363
+ storageKey;
1364
+ /**
1365
+ * {@link storageKey} 是否足以代表 {@link storage}。
1366
+ *
1367
+ * `undefined` 的新鲜度有两种含义 —— 文件真的不存在,或这一次 `fs.stat` 失败(Windows
1368
+ * 上并发替换文件时会短暂如此)—— 所以它不能单独用来判断“没变”。把它当成“没变”而
1369
+ * `storage` 恰好是空的,账号列表就会被读成空、界面显示“未登录”,而磁盘上凭据完好。
1370
+ */
1371
+ storageMemoValid = false;
1372
+ /** 上一次加载时账号集合的 uid 列表,用来判断集合本身有没有变。 */
1373
+ storageUids = "";
1374
+ /** 按账号 uid 单飞的令牌刷新。 */
1375
+ refreshing = /* @__PURE__ */ new Map();
1376
+ /** 按账号 uid 缓存的目录;合并目录由它派生,因此它的增删决定合并目录的内容。 */
1377
+ catalogs = /* @__PURE__ */ new Map();
1378
+ /**
1379
+ * 模型 id → 模型,由 {@link catalogs} 派生的并集索引,同 id 保留先到的那份。
1380
+ *
1381
+ * 集合只由**带会话身份的**读取填充(聊天请求、模型面板),并供拿不到会话身份的
1382
+ * 调用方({@link knownModel}、`listModels`)查询:那些调用方无法知道该用哪个账号
1383
+ * 的目录,主动读取只能落在默认账号上 —— 而当前会话用的可能正是另一个账号。
1384
+ */
1385
+ knownCatalog = /* @__PURE__ */ new Map();
1386
+ /** 按账号 uid 单飞的目录读取。 */
1387
+ catalogReads = /* @__PURE__ */ new Map();
1388
+ /** 按账号 uid 缓存的计量快照;在 {@link METER_TTL_MS} 内直接返回,不访问该平面。 */
1389
+ usageCache = /* @__PURE__ */ new Map();
1390
+ /** 活动会话,以 dsh 会话 id 为键;在 `session/disposed` 时清理。 */
1391
+ tracks = /* @__PURE__ */ new Map();
1392
+ /**
1393
+ * 已读出的会话信息(选用的账号 + 每轮积分),按会话 id。
1394
+ *
1395
+ * 单独于 {@link tracks}:账号选择要在还没有任何回合的会话上就能读到(新建会话后
1396
+ * 立刻切账号,第一条消息就得用它),而 `tracks` 只在 `turn/start` 时才有条目。
1397
+ */
1398
+ infos = /* @__PURE__ */ new Map();
1399
+ /** 正在从文件读取的会话信息,避免并发重复读。 */
1400
+ infoReads = /* @__PURE__ */ new Map();
1401
+ constructor(logger, onModelsChanged) {
1402
+ this.logger = logger;
1403
+ this.onModelsChanged = onModelsChanged;
1404
+ }
1405
+ /**
1406
+ * 在首次见到某会话时以及每个新轮次,为该会话铸出当前轮的请求 id。轮请求 id
1407
+ * 在 harness 打开一个该会话尚未被观察到的轮次时铸出,因此一个用户
1408
+ * 提示词内的所有模型调用都共享它,即使在运行中途插入了提示词。
1409
+ * @param sessionId - dsh 会话身份。
1410
+ * @param turn - harness 刚打开的轮次。
1411
+ * @param parentSessionId - 该会话派生自的会话(子代理、fork),没有时为 `undefined`。
1412
+ */
1413
+ observeTurn(sessionId, turn, parentSessionId) {
1414
+ const current = this.tracks.get(sessionId);
1415
+ if (current === void 0) {
1416
+ this.tracks.set(sessionId, {
1417
+ turn,
1418
+ turnRequestId: compactUuid(),
1419
+ credits: /* @__PURE__ */ new Map(),
1420
+ ...parentSessionId === void 0 ? {} : { parent: parentSessionId }
1421
+ });
1422
+ return;
1423
+ }
1424
+ if (parentSessionId !== void 0) current.parent = parentSessionId;
1425
+ if (current.turn === turn) return;
1426
+ current.turn = turn;
1427
+ current.turnRequestId = compactUuid();
1428
+ }
1429
+ /**
1430
+ * 在 harness 销毁某会话时丢弃它被跟踪的回合状态与已读出的会话信息,使这两个 map
1431
+ * 不会无界增长。磁盘上的会话信息不随之删除:该事件表示会话对象被销毁,而不是用户
1432
+ * 删除了会话 —— 一次性子代理跑完就会触发它。
1433
+ * @param sessionId - dsh 会话身份。
1434
+ */
1435
+ forget(sessionId) {
1436
+ this.tracks.delete(sessionId);
1437
+ this.infos.delete(sessionId);
1438
+ }
1439
+ /**
1440
+ * 某会话对应的 CodeBuddy 会话 id。由 dsh 会话身份推导,不依赖跟踪状态:
1441
+ * 同一会话在任何时刻、任何进程里都得到同一个值。
1442
+ * @param sessionId - dsh 会话身份。
1443
+ * @returns 32 位紧凑 uuid。
1444
+ */
1445
+ conversationIdOf(sessionId) {
1446
+ return compactConversationId(sessionId);
1447
+ }
1448
+ /**
1449
+ * 某会话当前轮的请求 id;该会话尚未被观察到时为 `undefined`。
1450
+ * @param sessionId - dsh 会话身份。
1451
+ * @returns 该轮的请求 id;未被观察到的会话为 `undefined`。
1452
+ */
1453
+ turnRequestIdOf(sessionId) {
1454
+ return this.tracks.get(sessionId)?.turnRequestId;
1455
+ }
1456
+ /**
1457
+ * 读出某会话的信息(选用的账号 + 每轮积分),进程内缓存一次。
1458
+ *
1459
+ * 首读才碰磁盘:账号解析在每次请求上发生,而一个会话的账号选择不会在运行期间被
1460
+ * 别的进程改动(只有本进程写它),因此缓存到会话被销毁即可。
1461
+ * @param sessionId - dsh 会话身份。
1462
+ * @returns 该会话的信息;文件缺失时为“未选账号、无积分”。
1463
+ */
1464
+ async infoOf(sessionId) {
1465
+ const cached = this.infos.get(sessionId);
1466
+ if (cached !== void 0) return cached;
1467
+ let pending = this.infoReads.get(sessionId);
1468
+ if (pending === void 0) {
1469
+ pending = loadSessionInfo(sessionId).finally(() => {
1470
+ if (this.infoReads.get(sessionId) === pending) this.infoReads.delete(sessionId);
1471
+ });
1472
+ this.infoReads.set(sessionId, pending);
1473
+ }
1474
+ const info = await pending;
1475
+ this.infos.set(sessionId, info);
1476
+ return info;
1477
+ }
1478
+ /**
1479
+ * 把一次 CodeBuddy 请求的扣分累加到会话当前轮。聊天请求与画图工具都
1480
+ * 调用:一轮内两者都可能发生(顺序不定),扣分相加。记录合并到插件自有的
1481
+ * 会话信息文件(fire-and-forget),重启后仍可读。模型不存——dsh 用量自带。
1482
+ *
1483
+ * 账号一并记录,且只在首次使用时追加(按完整 uid 判定是否已记过):扣分与"哪个账号
1484
+ * 扣的"必须在同一时刻取自同一个事实,悬停时再查当前账号会把一轮里切换过账号的情况说错。
1485
+ *
1486
+ * 序号在落盘的读-改-写内部才分配:它取决于该文件当前的账号表,而那张表只有在队列里
1487
+ * 读到文件之后才知道。内存里因此存解析好的账号(昵称 + 完整 uid),文件里存序号 —— 两者
1488
+ * 形状不同是有意的,文件只该存一次账号,而每次查询都回表解析会让悬停这条热路径多一层查找。
1489
+ * @param sessionId - dsh 会话 id。
1490
+ * @param credit - 本次请求扣的积分。
1491
+ * @param account - 发起本次请求的账号。
1492
+ */
1493
+ recordCredit(sessionId, credit, account) {
1494
+ const track = this.tracks.get(sessionId);
1495
+ if (track === void 0) return;
1496
+ const turn = track.turn;
1497
+ const existing = track.credits.get(turn);
1498
+ const accounts = existing?.accounts ?? [];
1499
+ const known = accounts.some((entry) => entry.uid === account.uid);
1500
+ const merged = {
1501
+ credit: (existing?.credit ?? 0) + credit,
1502
+ accounts: known ? accounts : [...accounts, {
1503
+ nickname: account.nickname,
1504
+ uid: account.uid
1505
+ }]
1506
+ };
1507
+ track.credits.set(turn, merged);
1508
+ updateSessionInfo(sessionId, (info) => {
1509
+ const table = { ...info.accounts };
1510
+ const keys = merged.accounts.map((entry) => {
1511
+ const key = accountKeyFor(table, entry.uid);
1512
+ table[key] = {
1513
+ uid: entry.uid,
1514
+ nick: entry.nickname
1515
+ };
1516
+ return key;
1517
+ });
1518
+ return {
1519
+ ...info,
1520
+ accounts: table,
1521
+ credits: {
1522
+ ...info.credits,
1523
+ [String(turn)]: {
1524
+ credit: merged.credit,
1525
+ accounts: keys
1526
+ }
1527
+ }
1528
+ };
1529
+ }).catch((error) => {
1530
+ this.logger?.warn(`dsh-codebuddy: could not persist turn credits: ${String(error)}`);
1531
+ });
1532
+ }
1533
+ /**
1534
+ * 某会话某一轮的积分。内存未命中时回退到会话信息文件(重启恢复),
1535
+ * 并回填缓存。
1536
+ * @param sessionId - dsh 会话身份。
1537
+ * @param turn - 轮次号。
1538
+ * @returns 该记录;该轮没有上报任何积分时为 `undefined`。
1539
+ */
1540
+ async creditOf(sessionId, turn) {
1541
+ const memory = this.tracks.get(sessionId)?.credits.get(turn);
1542
+ if (memory !== void 0) return memory;
1543
+ const info = await this.infoOf(sessionId);
1544
+ const track = this.tracks.get(sessionId);
1545
+ for (const [key, record] of Object.entries(info.credits)) {
1546
+ const n = Number(key);
1547
+ if (!Number.isSafeInteger(n)) continue;
1548
+ const resolved = resolveCreditRecord(record, info.accounts);
1549
+ if (resolved !== void 0) track?.credits.set(n, resolved);
1550
+ }
1551
+ const record = info.credits[String(turn)];
1552
+ return record === void 0 ? void 0 : resolveCreditRecord(record, info.accounts);
1553
+ }
1554
+ /**
1555
+ * 遗忘内存中的账号与按账号缓存的数值,并广播这次变化。
1556
+ *
1557
+ * 刻意不动 {@link catalogs} 与由它派生的 {@link knownCatalog}:目录内容
1558
+ * (模型、促销、档位)不随凭据或按模型的覆写变化(覆写每次解析都从磁盘重读,
1559
+ * 见 `model-enrich.ts` 的 `loadOverrides`),而清掉它们会把官方列表的并集一起
1560
+ * 打空 —— 本方法也被改覆写与 401 失效调用,那时别的账号已并进来的模型不该消失。
1561
+ * 账号真的增删时由 {@link forgetDepartedAccounts} 按 uid 精确丢弃。
1562
+ */
1563
+ invalidate() {
1564
+ this.storage = void 0;
1565
+ this.storageMemoValid = false;
1566
+ this.refreshing.clear();
1567
+ this.catalogReads.clear();
1568
+ this.usageCache.clear();
1569
+ this.onModelsChanged?.();
1570
+ }
1571
+ /**
1572
+ * 由当前缓存的各账号目录重建合并目录。
1573
+ *
1574
+ * 账号退出后的处理必须是"重建"而不是"清空":清空会让官方列表塌回默认账号,
1575
+ * 而其余仍登录账号的目录本来就在手上 —— 继续并着才是"退出后重新合并当前已有的
1576
+ * 列表"。重建同时确定性地解决同 id 的归属:只剩下仍在的账号参与竞争。
1577
+ */
1578
+ reindexKnownCatalog() {
1579
+ this.knownCatalog.clear();
1580
+ for (const catalog of this.catalogs.values()) for (const model of catalog.models) if (!this.knownCatalog.has(model.id)) this.knownCatalog.set(model.id, model);
1581
+ }
1582
+ /**
1583
+ * 丢弃已不在账号集合里的账号的缓存与计量快照,并重建合并目录。
1584
+ *
1585
+ * 按成员判定而不是按文件指纹:指纹会被 {@link invalidate} 置空(改一次覆写也走
1586
+ * 那条路),按指纹清空并集会让别的账号的模型凭空消失;而按成员判定是幂等的 ——
1587
+ * 同一个账号仍在时什么都不丢,重复调用也不会多清一次。
1588
+ */
1589
+ forgetDepartedAccounts() {
1590
+ const stored = new Set((this.storage?.accounts ?? []).map((entry) => entry.account.uid));
1591
+ let departed = false;
1592
+ for (const uid of [...this.catalogs.keys()]) {
1593
+ if (stored.has(uid)) continue;
1594
+ this.catalogs.delete(uid);
1595
+ departed = true;
1596
+ }
1597
+ for (const uid of [...this.usageCache.keys()]) if (!stored.has(uid)) this.usageCache.delete(uid);
1598
+ if (departed) this.reindexKnownCatalog();
1599
+ }
1600
+ /**
1601
+ * 只要凭据文件自上次读取以来发生了变化就重新读取,使另一个进程
1602
+ * (CLI)执行的登录或登出能在不重启的情况下到达运行中的 harness:已持有的
1603
+ * `storage` 不能比被重写或删除的凭据文件活得更久。
1604
+ *
1605
+ * 重读之后只有**账号集合本身**变了才算一次账号变更,判据是 uid 列表而不是文件
1606
+ * 指纹:指纹每次写文件都变,于是每刷新一次令牌(另一个进程的 `--status` 也会)
1607
+ * 或每记一次“最后切换的账号”都会被当成账号变更,从而丢掉按账号缓存的计量快照并
1608
+ * 广播一次模型变更 —— 那些都不是账号增删。真的增删时才会丢弃已退出账号的缓存并
1609
+ * 重建合并目录:退出一个账号后官方列表应当继续并着其余账号已读到的目录,而不是
1610
+ * 塌回默认账号一个。
1611
+ */
1612
+ async refreshIfChanged() {
1613
+ const storageKey = await storageFreshness();
1614
+ if (this.storageMemoValid && storageKey !== void 0 && storageKey === this.storageKey) return;
1615
+ this.storageKey = storageKey;
1616
+ this.storage = await loadStorage();
1617
+ this.storageMemoValid = this.storage.accounts.length > 0;
1618
+ const uids = this.storage.accounts.map((account) => account.account.uid).sort().join("\0");
1619
+ if (uids === this.storageUids) return;
1620
+ this.storageUids = uids;
1621
+ this.forgetDepartedAccounts();
1622
+ this.onModelsChanged?.();
1623
+ }
1624
+ /** 账号条目,按 uid 查。 */
1625
+ accountEntry(uid) {
1626
+ return uid === void 0 ? void 0 : this.storage?.accounts.find((a) => a.account.uid === uid);
1627
+ }
1628
+ /**
1629
+ * 该会话自身及其派生祖先显式选用的账号,由近及远。读取某个会话的信息可能要碰盘,
1630
+ * 因此本方法是异步的。
1631
+ *
1632
+ * 走到祖先是因为子代理与 fork 出来的会话在界面上没有自己的账号按钮:它们若不
1633
+ * 沿用父会话,就会静默改用默认账号,把一次派发记到另一个账号头上。
1634
+ */
1635
+ async accountChain(sessionId) {
1636
+ const chain = [];
1637
+ const seen = /* @__PURE__ */ new Set();
1638
+ let current = sessionId;
1639
+ while (current !== void 0 && !seen.has(current)) {
1640
+ seen.add(current);
1641
+ const uid = (await this.infoOf(current)).uid;
1642
+ if (uid !== void 0) chain.push(uid);
1643
+ current = this.tracks.get(current)?.parent;
1644
+ }
1645
+ return chain;
1646
+ }
1647
+ /**
1648
+ * 该会话此刻生效的账号 uid。
1649
+ *
1650
+ * 顺序:会话(或其派生祖先)显式选用的账号 → 默认账号 → 最后一次手动切换的
1651
+ * 账号 → 列表第一个。指向已退出账号的选择会被跳过,而不是当成“没有账号”;
1652
+ * 全部落空时返回 `undefined`,调用方据此报未登录。
1653
+ */
1654
+ async accountUidFor(sessionId) {
1655
+ if (sessionId !== void 0) {
1656
+ for (const uid of await this.accountChain(sessionId)) if (this.accountEntry(uid) !== void 0) return uid;
1657
+ }
1658
+ for (const fallback of [this.storage?.defaultUid, this.storage?.lastUid]) if (this.accountEntry(fallback) !== void 0) return fallback;
1659
+ return this.storage?.accounts[0]?.account.uid;
1660
+ }
1661
+ identityOf(entry) {
1662
+ return {
1663
+ accessToken: entry.auth.accessToken,
1664
+ domain: entry.auth.domain,
1665
+ uid: entry.account.uid,
1666
+ ...entry.account.enterpriseId === void 0 ? {} : { enterpriseId: entry.account.enterpriseId },
1667
+ ...entry.account.departmentFullName === void 0 ? {} : { departmentFullName: entry.account.departmentFullName }
1668
+ };
1669
+ }
1670
+ /** 把刷新后的账号写回内存,并尽力落盘(写不进去也不影响本进程继续使用)。 */
1671
+ async persist(entry) {
1672
+ const accounts = this.storage?.accounts;
1673
+ if (accounts !== void 0) {
1674
+ const at = accounts.findIndex((a) => a.account.uid === entry.account.uid);
1675
+ if (at !== -1) accounts[at] = entry;
1676
+ }
1677
+ try {
1678
+ await updateStorage((current) => ({
1679
+ ...current,
1680
+ accounts: current.accounts.map((a) => a.account.uid === entry.account.uid ? entry : a)
1681
+ }));
1682
+ } catch (error) {
1683
+ this.logger?.warn("dsh-codebuddy: refreshed the session but could not persist it");
1684
+ this.logger?.warn(error);
1685
+ }
1686
+ }
1687
+ /**
1688
+ * 已存账号条目,在首次使用、失效之后以及文件被外部改动时从磁盘读取。
1689
+ * @param sessionId - 用哪个会话的账号;会话未选用时按默认账号解析。
1690
+ * @throws NotLoggedInError 未存有任何凭据时。
1691
+ */
1692
+ async require(sessionId) {
1693
+ await this.refreshIfChanged();
1694
+ const entry = this.accountEntry(await this.accountUidFor(sessionId));
1695
+ if (entry === void 0) throw new NotLoggedInError("CodeBuddy is not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser; no API key is required.");
1696
+ return entry;
1697
+ }
1698
+ /**
1699
+ * 某账号可用的身份,在访问 token 到达或临近过期时刷新它。同一账号的并发
1700
+ * 调用者共享同一次刷新;不同账号各刷各的。
1701
+ * @param entry - 要解析的账号条目。
1702
+ * @returns 用于认证请求的身份。
1703
+ * @throws NotLoggedInError 刷新 token 本身已过期、只能重新在浏览器登录时。
1704
+ */
1705
+ async identityFor(entry) {
1706
+ const now = Date.now();
1707
+ if (now < entry.auth.expiresAt - REFRESH_SKEW_MS) return this.identityOf(entry);
1708
+ if (now >= entry.auth.refreshExpiresAt) throw new NotLoggedInError(`The CodeBuddy session for ${entry.account.nickname} has expired. Run \`dsh plugin --profile web codebuddy-power\` to sign in again through your browser.`);
1709
+ const uid = entry.account.uid;
1710
+ let pending = this.refreshing.get(uid);
1711
+ if (pending === void 0) {
1712
+ pending = this.refresh(entry).finally(() => {
1713
+ if (this.refreshing.get(uid) === pending) this.refreshing.delete(uid);
1714
+ });
1715
+ this.refreshing.set(uid, pending);
1716
+ }
1717
+ return pending;
1718
+ }
1719
+ async refresh(entry) {
1720
+ const refreshed = await refreshAccessToken(this.identityOf(entry), entry.auth.refreshToken);
1721
+ if (refreshed === void 0) throw new NotLoggedInError(`Refreshing the CodeBuddy session for ${entry.account.nickname} failed. Run \`dsh plugin --profile web codebuddy-power\` to sign in again through your browser.`);
1722
+ const next = {
1723
+ auth: {
1724
+ accessToken: refreshed.accessToken,
1725
+ expiresAt: Date.now() + refreshed.expiresIn * 1e3,
1726
+ refreshToken: refreshed.refreshToken,
1727
+ refreshExpiresAt: Date.now() + refreshed.refreshExpiresIn * 1e3,
1728
+ domain: refreshed.domain
1729
+ },
1730
+ account: entry.account
1731
+ };
1732
+ await this.persist(next);
1733
+ return this.identityOf(next);
1734
+ }
1735
+ /** 由身份构建每个已认证 CodeBuddy 请求都携带的请求头。 */
1736
+ headersFrom(identity) {
1737
+ const headers = {
1738
+ "Authorization": `Bearer ${identity.accessToken}`,
1739
+ "X-Domain": identity.domain,
1740
+ "X-User-Id": identity.uid
1741
+ };
1742
+ if (identity.enterpriseId !== void 0) {
1743
+ headers["X-Enterprise-Id"] = identity.enterpriseId;
1744
+ headers["X-Tenant-Id"] = identity.enterpriseId;
1745
+ }
1746
+ if (identity.departmentFullName !== void 0) headers["X-Department-Info"] = identity.departmentFullName;
1747
+ return headers;
1748
+ }
1749
+ /**
1750
+ * 每个已认证 CodeBuddy 请求都携带的请求头,以及这些头属于哪个账号。
1751
+ *
1752
+ * 两者在**同一次**身份解析中一起给出:分开解析会有两次账号选择,而账号是每次请求
1753
+ * 解析的(用户可在同一步里切换),两次解析可能落到不同账号上 —— 那样记进这一轮的
1754
+ * 账号名就与实际发出去的请求不符。缩写由落盘侧按同一规则算出,故此处给出完整账号。
1755
+ * @param sessionId - 请求所属的会话;决定用哪个账号,缺省时用默认账号。
1756
+ * @returns 身份请求头与发起该请求的账号。
1757
+ */
1758
+ async requestIdentity(sessionId) {
1759
+ const entry = await this.require(sessionId);
1760
+ return {
1761
+ headers: this.headersFrom(await this.identityFor(entry)),
1762
+ account: {
1763
+ uid: entry.account.uid,
1764
+ nickname: entry.account.nickname
1765
+ }
1766
+ };
1767
+ }
1768
+ /**
1769
+ * 是否存在凭据,但不要求必须有。
1770
+ * @returns 已存账号可读取且可用时为 true。
1771
+ */
1772
+ async isLoggedIn() {
1773
+ await this.refreshIfChanged();
1774
+ return (this.storage?.accounts.length ?? 0) > 0;
1775
+ }
1776
+ /**
1777
+ * 已存账号及其默认账号,供设置页与会话切换按钮展示。
1778
+ * @returns 账号列表(顺序与磁盘一致)与默认账号 uid;未登录时列表为空。
1779
+ */
1780
+ async accountList() {
1781
+ await this.refreshIfChanged();
1782
+ return {
1783
+ accounts: (this.storage?.accounts ?? []).map(accountInfo),
1784
+ ...this.storage?.defaultUid === void 0 ? {} : { defaultUid: this.storage.defaultUid }
1785
+ };
1786
+ }
1787
+ /**
1788
+ * 某会话此刻生效的账号 uid。
1789
+ * @param sessionId - dsh 会话身份;缺省时用默认账号。
1790
+ * @returns 账号 uid;一个账号也没有时为 `undefined`。
1791
+ */
1792
+ async activeUid(sessionId) {
1793
+ await this.refreshIfChanged();
1794
+ return this.accountUidFor(sessionId);
1795
+ }
1796
+ /**
1797
+ * 把某会话的账号切换为指定账号,并把它记为最后一次手动切换的账号。
1798
+ * @param sessionId - dsh 会话身份。
1799
+ * @param uid - 要使用的账号 uid。
1800
+ * @returns 该 uid 是已存账号时为 true;否则不做任何改动并返回 false。
1801
+ */
1802
+ async activate(sessionId, uid) {
1803
+ await this.refreshIfChanged();
1804
+ if (this.accountEntry(uid) === void 0) return false;
1805
+ const info = await this.infoOf(sessionId);
1806
+ await updateSessionInfo(sessionId, (current) => ({
1807
+ ...current,
1808
+ uid
1809
+ }));
1810
+ this.infos.set(sessionId, {
1811
+ ...info,
1812
+ uid
1813
+ });
1814
+ await setLastAccount(uid);
1815
+ return true;
1816
+ }
1817
+ /**
1818
+ * CodeBuddy 模型目录,短暂缓存,并在并发读取者之间共享。
1819
+ * @param signal - 底层读取的可选取消信号。
1820
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1821
+ * @returns 目录中的模型,按服务自身的顺序。
1822
+ */
1823
+ async models(signal, sessionId) {
1824
+ return (await this.catalogData(signal, sessionId)).models;
1825
+ }
1826
+ /**
1827
+ * 目录及其推广与档位,在同一个 TTL 下按账号一起缓存,并按账号单飞,使列表与
1828
+ * 面板富化共享同一次读取。
1829
+ * @param signal - 底层读取的可选取消信号。
1830
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1831
+ * @returns 模型、推广与档位。
1832
+ */
1833
+ async catalogData(signal, sessionId) {
1834
+ await this.refreshIfChanged();
1835
+ const uid = await this.accountUidFor(sessionId);
1836
+ if (uid === void 0) throw new NotLoggedInError("CodeBuddy is not signed in. Run `dsh plugin --profile web codebuddy-power` to sign in through your browser; no API key is required.");
1837
+ const cached = this.catalogs.get(uid);
1838
+ if (cached !== void 0 && Date.now() - cached.readAt < CATALOG_TTL_MS) return cached;
1839
+ let pending = this.catalogReads.get(uid);
1840
+ if (pending === void 0) {
1841
+ pending = this.readCatalog(uid, signal).finally(() => {
1842
+ if (this.catalogReads.get(uid) === pending) this.catalogReads.delete(uid);
1843
+ });
1844
+ this.catalogReads.set(uid, pending);
1845
+ }
1846
+ return pending;
1847
+ }
1848
+ async readCatalog(uid, signal) {
1849
+ const entry = this.accountEntry(uid);
1850
+ if (entry === void 0) throw new NotLoggedInError(`The CodeBuddy account ${uid} is no longer stored.`);
1851
+ const config = await getConfig(this.headersFrom(await this.identityFor(entry)), signal);
1852
+ const models = config.models.filter((model) => typeof model.id === "string" && model.id.length > 0);
1853
+ const catalog = {
1854
+ models,
1855
+ promotions: config.modelPromotions ?? [],
1856
+ tiers: config.modelTiers
1857
+ };
1858
+ if (this.accountEntry(uid) === void 0) return catalog;
1859
+ this.catalogs.set(uid, {
1860
+ ...catalog,
1861
+ readAt: Date.now()
1862
+ });
1863
+ let grown = false;
1864
+ for (const model of models) {
1865
+ if (this.knownCatalog.has(model.id)) continue;
1866
+ this.knownCatalog.set(model.id, model);
1867
+ grown = true;
1868
+ }
1869
+ if (grown) this.onModelsChanged?.();
1870
+ return catalog;
1871
+ }
1872
+ /**
1873
+ * 从各账号已读到的目录的并集里查一个模型,**不发起任何请求**。
1874
+ *
1875
+ * 供拿不到会话身份的调用方使用(适配器的 `resolveModel`):它无法知道该用哪个
1876
+ * 账号的目录,主动读取只能落在默认账号上,而当前会话用的可能正是另一个账号 ——
1877
+ * 那会把一个账号独有的模型解析成保守的默认能力(上下文窗口、输出上限、图片输入
1878
+ * 全部按最保守处理)。并集由带会话身份的读取填充,因此这个查询零成本、也不改变
1879
+ * "谁在读目录"这件事。
1880
+ * @param modelId - 模型 id。
1881
+ * @returns 该模型;尚无任何账号读到过它时为 `undefined`。
1882
+ */
1883
+ knownModel(modelId) {
1884
+ return this.knownCatalog.get(modelId);
1885
+ }
1886
+ /**
1887
+ * 各账号已读到的目录并集里的全部模型。
1888
+ *
1889
+ * 与 {@link knownModel} 同为只读查询:官方模型列表按它取数,因此它既包含默认
1890
+ * 账号的模型(冷启动那一次读取必然先做),也包含其他账号在会话里读过之后并进来
1891
+ * 的模型。顺序按各账号目录并入的先后,而非任何排序 —— 官方列表自行决定呈现顺序。
1892
+ * @returns 并集里的模型。
1893
+ */
1894
+ knownModels() {
1895
+ return [...this.knownCatalog.values()];
1896
+ }
1897
+ /**
1898
+ * 目录及其推广/档位;读不到时返回空值 —— 供面板使用的
1899
+ * {@link catalogData} 建议性读取孪生体。
1900
+ * @param signal - 可选取消信号。
1901
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1902
+ * @returns 目录与活动,或空列表。
1903
+ */
1904
+ async catalogDataOrEmpty(signal, sessionId) {
1905
+ try {
1906
+ return await this.catalogData(signal, sessionId);
1907
+ } catch (error) {
1908
+ if (error instanceof NotLoggedInError) return {
1909
+ models: [],
1910
+ promotions: [],
1911
+ tiers: void 0
1912
+ };
1913
+ this.logger?.warn("dsh-codebuddy: could not read the model catalog");
1914
+ this.logger?.warn(error);
1915
+ return {
1916
+ models: [],
1917
+ promotions: [],
1918
+ tiers: void 0
1919
+ };
1920
+ }
1921
+ }
1922
+ /**
1923
+ * 目录;读不到时返回空列表。
1924
+ *
1925
+ * 列出模型是设置页上的浏览动作,所以失败必须降级为“没有可显示的内容”,
1926
+ * 而不是让页面崩掉。请求路径直接使用 {@link models} 并保留真实失败。
1927
+ * @param signal - 可选取消信号。
1928
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1929
+ * @returns 目录,或空列表。
1930
+ */
1931
+ async modelsOrEmpty(signal, sessionId) {
1932
+ try {
1933
+ return await this.models(signal, sessionId);
1934
+ } catch (error) {
1935
+ if (error instanceof NotLoggedInError) return [];
1936
+ this.logger?.warn("dsh-codebuddy: could not read the model catalog");
1937
+ this.logger?.warn(error);
1938
+ return [];
1939
+ }
1940
+ }
1941
+ /**
1942
+ * CodeBuddy 用量/配额快照;读不到时为 `undefined`。
1943
+ *
1944
+ * 用量是界面上的建议性读取,所以计量故障必须降级为“没有可显示的
1945
+ * 内容”而不是向外传播:{@link NotLoggedInError} 表现为未登录状态,
1946
+ * 其他每种失败(传输、解析、刷新过期)都在一条警告之后解析为
1947
+ * `undefined`。身份通过与聊天请求相同的按账号单飞刷新解析,所以并发的计量
1948
+ * 读取绝不会消耗两次刷新 token。
1949
+ * @param sessionId - 用哪个会话的账号;缺省时用默认账号。
1950
+ * @param signal - 可选取消信号。
1951
+ * @returns 该快照;未存有任何凭据或计量平面不可达时为 `undefined`。
1952
+ */
1953
+ async usage(sessionId, signal) {
1954
+ await this.refreshIfChanged();
1955
+ const entry = this.accountEntry(await this.accountUidFor(sessionId));
1956
+ if (entry === void 0) return void 0;
1957
+ const uid = entry.account.uid;
1958
+ const cached = this.usageCache.get(uid);
1959
+ if (cached !== void 0 && Date.now() - cached.readAt < METER_TTL_MS) return cached.snapshot;
1960
+ let identity;
1961
+ try {
1962
+ identity = await this.identityFor(entry);
1963
+ } catch (error) {
1964
+ if (error instanceof NotLoggedInError) return void 0;
1965
+ this.logger?.warn("dsh-codebuddy: could not resolve identity for usage read");
1966
+ this.logger?.warn(error);
1967
+ return;
1968
+ }
1969
+ const snapshot = await fetchUsage(identity, signal);
1970
+ if (snapshot !== void 0) {
1971
+ this.usageCache.set(uid, {
1972
+ snapshot,
1973
+ readAt: Date.now()
1974
+ });
1975
+ return snapshot;
1976
+ }
1977
+ return cached?.snapshot;
1978
+ }
1979
+ };
1980
+ /**
1981
+ * 把 SSE 字节流解析为 data 载荷。
1982
+ * @param stream - 原始 SSE 字节分块;读取可能在任何位置断开。
1983
+ * @param onComment - 传输活动回调;注释不会进入载荷流。
1984
+ * @returns 按到达顺序的各个载荷,`[DONE]` 在最后。
1985
+ * @throws LlmError 流在未出现 `[DONE]` 就结束时抛出 `STREAM_CLOSED`。
1986
+ */
1987
+ async function* parseSse(stream, onComment) {
1988
+ const events = stream.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream(onComment === void 0 ? {} : { onComment }));
1989
+ for await (const { data } of events) {
1990
+ yield data;
1991
+ if (data === "[DONE]") return;
1992
+ }
1993
+ throw new LlmError("CodeBuddy SSE stream ended without [DONE]", "STREAM_CLOSED");
1994
+ }
1995
+ //#endregion
1996
+ //#region lib/types/serialize.js
1997
+ /**
1998
+ * 把 harness 消息序列化为 CodeBuddy(OpenAI 兼容)chat 请求。
1999
+ *
2000
+ * 用户文本被拼接,助手文本成为 `content`,工具调用成为
2001
+ * `tool_calls`,每个工具结果成为其自身的 `role: 'tool'` 消息 →
2002
+ * harness 把工具结果放在用户消息内部,而这条 wire 路由
2003
+ * 不接受这种形式。对于支持图像的模型,图像以 OpenAI 兼容的
2004
+ * `image_url` 部件(`data:<type>;base64,...`)出现在用户或工具 content
2005
+ * 数组中,与官方 CLI 的发送方式完全一致。工具结果的图只对 `read_image`
2006
+ * 这类"看图"工具发送,生成图工具的结果仍走纯文本信封。
2007
+ *
2008
+ * @module dsh-llm-codebuddy-power/serialize
2009
+ */
2010
+ /** 拼接一条消息中的文本块。 */
2011
+ function flattenText(blocks) {
2012
+ return blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
2013
+ }
2014
+ /** 在 wire 上承载一个请求图像的内联 base64 data URL。 */
2015
+ function imageDataUrl(version) {
2016
+ return `data:${version.mediaType};base64,${Buffer.from(version.data).toString("base64")}`;
2017
+ }
2018
+ /**
2019
+ * 在任何文本扁平化可能静默丢弃图像内容之前先拒绝它。
2020
+ * @param blocks - 消息内容。
2021
+ * @param supportsImages - 所选模型是否声明支持图像输入。
2022
+ */
2023
+ function assertSupportedContent(blocks, supportsImages) {
2024
+ if (!supportsImages && contentHasImage(blocks)) throw new LlmError("The selected CodeBuddy model does not accept image content.", "UNSUPPORTED_CONTENT");
2025
+ }
2026
+ /**
2027
+ * 序列化一条用户消息中非工具结果的块。纯文本内容保持紧凑的
2028
+ * 字符串 wire 形式;含图像的内容变为 OpenAI 兼容的
2029
+ * 部件数组,并保持块顺序。
2030
+ * @param blocks - 消息中非工具结果的块。
2031
+ * @param images - 请求中每个图像已解析的请求版本。
2032
+ * @returns 用户角色的 wire 内容。
2033
+ */
2034
+ function userContent(blocks, images) {
2035
+ if (!contentHasImage(blocks)) return flattenText(blocks);
2036
+ const parts = [];
2037
+ let text = "";
2038
+ const flush = () => {
2039
+ if (text.length === 0) return;
2040
+ parts.push({
2041
+ type: "text",
2042
+ text
2043
+ });
2044
+ text = "";
2045
+ };
2046
+ for (const block of blocks) {
2047
+ if (block.type === "text") {
2048
+ text += block.text;
2049
+ continue;
2050
+ }
2051
+ if (block.type !== "image") continue;
2052
+ const version = images.get(block.attachment.attachmentId);
2053
+ if (version === void 0) throw new LlmError(`CodeBuddy request image ${block.attachment.attachmentId} was not prepared.`, "INVALID_REQUEST");
2054
+ flush();
2055
+ parts.push({
2056
+ type: "image_url",
2057
+ image_url: { url: imageDataUrl(version) }
2058
+ });
2059
+ }
2060
+ flush();
2061
+ return parts;
2062
+ }
2063
+ /** 序列化一个助手轮次:文本、重放的 reasoning 与工具调用。 */
2064
+ function serializeAssistant(message) {
2065
+ const text = flattenText(message.content);
2066
+ const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
2067
+ const toolCalls = message.content.filter((block) => block.type === "tool-call").map((block) => ({
2068
+ id: block.id,
2069
+ type: "function",
2070
+ function: {
2071
+ name: block.name,
2072
+ arguments: block.arguments
2073
+ }
2074
+ }));
2075
+ return {
2076
+ role: "assistant",
2077
+ content: text,
2078
+ ...toolCalls.length > 0 && reasoning.length > 0 ? { reasoning_content: reasoning } : {},
2079
+ ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
2080
+ };
2081
+ }
2082
+ /**
2083
+ * 按顺序序列化整个对话。
2084
+ * @param messages - harness 会话。
2085
+ * @param supportsImages - 所选模型是否声明支持图像输入。
2086
+ * @param images - 请求中每个图像已解析的请求版本。
2087
+ * @returns wire 消息,每个工具结果展开为独立条目。
2088
+ */
2089
+ function serializeMessages(messages, supportsImages, images = /* @__PURE__ */ new Map()) {
2090
+ const wire = [];
2091
+ const toolNames = collectToolNames(messages);
2092
+ for (const message of messages) {
2093
+ assertSupportedContent(message.content, supportsImages);
2094
+ if (message.role === "system") {
2095
+ wire.push({
2096
+ role: "system",
2097
+ content: flattenText(message.content)
2098
+ });
2099
+ continue;
2100
+ }
2101
+ if (message.role === "assistant") {
2102
+ wire.push(serializeAssistant(message));
2103
+ continue;
2104
+ }
2105
+ const toolResults = message.content.filter((block) => block.type === "tool-result");
2106
+ const content = userContent(message.content.filter((block) => block.type !== "tool-result"), images);
2107
+ if (content.length > 0 || toolResults.length === 0) wire.push({
2108
+ role: "user",
2109
+ content
2110
+ });
2111
+ for (const result of toolResults) {
2112
+ const text = flattenText(result.content);
2113
+ const imageBlocks = result.content.filter((block) => block.type === "image");
2114
+ if (!(supportsImages && imageBlocks.length > 0 && toolNames.get(result.toolCallId) === READ_IMAGE_TOOL)) {
2115
+ wire.push({
2116
+ role: "tool",
2117
+ tool_call_id: result.toolCallId,
2118
+ content: text.length === 0 ? "(no output)" : text
2119
+ });
2120
+ continue;
2121
+ }
2122
+ const parts = [];
2123
+ if (text.length > 0) parts.push({
2124
+ type: "text",
2125
+ text
2126
+ });
2127
+ for (const block of imageBlocks) {
2128
+ const version = images.get(block.attachment.attachmentId);
2129
+ if (version === void 0) throw new LlmError(`CodeBuddy request image ${block.attachment.attachmentId} was not prepared.`, "INVALID_REQUEST");
2130
+ parts.push({
2131
+ type: "image_url",
2132
+ image_url: { url: imageDataUrl(version) }
2133
+ });
2134
+ }
2135
+ wire.push({
2136
+ role: "tool",
2137
+ tool_call_id: result.toolCallId,
2138
+ content: parts
2139
+ });
2140
+ }
2141
+ }
2142
+ return wire;
2143
+ }
2144
+ /** 产出需要模型看到的图的那个工具:`read_image`。 */
2145
+ const READ_IMAGE_TOOL = "read_image";
2146
+ /**
2147
+ * 建立 toolCallId → 工具名 的索引。
2148
+ *
2149
+ * tool 结果自身只带 `toolCallId`,而"这张图该不该发给模型"取决于产出它的工具,
2150
+ * 因此需要回到助手轮次的 tool-call 块里查名字。
2151
+ * @param messages - harness 会话。
2152
+ * @returns 每个工具调用 id 对应的工具名。
2153
+ */
2154
+ function collectToolNames(messages) {
2155
+ const names = /* @__PURE__ */ new Map();
2156
+ for (const message of messages) {
2157
+ if (message.role !== "assistant") continue;
2158
+ for (const block of message.content) if (block.type === "tool-call") names.set(block.id, block.name);
2159
+ }
2160
+ return names;
2161
+ }
2162
+ /**
2163
+ * 构建 chat-completions 请求体。始终流式并上报 usage;
2164
+ * 缺省的选项被省略而非以 null 发送,以便应用
2165
+ * 提供方自身的默认值。
2166
+ * @param options - 组装好的 harness 请求。
2167
+ * @param supportsImages - 所选模型是否声明支持图像输入。
2168
+ * @param images - 请求中每个图像已解析的请求版本。
2169
+ * @returns 请求体。
2170
+ */
2171
+ function serializeRequest(options, supportsImages, images = /* @__PURE__ */ new Map()) {
2172
+ const messages = [];
2173
+ if (options.system !== void 0) messages.push({
2174
+ role: "system",
2175
+ content: options.system
2176
+ });
2177
+ messages.push(...serializeMessages(options.messages, supportsImages, images));
2178
+ const tools = options.tools?.map((tool) => ({
2179
+ type: "function",
2180
+ function: {
2181
+ name: tool.name,
2182
+ description: tool.description,
2183
+ parameters: tool.parameters
2184
+ }
2185
+ }));
2186
+ return {
2187
+ model: options.model,
2188
+ messages,
2189
+ stream: true,
2190
+ stream_options: { include_usage: true },
2191
+ ...tools !== void 0 && tools.length > 0 ? { tools } : {},
2192
+ ...options.temperature === void 0 ? {} : { temperature: options.temperature },
2193
+ ...options.maxTokens === void 0 ? {} : { max_tokens: options.maxTokens },
2194
+ ...options.stop === void 0 ? {} : { stop: options.stop },
2195
+ ...options.reasoningEffort === void 0 ? {} : { reasoning_effort: options.reasoningEffort }
2196
+ };
2197
+ }
2198
+ //#endregion
2199
+ //#region lib/types/translate.js
2200
+ /**
2201
+ * 把 CodeBuddy SSE 负载转换成 harness 的 `StreamChunk` 协议。
2202
+ *
2203
+ * 文本、推理和工具调用索引各持有一个打开的块,索引按首次出现的顺序分配。
2204
+ * `block-end`、`usage` 和 `finish` 全部推迟到 `[DONE]` 哨兵:正是这一点满足了
2205
+ * 协议的两项硬性义务——usage 严格早于 finish,且 finish 之后没有任何内容
2206
+ * ——对发送尾部 usage-only 块的 provider 也是如此。
2207
+ *
2208
+ * @module dsh-llm-codebuddy-power/translate
2209
+ */
2210
+ /** 返回属性 schema 是否刻意接受任意 JSON 值。 */
2211
+ function acceptsAnyJsonValue(schema) {
2212
+ if (schema === true) return true;
2213
+ if (schema === null || typeof schema !== "object" || Array.isArray(schema)) return false;
2214
+ const record = schema;
2215
+ return !("type" in record) && !("$ref" in record) && !("const" in record) && !("enum" in record) && !("allOf" in record) && !("anyOf" in record) && !("oneOf" in record) && !("not" in record);
2216
+ }
2217
+ /** 解码 CodeBuddy 以 JSON 字符串形式发出的单个 object/array 值。 */
2218
+ function decodeNestedComposite(value) {
2219
+ if (typeof value !== "string") return value;
2220
+ const text = value.trim();
2221
+ if (!(text.startsWith("{") && text.endsWith("}")) && !(text.startsWith("[") && text.endsWith("]"))) return value;
2222
+ try {
2223
+ const decoded = JSON.parse(text);
2224
+ return decoded !== null && typeof decoded === "object" ? decoded : value;
2225
+ } catch {
2226
+ return value;
2227
+ }
2228
+ }
2229
+ /**
2230
+ * 修复 CodeBuddy 对未受约束 Tool 字段偶发的双重编码。
2231
+ *
2232
+ * 没有 `type` 的属性 schema 是合法 JSON Schema,表示其值可以是任意 JSON
2233
+ * 类型。CodeBuddy 仍可能把为这种字段选定的 object 或 array 渲染成 JSON
2234
+ * 字符串。只解码这一种形态;有类型和其他约束的字段、标量字符串以及格式
2235
+ * 错误的 JSON 一律逐字节保持原样。
2236
+ */
2237
+ function normalizeToolArguments(name, argumentsText, tools) {
2238
+ const properties = tools.find((candidate) => candidate.name === name)?.parameters?.properties;
2239
+ if (properties === void 0) return argumentsText;
2240
+ let args;
2241
+ try {
2242
+ args = JSON.parse(argumentsText);
2243
+ } catch {
2244
+ return argumentsText;
2245
+ }
2246
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return argumentsText;
2247
+ const record = args;
2248
+ let changed = false;
2249
+ for (const [key, schema] of Object.entries(properties)) {
2250
+ if (!acceptsAnyJsonValue(schema) || !(key in record)) continue;
2251
+ const decoded = decodeNestedComposite(record[key]);
2252
+ if (decoded !== record[key]) {
2253
+ record[key] = decoded;
2254
+ changed = true;
2255
+ }
2256
+ }
2257
+ return changed ? JSON.stringify(record) : argumentsText;
2258
+ }
2259
+ /**
2260
+ * 把 wire 的 `finish_reason` 取值映射到 harness 的取值。
2261
+ * @param reason - wire 的取值。
2262
+ * @returns 映射后的原因;任何无法识别的值都会变成 error finish,并以大写后的
2263
+ * wire 取值作为其 code,这样新出现的 provider 原因会以自身形式暴露,而不是
2264
+ * 被静默报告为正常停止。
2265
+ */
2266
+ function mapFinishReason(reason) {
2267
+ switch (reason) {
2268
+ case "stop": return { kind: "stop" };
2269
+ case "tool_calls": return { kind: "tool-calls" };
2270
+ case "length": return { kind: "max-tokens" };
2271
+ default: return {
2272
+ kind: "error",
2273
+ failure: {
2274
+ message: `model stopped: ${reason}`,
2275
+ code: reason.toUpperCase()
2276
+ }
2277
+ };
2278
+ }
2279
+ }
2280
+ /**
2281
+ * 把 wire 的 usage 映射到 harness 的互斥计数。
2282
+ *
2283
+ * CodeBuddy 的 `prompt_tokens` 是 OpenAI 兼容语义,含缓存命中
2284
+ * (`prompt_tokens = 缓存命中 + 未缓存输入`);harness 的 TokenUsage 约定是
2285
+ * 互斥计数,所以缓存命中从 `inputTokens` 里减掉。`totalTokens` 用 wire 的
2286
+ * 原始 prompt+completion 之和(与官方 DeepSeek adapter 一致),仅当
2287
+ * prompt/completion 都是合法计数时才给出。
2288
+ * @param usage - wire 的 usage 块。
2289
+ * @returns 互斥 token 计数;合法时含完整的聚合总数。
2290
+ */
2291
+ function mapUsage(usage) {
2292
+ const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens;
2293
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
2294
+ const prompt = usage.prompt_tokens ?? 0;
2295
+ const completion = usage.completion_tokens ?? 0;
2296
+ const combined = prompt + completion;
2297
+ const hasExactTotal = Number.isSafeInteger(prompt) && prompt >= 0 && Number.isSafeInteger(completion) && completion >= 0 && Number.isSafeInteger(combined);
2298
+ return {
2299
+ inputTokens: Math.max(0, prompt - (cacheRead ?? 0)),
2300
+ outputTokens: completion,
2301
+ ...hasExactTotal ? { totalTokens: combined } : {},
2302
+ ...cacheRead === void 0 ? {} : { cacheReadTokens: cacheRead },
2303
+ ...reasoning === void 0 ? {} : { reasoningTokens: reasoning }
2304
+ };
2305
+ }
2306
+ /** 为一个打开的块组装最终的内容块。 */
2307
+ function closeBlock(block, tools) {
2308
+ switch (block.kind) {
2309
+ case "text": return {
2310
+ type: "text",
2311
+ text: block.text
2312
+ };
2313
+ case "reasoning": return {
2314
+ type: "reasoning",
2315
+ text: block.text
2316
+ };
2317
+ case "tool-call": return {
2318
+ type: "tool-call",
2319
+ id: ToolCallId(block.callId ?? ""),
2320
+ name: block.name ?? "",
2321
+ arguments: normalizeToolArguments(block.name ?? "", block.text, tools)
2322
+ };
2323
+ }
2324
+ }
2325
+ /**
2326
+ * 消费以 `[DONE]` 结尾的 SSE 负载并产出 harness 块。
2327
+ * @param payloads - 来自 `parseSse` 的负载。
2328
+ * @param tools - 用于修复 CodeBuddy wire 参数的原始 Tool schema。
2329
+ * @param sink - 可选出参,调用方通过它观察不随 harness 块协议传递的附带数据
2330
+ * (例如服务扣减的 credits)。
2331
+ * @returns 到达时即产出的增量,块结束、usage 和 finish 在 `[DONE]` 时刷出。
2332
+ * @throws LlmError 在 JSON 无法解析时抛 `MALFORMED_RESPONSE`,在负载源未出现
2333
+ * 哨兵就结束时抛 `STREAM_CLOSED`。
2334
+ */
2335
+ async function* translate(payloads, tools = [], sink) {
2336
+ let nextIndex = 0;
2337
+ let textBlock;
2338
+ let reasoningBlock;
2339
+ const toolBlocks = /* @__PURE__ */ new Map();
2340
+ const order = [];
2341
+ let pendingFinish;
2342
+ let pendingUsage;
2343
+ const open = (kind) => {
2344
+ const block = {
2345
+ index: nextIndex++,
2346
+ kind,
2347
+ text: ""
2348
+ };
2349
+ order.push(block);
2350
+ return block;
2351
+ };
2352
+ for await (const payload of payloads) {
2353
+ if (payload === "[DONE]") {
2354
+ for (const block of order) yield {
2355
+ type: "block-end",
2356
+ index: block.index,
2357
+ block: closeBlock(block, tools)
2358
+ };
2359
+ if (pendingUsage !== void 0) yield {
2360
+ type: "usage",
2361
+ usage: pendingUsage
2362
+ };
2363
+ const reason = pendingFinish ?? { kind: "stop" };
2364
+ yield {
2365
+ type: "finish",
2366
+ reason: reason.kind === "stop" && order.length === 0 ? {
2367
+ kind: "error",
2368
+ failure: {
2369
+ message: "model returned a completed response with no content",
2370
+ code: EMPTY_RESPONSE_CODE
2371
+ }
2372
+ } : reason
2373
+ };
2374
+ return;
2375
+ }
2376
+ let chunk;
2377
+ try {
2378
+ chunk = JSON.parse(payload);
2379
+ } catch {
2380
+ throw new LlmError(`malformed CodeBuddy SSE payload: ${payload.slice(0, 120)}`, "MALFORMED_RESPONSE");
2381
+ }
2382
+ for (const choice of chunk.choices ?? []) {
2383
+ const delta = choice.delta;
2384
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning;
2385
+ if (typeof reasoning === "string" && reasoning.length > 0) {
2386
+ if (reasoningBlock === void 0) {
2387
+ reasoningBlock = open("reasoning");
2388
+ yield {
2389
+ type: "block-start",
2390
+ index: reasoningBlock.index,
2391
+ blockType: "reasoning"
2392
+ };
2393
+ }
2394
+ reasoningBlock.text += reasoning;
2395
+ yield {
2396
+ type: "reasoning-delta",
2397
+ index: reasoningBlock.index,
2398
+ text: reasoning
2399
+ };
2400
+ }
2401
+ const content = delta?.content;
2402
+ if (typeof content === "string" && content.length > 0) {
2403
+ if (textBlock === void 0) {
2404
+ textBlock = open("text");
2405
+ yield {
2406
+ type: "block-start",
2407
+ index: textBlock.index,
2408
+ blockType: "text"
2409
+ };
2410
+ }
2411
+ textBlock.text += content;
2412
+ yield {
2413
+ type: "text-delta",
2414
+ index: textBlock.index,
2415
+ text: content
2416
+ };
2417
+ }
2418
+ for (const call of delta?.tool_calls ?? []) {
2419
+ let block = toolBlocks.get(call.index);
2420
+ if (block === void 0) {
2421
+ block = open("tool-call");
2422
+ toolBlocks.set(call.index, block);
2423
+ yield {
2424
+ type: "block-start",
2425
+ index: block.index,
2426
+ blockType: "tool-call"
2427
+ };
2428
+ }
2429
+ if (call.id !== void 0 && call.id.length > 0) block.callId = call.id;
2430
+ const name = call.function?.name;
2431
+ if (name !== void 0 && name.length > 0) block.name = name;
2432
+ const fragment = call.function?.arguments ?? "";
2433
+ block.text += fragment;
2434
+ yield {
2435
+ type: "tool-call-delta",
2436
+ index: block.index,
2437
+ id: ToolCallId(block.callId ?? ""),
2438
+ ...block.name === void 0 ? {} : { name: block.name },
2439
+ argumentsDelta: fragment
2440
+ };
2441
+ }
2442
+ if (typeof choice.finish_reason === "string") pendingFinish = mapFinishReason(choice.finish_reason);
2443
+ }
2444
+ if (chunk.usage !== void 0 && chunk.usage !== null) {
2445
+ pendingUsage = mapUsage(chunk.usage);
2446
+ if (sink !== void 0 && chunk.usage.credit !== void 0) sink.credit = chunk.usage.credit;
2447
+ }
2448
+ }
2449
+ throw new LlmError("CodeBuddy SSE payload stream ended without [DONE]", "STREAM_CLOSED");
2450
+ }
2451
+ //#endregion
2452
+ //#region lib/types/types.js
2453
+ /**
2454
+ * 本插件读取的 wire 结构,分属两套互不相关的协议。
2455
+ *
2456
+ * CodeBuddy 控制平面(`/v2/plugin/auth/*`、`/v3/config`)把每个回复包在
2457
+ * `{code, msg, requestId, data}` 中,且不兼容 OpenAI——这正是本插件存在、
2458
+ * 而不使用普通 OpenAI 兼容路由的原因。对话平面(`/v2/chat/completions`)
2459
+ * 兼容 OpenAI,其 chunk 结构即常见结构。
2460
+ *
2461
+ * @module dsh-llm-codebuddy-power/types
2462
+ */
2463
+ /**
2464
+ * 目录是否披露了 harness 所需的容量。
2465
+ *
2466
+ * harness 要求它提供的每个模型都有正的 `contextWindow` 与输出上限,而
2467
+ * CodeBuddy 对非对话模型的条目(补全、改写/跳转、图片生成)两者都不给。
2468
+ * 为这些条目编造数字会把不可用的模型以猜测的尺寸放进选择器,因此调用方
2469
+ * 改为丢弃它们。放在这里、紧邻 wire 类型,使列表与解析路径不会对哪些条目
2470
+ * 可提供产生分歧。
2471
+ * @param model - 一个目录条目。
2472
+ * @returns 两个容量都存在且为正时返回 true。
2473
+ */
2474
+ function hasDisclosedCapacity(model) {
2475
+ return model.maxAllowedSize !== void 0 && model.maxAllowedSize > 0 && model.maxOutputTokens !== void 0 && model.maxOutputTokens > 0;
2476
+ }
2477
+ /**
2478
+ * 插件有意不提供的模型 id,即使目录把它们描述为完全支持对话。
2479
+ *
2480
+ * CodeBuddy 的目录会列出并非真实模型的默认/别名条目:它们的请求在用量记录
2481
+ * 中会落成另一个模型(`default` 别名解析为 `deepseek-v4-flash`),而选择器
2482
+ * 会把它显示为独立选项,因此提供它们会误导用户。被屏蔽的 id 只在列表中
2483
+ * 滤除;显式指名的 id 仍会走通(服务以 "The requested model is not
2484
+ * available" 拒绝),与选择器隐藏服务无法提供选项的做法一致。
2485
+ */
2486
+ const BLOCKED_MODEL_IDS = new Set(["default", "codewise-default-model-v2"]);
2487
+ /**
2488
+ * 某个目录条目 id 是否在屏蔽列表中。
2489
+ * @param id - 目录条目 id。
2490
+ * @returns 插件不得提供该模型时返回 true。
2491
+ */
2492
+ function isBlockedModelId(id) {
2493
+ return BLOCKED_MODEL_IDS.has(id);
2494
+ }
2495
+ //#endregion
2496
+ //#region lib/types/adapter.js
2497
+ /**
2498
+ * `CodeBuddyAdapter`:针对 CodeBuddy 的 OpenAI 兼容聊天路由使用 fetch + SSE,
2499
+ * 身份与模型目录则从 OAuth 会话解析。
2500
+ *
2501
+ * 这一区分很重要:聊天面兼容 OpenAI,而目录面并不兼容,因此模型信息取自 CodeBuddy
2502
+ * 自己的 `/v3/config` 响应——每个模型的工具调用、推理、图像与尺寸事实都出自那里。
2503
+ * 本类中不存在任何 API key;每个请求都由会话刷新的、浏览器签发的 bearer token 授权。
2504
+ *
2505
+ * @module dsh-llm-codebuddy-power/adapter
2506
+ */
2507
+ /** 在 `retry-after` 响应头携带可用延迟时,把它解析为毫秒。 */
2508
+ function providerRetryAfterMs(value) {
2509
+ if (value === null) return void 0;
2510
+ if (/^\d+$/.test(value)) {
2511
+ const delay = Number(value) * 1e3;
2512
+ return Number.isFinite(delay) && delay > 0 ? delay : void 0;
2513
+ }
2514
+ const delay = Date.parse(value) - Date.now();
2515
+ return Number.isFinite(delay) && delay > 0 ? delay : void 0;
2516
+ }
2517
+ function requestId(headers) {
2518
+ const value = headers.get("x-request-id") ?? headers.get("x-requestid");
2519
+ return value === null || value.length === 0 ? void 0 : ProviderRequestId(value);
2520
+ }
2521
+ /** 收集单条消息内容中的全部图像引用,包括工具结果内的引用。 */
2522
+ function collectImageRefs(content, refs) {
2523
+ for (const block of content) if (block.type === "image") refs.set(block.attachment.attachmentId, block.attachment);
2524
+ else if (block.type === "tool-result") collectImageRefs(block.content, refs);
2525
+ }
2526
+ /**
2527
+ * 把"长边不超过 longEdge"折算为该图的总像素预算。
2528
+ *
2529
+ * 请求投影只接受总像素上限(`ImageRequestPolicy.maxPixels`),没有长边字段,
2530
+ * 而总像素无法约束长边:8000×460 仅 368 万像素,长边却达 8000。投影按
2531
+ * `sqrt(maxPixels / (width * height))` 等比缩放,因此令
2532
+ * `maxPixels = longEdge² × (短边 / 长边)` 时缩放系数恰为 `longEdge / 长边`,
2533
+ * 结果正好把长边压到该档。
2534
+ *
2535
+ * 长边本就在档内时返回原图总像素。这**不是**防放大所必需的:投影自身以
2536
+ * `min(1, …)` 截断缩放系数,且长边 ≤ longEdge 时恒有
2537
+ * `longEdge² × (短边/长边) ≥ 原面积`,故照公式给值也不会被放大。返回原图
2538
+ * 面积的作用是让**预算值在各档之间重复**,从而被逐档重试的去重跳过,避免
2539
+ * 对同一尺寸重复编码。
2540
+ *
2541
+ * 宽高由会话格式的载荷校验强制为正整数(`payload-validation.ts` 的
2542
+ * `imageAttachmentValue`),故此处不做防御检查。
2543
+ * @param width - 原图宽度(像素)。
2544
+ * @param height - 原图高度(像素)。
2545
+ * @param longEdge - 本档的长边上限(像素)。
2546
+ * @returns 该图在该档下的 `maxPixels` 取值,恒为正整数。
2547
+ */
2548
+ function pixelBudgetForLongEdge(width, height, longEdge) {
2549
+ const source = Math.max(width, height);
2550
+ if (source <= longEdge) return width * height;
2551
+ const scale = longEdge / source;
2552
+ return Math.max(1, Math.round(width * scale)) * Math.max(1, Math.round(height * scale));
2553
+ }
2554
+ /**
2555
+ * 解析一张图的请求版本:源图未超字节目标时原样复用,超过时才逐档收紧长边。
2556
+ *
2557
+ * 未超目标的一支不做任何转换:`maxPixels` 取原图总像素,投影因此不缩放,附件
2558
+ * 服务命中其直通分支返回原始字节,既不重编码也不写请求缓存 —— 降到 1920 会
2559
+ * 白白丢掉分辨率与画质,而模型侧的约束只有字节目标。
2560
+ *
2561
+ * 超过目标的一支才逐档收紧长边。附件服务的编码阶梯只降质量、不降尺寸,单靠
2562
+ * 一档无法保证落到字节目标,故需要换更小的长边重来;预算相同的档位会被跳过。
2563
+ * @param ref - 持久化附件引用。
2564
+ * @param attachments - 已挂载的附件服务。
2565
+ * @param signal - 本次生成的取消信号。
2566
+ * @returns 首个达标(或最小)的请求版本。
2567
+ */
2568
+ async function requestImageVersion(ref, attachments, signal) {
2569
+ const maxBytes = REQUEST_IMAGE_MAX_BYTES;
2570
+ if (ref.bytes <= maxBytes) return attachments.readImageRequest(ref, {
2571
+ maxPixels: ref.width * ref.height,
2572
+ maxBytes
2573
+ }, signal);
2574
+ const tried = /* @__PURE__ */ new Set();
2575
+ let smallest;
2576
+ for (const longEdge of REQUEST_IMAGE_DIMENSION_LADDER) {
2577
+ const maxPixels = pixelBudgetForLongEdge(ref.width, ref.height, longEdge);
2578
+ if (tried.has(maxPixels)) continue;
2579
+ tried.add(maxPixels);
2580
+ const version = await attachments.readImageRequest(ref, {
2581
+ maxPixels,
2582
+ maxBytes
2583
+ }, signal);
2584
+ if (version.bytes <= maxBytes) return version;
2585
+ if (smallest === void 0 || version.bytes < smallest.bytes) smallest = version;
2586
+ }
2587
+ return smallest;
2588
+ }
2589
+ /**
2590
+ * 在所选模型接受图像输入时,解析会话所携带每张图像的请求版本。持久化 harness 以引用
2591
+ * 方式附加;请求字节由附件服务按逐图的像素与字节预算派生,与官方 DeepSeek 适配器
2592
+ * 的准备方式一致。
2593
+ * @param messages - 等待序列化的 harness 消息。
2594
+ * @param attachments - 持久化附件服务;未挂载时为 undefined。
2595
+ * @param signal - 本次生成的取消信号。
2596
+ * @returns 以附件 id 为键的请求版本;会话不携带图像时为空 Map。
2597
+ */
2598
+ async function prepareRequestImages(messages, attachments, signal) {
2599
+ const refs = /* @__PURE__ */ new Map();
2600
+ for (const message of messages) collectImageRefs(message.content, refs);
2601
+ if (refs.size === 0) return /* @__PURE__ */ new Map();
2602
+ if (attachments === void 0) throw new LlmError("CodeBuddy image input requires the durable attachment service.", "UNSUPPORTED_CONTENT");
2603
+ const ordered = [...refs.values()];
2604
+ const versions = await Promise.all(ordered.map((ref) => requestImageVersion(ref, attachments, signal)));
2605
+ return new Map(ordered.map((ref, index) => [ref.attachmentId, versions[index]]));
2606
+ }
2607
+ /**
2608
+ * 把 HTTP 状态码映射为稳定的 harness 错误码。
2609
+ * @param status - 非 2xx 的状态码。
2610
+ * @param error - 可读取时,已解析的 provider 错误体。
2611
+ * @returns 归一化后的错误码。
2612
+ */
2613
+ function httpErrorCode(status, error) {
2614
+ if (status === 401 || status === 403) return "AUTH";
2615
+ const detail = [
2616
+ error?.code,
2617
+ error?.type,
2618
+ error?.message
2619
+ ].filter(Boolean).join(" ");
2620
+ if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
2621
+ if (status === 429) return "RATE_LIMIT";
2622
+ if (status === 400) {
2623
+ if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
2624
+ return "INVALID_REQUEST";
2625
+ }
2626
+ if (status >= 500) return "SERVER";
2627
+ return `HTTP_${status}`;
2628
+ }
2629
+ /**
2630
+ * 把目录中的模型投影为 harness 的 model-info 结构。
2631
+ *
2632
+ * 名称保持目录中的原始名称——不带 👀 标记或积分倍率:那些属于插件自己的模型面板,
2633
+ * 而不是官方选择器。
2634
+ */
2635
+ function modelInfo(provider, model) {
2636
+ return {
2637
+ provider,
2638
+ id: model.id,
2639
+ name: model.name,
2640
+ inputModalities: model.supportsImages === true ? ["text", "image"] : ["text"]
2641
+ };
2642
+ }
2643
+ /** CodeBuddy effort 词表中各取值的可读名称。 */
2644
+ const EFFORT_NAMES = {
2645
+ low: "Low",
2646
+ medium: "Medium",
2647
+ high: "High",
2648
+ xhigh: "Extra high",
2649
+ max: "Max"
2650
+ };
2651
+ /**
2652
+ * 把 CodeBuddy 公开的思考等级转换为 harness 的推理元数据;目录未给出任何可选值时
2653
+ * 返回 `undefined`。
2654
+ *
2655
+ * 这些等级作为不透明 id 原样传递,而不映射到固定刻度:它们正是聊天 endpoint 接受的
2656
+ * `reasoning_effort` 取值,因此 CodeBuddy 之后新增的等级无需在此改动代码。无法识别的
2657
+ * id 仍会依据自身拼写得到可读名称。
2658
+ *
2659
+ * 以下三种情况返回 `undefined` 而不是部分值,因为 harness 会把它们一律判为
2660
+ * INVALID_MODEL_REASONING,而被拒绝的目录比缺失的能力更糟:
2661
+ * - 完全没有 reasoning 块;
2662
+ * - 有 reasoning 块但没有 `supportedEfforts`(`auto` 会声明一个生效的 `effort`
2663
+ * 却没有列表,用户无从选择);
2664
+ * - 列表为空,或去重后为空。
2665
+ */
2666
+ function reasoningInfo(model) {
2667
+ const supported = model.reasoning?.supportedEfforts;
2668
+ if (supported === void 0) return void 0;
2669
+ const seen = /* @__PURE__ */ new Set();
2670
+ const efforts = [];
2671
+ for (const raw of supported) {
2672
+ const id = typeof raw === "string" ? raw.trim() : "";
2673
+ if (id.length === 0 || seen.has(id)) continue;
2674
+ seen.add(id);
2675
+ efforts.push({
2676
+ id: ReasoningEffortId(id),
2677
+ name: EFFORT_NAMES[id] ?? id
2678
+ });
2679
+ }
2680
+ if (efforts.length === 0) return void 0;
2681
+ const candidate = model.reasoning?.defaultEffort ?? model.reasoning?.effort;
2682
+ const defaultEffort = candidate !== void 0 && seen.has(candidate) ? ReasoningEffortId(candidate) : void 0;
2683
+ return {
2684
+ efforts,
2685
+ ...defaultEffort === void 0 ? {} : { defaultEffort }
2686
+ };
2687
+ }
2688
+ /**
2689
+ * CodeBuddy 适配器。单个实例服务于唯一的 `codebuddy` 路由,
2690
+ * 以及该路由目录报告的全部模型。
2691
+ */
2692
+ var CodeBuddyAdapter = class extends LlmAdapter {
2693
+ config;
2694
+ constructor(config) {
2695
+ super();
2696
+ this.config = config;
2697
+ }
2698
+ providerInfo(provider) {
2699
+ return {
2700
+ id: provider,
2701
+ name: CODEBUDDY_DISPLAY_NAME
2702
+ };
2703
+ }
2704
+ async listModels(provider) {
2705
+ await this.config.session.modelsOrEmpty();
2706
+ return this.config.session.knownModels().filter((model) => hasDisclosedCapacity(model) && !isBlockedModelId(model.id)).map((model) => modelInfo(provider, model));
2707
+ }
2708
+ async resolveModel(provider, model, signal) {
2709
+ const connection = this.config.options();
2710
+ const overrides = this.config.resolveOverrides === void 0 ? void 0 : await this.config.resolveOverrides(model);
2711
+ const entry = this.config.session.knownModel(model) ?? (await this.config.session.modelsOrEmpty(signal)).find((candidate) => candidate.id === model);
2712
+ if (entry === void 0) return {
2713
+ provider,
2714
+ id: model,
2715
+ name: model,
2716
+ inputModalities: ["text"],
2717
+ context: { contextWindow: overrides?.contextWindow ?? connection.defaultContextWindow },
2718
+ defaultMaxTokens: overrides?.maxOutputTokens ?? connection.defaultMaxTokens
2719
+ };
2720
+ const reasoning = reasoningInfo(entry);
2721
+ return {
2722
+ ...modelInfo(provider, entry),
2723
+ context: { contextWindow: overrides?.contextWindow ?? (entry.maxAllowedSize !== void 0 && entry.maxAllowedSize > 0 ? entry.maxAllowedSize : connection.defaultContextWindow) },
2724
+ defaultMaxTokens: overrides?.maxOutputTokens ?? (entry.maxOutputTokens !== void 0 && entry.maxOutputTokens > 0 ? entry.maxOutputTokens : connection.defaultMaxTokens),
2725
+ ...reasoning === void 0 ? {} : { reasoning }
2726
+ };
2727
+ }
2728
+ async *stream(options) {
2729
+ const connection = this.config.options();
2730
+ const sessionId = options.sessionId === void 0 ? void 0 : String(options.sessionId);
2731
+ let identity;
2732
+ try {
2733
+ identity = await this.config.session.requestIdentity(sessionId);
2734
+ } catch (error) {
2735
+ if (error instanceof NotLoggedInError) throw new LlmError(error.message, "MISSING_CREDENTIAL", { cause: error });
2736
+ throw error;
2737
+ }
2738
+ const entry = (await this.config.session.modelsOrEmpty(options.signal, sessionId)).find((candidate) => candidate.id === options.model);
2739
+ const supportsImages = entry?.supportsImages === true;
2740
+ if (options.tools !== void 0 && options.tools.length > 0 && entry?.supportsToolCall === false) throw new LlmError(`CodeBuddy model "${options.model}" does not support tool calls`, "UNSUPPORTED_OPTION");
2741
+ const body = serializeRequest(options, supportsImages, supportsImages ? await prepareRequestImages(options.messages, this.config.resolveAttachments(), options.signal) : /* @__PURE__ */ new Map());
2742
+ const payload = gzipSync(Buffer.from(JSON.stringify(body), "utf8"));
2743
+ const messageId = compactUuid();
2744
+ const conversationId = options.sessionId === void 0 ? compactUuid() : this.config.session.conversationIdOf(String(options.sessionId));
2745
+ const conversationRequestId = options.sessionId === void 0 ? compactUuid() : this.config.session.turnRequestIdOf(String(options.sessionId)) ?? compactUuid();
2746
+ let response;
2747
+ try {
2748
+ const requestTrace = traceHeaders();
2749
+ const monitorPrepare = String(Date.now());
2750
+ const requestHeaders = {
2751
+ ...identity.headers,
2752
+ ...clientIdentityHeaders(),
2753
+ "X-Request-ID": conversationRequestId,
2754
+ "content-type": "application/json;charset=UTF-8",
2755
+ "content-encoding": "gzip",
2756
+ "accept": "*/*",
2757
+ "accept-language": "*",
2758
+ "User-Agent": ideUserAgent(),
2759
+ "X-Agent-Intent": "craft",
2760
+ "X-Conversation-ID": conversationId,
2761
+ "X-Conversation-Request-ID": conversationRequestId,
2762
+ "X-Conversation-Message-ID": messageId,
2763
+ "X-Env-ID": "production",
2764
+ "X-IDE-Type": "CodeBuddyIDE",
2765
+ "X-IDE-Name": "CodeBuddyIDE",
2766
+ "X-IDE-Version": CODEBUDDY_IDE_VERSION,
2767
+ "X-Product-Version": CODEBUDDY_IDE_VERSION,
2768
+ "X-Model-ID": options.model,
2769
+ "X-Requested-With": "XMLHttpRequest",
2770
+ ...requestTrace,
2771
+ "monitor_promptpreparestarttime": monitorPrepare,
2772
+ "monitor_httpsendtime": String(Date.now())
2773
+ };
2774
+ response = await fetch(`${connection.baseURL}/chat/completions`, {
2775
+ method: "POST",
2776
+ headers: requestHeaders,
2777
+ body: payload,
2778
+ ...options.signal === void 0 ? {} : { signal: options.signal }
2779
+ });
2780
+ } catch (error) {
2781
+ if (options.signal?.aborted) throw new LlmError("CodeBuddy request aborted by caller", "ABORTED", { cause: error });
2782
+ throw new LlmError(`CodeBuddy request to ${connection.baseURL} failed`, "TRANSPORT", { cause: error });
2783
+ }
2784
+ if (!response.ok) {
2785
+ let message = `CodeBuddy API error (HTTP ${response.status})`;
2786
+ let providerError;
2787
+ try {
2788
+ const parsed = await response.json();
2789
+ providerError = parsed.error;
2790
+ if (providerError?.message !== void 0 && providerError.message.length > 0) message = providerError.message;
2791
+ else if (parsed.msg !== void 0 && parsed.msg.length > 0) message = `${parsed.displayMsg?.en ?? parsed.msg} (HTTP ${response.status})`;
2792
+ } catch {}
2793
+ if (response.status === 401 || response.status === 403) this.config.session.invalidate();
2794
+ const delay = providerRetryAfterMs(response.headers.get("retry-after"));
2795
+ const id = requestId(response.headers);
2796
+ throw new LlmError(message, httpErrorCode(response.status, providerError), {
2797
+ status: response.status,
2798
+ ...delay === void 0 ? {} : { providerRetryAfterMs: delay },
2799
+ ...id === void 0 ? {} : { requestId: id }
2800
+ });
2801
+ }
2802
+ if (response.body === null) throw new LlmError("CodeBuddy API returned no response body", "EMPTY_RESPONSE");
2803
+ const creditSink = {};
2804
+ try {
2805
+ yield* translate(parseSse(response.body), options.tools, creditSink);
2806
+ } finally {
2807
+ if (creditSink.credit !== void 0 && options.sessionId !== void 0) this.config.session.recordCredit(String(options.sessionId), creditSink.credit, identity.account);
2808
+ }
2809
+ }
2810
+ };
2811
+ //#endregion
2812
+ //#region lib/types/image-tool.js
2813
+ /**
2814
+ * codebuddy-draw 工具:调用 CodeBuddy 文生图模型生成图片,通过标准附件服务
2815
+ * 持久化,并在对话中内联展示。
2816
+ *
2817
+ * 与官方 IDE 的 image_gen 工具一致:聊天模型用提示词调用它,请求携带全套 IDE
2818
+ * 身份头(后台计费按 IDE 客户端与会话请求 id 记录)。生成的字节走
2819
+ * attachments.saveImage 提交到标准附件仓库(content-addressed、归一化、
2820
+ * 回放安全),轨迹/详情按标准 ImageBlock 渲染;对话卡片由浏览器半的
2821
+ * DrawToolCard 经 uiConversation.imageUrl 标准附件通道读取展示。
2822
+ *
2823
+ * @module dsh-llm-codebuddy-power/image-tool
2824
+ */
2825
+ /** 工具名;注册前检查唯一性。 */
2826
+ const CODEBUDDY_DRAW_TOOL = "codebuddy-draw";
2827
+ /** 文生图模型 id(后台计费记录为会话模型)。 */
2828
+ const TEXT_TO_IMAGE_MODEL = "hunyuan-image-alpha";
2829
+ /** 图片下方标注实际扣分的文本;也告诉模型图已显示在对话里,无需复述或重试。 */
2830
+ function formatDrawEnvelope(value) {
2831
+ return `Generated ${value.images.length} image${value.images.length === 1 ? "" : "s"} and displayed ${value.images.length === 1 ? "it" : "them"} in the conversation; ${value.usedCredits} CodeBuddy credits spent (x5.00 billing multiplier). You do not need to describe the image content; just confirm to the user that it was generated.`;
2832
+ }
2833
+ /**
2834
+ * 注册 codebuddy-draw 工具。
2835
+ * @param ctx - 注册作用域;执行时使用 session 与 attachments。
2836
+ * @param session - 已登录的 CodeBuddy 会话(身份头 + 会话 id)。
2837
+ * @param attachments - 图片持久化所用的标准附件仓库。
2838
+ */
2839
+ function applyImageTool(ctx, session, attachments) {
2840
+ if (ctx.tools.get("codebuddy-draw") !== void 0) throw new Error(`tool "${CODEBUDDY_DRAW_TOOL}" is already registered; refusing to shadow it`);
2841
+ ctx.tools.register(defineTool({
2842
+ name: CODEBUDDY_DRAW_TOOL,
2843
+ description: "Generate an image from a text prompt using CodeBuddy's text-to-image model (Hunyuan Image Alpha). Use when the user asks to generate, create, or draw an image; the generated image renders inline in the chat. Provide a detailed prompt describing the desired image.",
2844
+ parameters: {
2845
+ prompt: {
2846
+ type: "string",
2847
+ required: true,
2848
+ description: "Text description of the image to generate."
2849
+ },
2850
+ size: {
2851
+ type: "string",
2852
+ enum: [
2853
+ "1024x1024",
2854
+ "1024x1536",
2855
+ "1536x1024"
2856
+ ],
2857
+ description: "Output size; defaults to 1024x1024."
2858
+ },
2859
+ quality: {
2860
+ type: "string",
2861
+ enum: [
2862
+ "low",
2863
+ "medium",
2864
+ "high"
2865
+ ],
2866
+ description: "Quality hint."
2867
+ },
2868
+ style: {
2869
+ type: "string",
2870
+ description: "Style hint, passed through to the model."
2871
+ },
2872
+ n: {
2873
+ type: "integer",
2874
+ description: "Number of images; defaults to 1."
2875
+ }
2876
+ },
2877
+ output: {
2878
+ schema: {
2879
+ type: "object",
2880
+ additionalProperties: false,
2881
+ properties: {
2882
+ prompt: {
2883
+ type: "string",
2884
+ required: true
2885
+ },
2886
+ usedCredits: {
2887
+ type: "number",
2888
+ required: true
2889
+ },
2890
+ images: {
2891
+ type: "array",
2892
+ required: true,
2893
+ items: {
2894
+ type: "object",
2895
+ additionalProperties: false,
2896
+ properties: {
2897
+ attachmentId: {
2898
+ type: "string",
2899
+ required: true
2900
+ },
2901
+ mediaType: {
2902
+ type: "string",
2903
+ required: true
2904
+ },
2905
+ bytes: {
2906
+ type: "integer",
2907
+ required: true
2908
+ },
2909
+ width: {
2910
+ type: "integer",
2911
+ required: true
2912
+ },
2913
+ height: {
2914
+ type: "integer",
2915
+ required: true
2916
+ }
2917
+ }
2918
+ }
2919
+ }
2920
+ }
2921
+ },
2922
+ render: (_args, value) => [...value.images.map((image) => ({
2923
+ type: "image",
2924
+ attachment: image
2925
+ })), {
2926
+ type: "text",
2927
+ text: formatDrawEnvelope(value)
2928
+ }],
2929
+ presentationMeta: (_args, value) => ({
2930
+ kind: "codebuddy-draw",
2931
+ prompt: value.prompt,
2932
+ usedCredits: value.usedCredits
2933
+ })
2934
+ },
2935
+ isConcurrencySafe: () => false,
2936
+ async execute(args, exec) {
2937
+ const provider = exec.agent?.session.requestHeader()?.config.provider;
2938
+ if (provider !== void 0 && provider !== "codebuddy") throw new Error(`codebuddy-draw charges CodeBuddy credits, so only a CodeBuddy model may call it; the current agent uses provider "${provider}"`);
2939
+ const agentSessionId = exec.agent?.session.id;
2940
+ const sessionId = agentSessionId === void 0 ? void 0 : String(agentSessionId);
2941
+ const identity = await session.requestIdentity(sessionId);
2942
+ const conversationId = sessionId === void 0 ? compactUuid() : session.conversationIdOf(sessionId);
2943
+ const requestId = sessionId === void 0 ? compactUuid() : session.turnRequestIdOf(sessionId) ?? compactUuid();
2944
+ const messageId = compactUuid();
2945
+ const generated = await generateImage(identity.headers, TEXT_TO_IMAGE_MODEL, args, {
2946
+ conversationId,
2947
+ requestId,
2948
+ messageId
2949
+ }, exec.signal);
2950
+ const images = [];
2951
+ for (const image of generated.images) {
2952
+ if (image.url === void 0 && image.b64_json === void 0) continue;
2953
+ const data = image.b64_json !== void 0 ? Buffer.from(image.b64_json, "base64") : await downloadImage(image.url, exec.signal);
2954
+ images.push(await attachments.saveImage({
2955
+ data,
2956
+ mediaType: "image/png"
2957
+ }));
2958
+ }
2959
+ if (images.length === 0) throw new Error("image generation returned no usable image data");
2960
+ if (sessionId !== void 0 && generated.usedCredits > 0) session.recordCredit(sessionId, generated.usedCredits, identity.account);
2961
+ return {
2962
+ images,
2963
+ usedCredits: generated.usedCredits,
2964
+ prompt: args.prompt
2965
+ };
2966
+ }
2967
+ }));
2968
+ }
2969
+ /** 从签名对象 URL 下载生成的图片字节。 */
2970
+ async function downloadImage(url, signal) {
2971
+ const response = await fetch(url, { ...signal === void 0 ? {} : { signal } });
2972
+ if (!response.ok) throw new Error(`image download failed (HTTP ${response.status})`);
2973
+ return Buffer.from(await response.arrayBuffer());
2974
+ }
2975
+ //#endregion
2976
+ //#region lib/types/rpc-route.js
2977
+ /**
2978
+ * 本插件私有浏览器 RPC 通道的物理 HTTP 路由持有者。
2979
+ *
2980
+ * `connection.rpc.handle()` 无法在功能插件中使用:它在 Connection 服务自身的
2981
+ * Context 下注册路由,而该 Context 的 fiber 从未声明 `webServer`,因此注册会抛出
2982
+ * `cannot get property "webServer" without inject`,通道永远无法挂载,浏览器调用
2983
+ * 随后落到 SPA 回退(HTTP 405)。本模块改为在同时声明这两个服务的 inject fork 中,
2984
+ * 把前缀路由直接注册到 `webServer` 上,复现 Connection 桥本应提供的同一传输契约:
2985
+ * 先做浏览器认证,然后接收 JSON `client-request` 信封、返回 `server-response` 信封。
2986
+ *
2987
+ * @module dsh-llm-codebuddy-power/rpc-route
2988
+ */
2989
+ /** 单个已缓冲请求体的上限;这些信封只携带很小的 JSON 载荷。 */
2990
+ const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
2991
+ /** 单个 endpoint 路径段允许的字符(与 Connection 通道文法一致)。 */
2992
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
2993
+ /** 信封在尚未读出 id 前就已畸形时回显的 rpcId。 */
2994
+ const INVALID_REQUEST_RPC_ID = "invalid-request";
2995
+ /**
2996
+ * 把一个私有 RPC 通道挂载为 Web 前缀路由。
2997
+ *
2998
+ * 该路由由下方的 inject fork 持有,因此卸载本插件的 fiber 时会将其移除。
2999
+ * 不含 `webServer` 的 profile(headless)不会解析出该 fork,也就不会挂载路由。
3000
+ * @param ctx - 插件自身的 Context,用于创建持有路由的 inject fork。
3001
+ * @param channel - 绝对通道前缀,例如 `/codebuddy`。
3002
+ * @param handler - 已解码的 endpoint 处理器,返回 RPC 结果信封。
3003
+ */
3004
+ function mountPrivateChannel(ctx, channel, handler) {
3005
+ ctx.inject(["connection", "webServer"], (rpcCtx) => {
3006
+ rpcCtx.effect(() => rpcCtx.webServer.register({
3007
+ kind: "prefix",
3008
+ path: channel,
3009
+ handler: (req, res) => serveChannel(rpcCtx, channel, handler, req, res)
3010
+ }), `dsh-llm-codebuddy-power: ${channel} rpc channel`);
3011
+ });
3012
+ }
3013
+ /** 通道之下某个请求路径对应的 endpoint 名称,不存在时为 undefined。 */
3014
+ function endpointOf(channel, pathname) {
3015
+ if (!pathname.startsWith(`${channel}/`)) return void 0;
3016
+ const endpoint = pathname.slice(channel.length + 1);
3017
+ if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return;
3018
+ return endpoint;
3019
+ }
3020
+ /**
3021
+ * 在通道上完成一次请求的认证、解码、分发与应答。
3022
+ * @param rpcCtx - 提供 `connection` 与 `webServer` 的 inject fork Context。
3023
+ * @param channel - 本路由持有的通道前缀。
3024
+ * @param handler - 已解码的 endpoint 处理器。
3025
+ * @param req - 到来的 HTTP 请求。
3026
+ * @param res - 本路由在整个生命周期内持有的响应。
3027
+ */
3028
+ async function serveChannel(rpcCtx, channel, handler, req, res) {
3029
+ const rejection = rpcCtx.connection.requestRejection(req);
3030
+ if (rejection !== void 0) {
3031
+ res.writeHead(rejection);
3032
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
3033
+ return;
3034
+ }
3035
+ const endpoint = endpointOf(channel, new URL(req.url ?? "/", "http://dsh.internal").pathname);
3036
+ if (req.method !== "POST" || endpoint === void 0) {
3037
+ res.writeHead(404);
3038
+ res.end("not found");
3039
+ return;
3040
+ }
3041
+ if (req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
3042
+ res.writeHead(415);
3043
+ res.end("content type must be application/json");
3044
+ return;
3045
+ }
3046
+ const body = await readBody(req);
3047
+ if (body === void 0) {
3048
+ res.writeHead(413);
3049
+ res.end("body too large");
3050
+ return;
3051
+ }
3052
+ let envelope;
3053
+ try {
3054
+ envelope = JSON.parse(body);
3055
+ } catch {
3056
+ res.writeHead(400);
3057
+ res.end("body is not JSON");
3058
+ return;
3059
+ }
3060
+ const message = parseEnvelope(envelope);
3061
+ if (message === void 0) {
3062
+ writeJson(res, 200, serverResponse(INVALID_REQUEST_RPC_ID, {
3063
+ ok: false,
3064
+ error: {
3065
+ code: "gateway/bad-request",
3066
+ message: "invalid client-request message",
3067
+ details: {}
3068
+ }
3069
+ }));
3070
+ return;
3071
+ }
3072
+ if (message.method !== endpoint) {
3073
+ writeJson(res, 200, serverResponse(message.rpcId, {
3074
+ ok: false,
3075
+ error: {
3076
+ code: "gateway/bad-request",
3077
+ message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
3078
+ details: {}
3079
+ }
3080
+ }));
3081
+ return;
3082
+ }
3083
+ try {
3084
+ const controller = new AbortController();
3085
+ res.on("close", () => {
3086
+ controller.abort();
3087
+ });
3088
+ const result = await handler(endpoint, message.payload, controller.signal);
3089
+ writeJson(res, 200, serverResponse(message.rpcId, result));
3090
+ } catch (error) {
3091
+ res.writeHead(500);
3092
+ res.end(`handler failure: ${String(error)}`);
3093
+ }
3094
+ }
3095
+ /** 读取一个请求体;超过 {@link MAX_REQUEST_BODY_BYTES} 时返回 undefined。 */
3096
+ async function readBody(req) {
3097
+ const chunks = [];
3098
+ let total = 0;
3099
+ for await (const chunk of req) {
3100
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3101
+ total += buffer.byteLength;
3102
+ if (total > MAX_REQUEST_BODY_BYTES) {
3103
+ req.destroy();
3104
+ return;
3105
+ }
3106
+ chunks.push(buffer);
3107
+ }
3108
+ return Buffer.concat(chunks).toString("utf-8");
3109
+ }
3110
+ /** 解码信封;未携带必需字段时返回 undefined。 */
3111
+ function parseEnvelope(value) {
3112
+ if (typeof value !== "object" || value === null) return void 0;
3113
+ const record = value;
3114
+ if (record.type !== "client-request") return void 0;
3115
+ if (typeof record.rpcId !== "string" || typeof record.method !== "string") return void 0;
3116
+ return {
3117
+ rpcId: record.rpcId,
3118
+ method: record.method,
3119
+ payload: record.payload
3120
+ };
3121
+ }
3122
+ /** 构建回传给浏览器的 `server-response` 信封。 */
3123
+ function serverResponse(rpcId, result) {
3124
+ return {
3125
+ type: "server-response",
3126
+ rpcId,
3127
+ result
3128
+ };
3129
+ }
3130
+ /** 以给定状态码写回一个 JSON 响应。 */
3131
+ function writeJson(res, status, value) {
3132
+ const body = Buffer.from(JSON.stringify(value), "utf-8");
3133
+ res.writeHead(status, {
3134
+ "content-type": "application/json",
3135
+ "content-length": String(body.byteLength)
3136
+ });
3137
+ res.end(body);
3138
+ }
3139
+ //#endregion
3140
+ //#region lib/types/login.js
3141
+ /**
3142
+ * 浏览器 OAuth 登录流程,从发起到凭据落盘。
3143
+ *
3144
+ * 全程不索取 API key:握手生成一个 `state`,用户在普通浏览器中
3145
+ * 登录,然后依据该 state 从服务轮询取出令牌。用户需要做的
3146
+ * 只是在一个登录页面上点击完成。
3147
+ *
3148
+ * @module dsh-llm-codebuddy-power/login
3149
+ */
3150
+ /**
3151
+ * 由刚签发的令牌与账号信息构建一个可持久化的账号条目。
3152
+ *
3153
+ * CLI 流程与 Web 认证服务共用,以免两者在存储结构上
3154
+ * 产生分歧:两者写入的都是这个对象。
3155
+ * @param token - 浏览器登录完成后签发的令牌。
3156
+ * @param account - 令牌所对应的已登录账号。
3157
+ * @returns 要持久化的账号条目。
3158
+ */
3159
+ function buildAccount(token, account) {
3160
+ return {
3161
+ auth: {
3162
+ accessToken: token.accessToken,
3163
+ expiresAt: Date.now() + token.expiresIn * 1e3,
3164
+ refreshToken: token.refreshToken,
3165
+ refreshExpiresAt: Date.now() + token.refreshExpiresIn * 1e3,
3166
+ domain: token.domain
3167
+ },
3168
+ account: {
3169
+ uid: account.uid,
3170
+ nickname: account.nickname,
3171
+ ...account.uin === void 0 ? {} : { uin: account.uin },
3172
+ ...account.enterpriseId === void 0 ? {} : { enterpriseId: account.enterpriseId },
3173
+ ...account.enterpriseName === void 0 ? {} : { enterpriseName: account.enterpriseName },
3174
+ ...account.enterpriseUserName === void 0 ? {} : { enterpriseUserName: account.enterpriseUserName },
3175
+ ...account.departmentFullName === void 0 ? {} : { departmentFullName: account.departmentFullName }
3176
+ }
3177
+ };
3178
+ }
3179
+ /**
3180
+ * 尽力在用户的默认浏览器中打开 URL。
3181
+ *
3182
+ * 失败被刻意设为静默:URL 已经打印出来,因此无头或受限
3183
+ * 环境仍有可用的前进路径,否则 spawn 错误会被
3184
+ * 读作登录失败。
3185
+ */
3186
+ function openInBrowser(url) {
3187
+ let command;
3188
+ let args;
3189
+ if (process.platform === "win32") {
3190
+ command = "rundll32";
3191
+ args = ["url.dll,FileProtocolHandler", url];
3192
+ } else {
3193
+ command = process.platform === "darwin" ? "open" : "xdg-open";
3194
+ args = [url];
3195
+ }
3196
+ try {
3197
+ const child = spawn(command, args, {
3198
+ stdio: "ignore",
3199
+ detached: true
3200
+ });
3201
+ child.on("error", () => {});
3202
+ child.unref();
3203
+ } catch {}
3204
+ }
3205
+ /**
3206
+ * 运行完整的浏览器登录流程。
3207
+ * @param hooks - 用户交互钩子。
3208
+ * @param signal - 可选取消。
3209
+ * @returns 已追加到账号列表的账号与已登录昵称。
3210
+ * @throws Error 握手失败、用户未及时完成,或
3211
+ * 账号无法读取时。
3212
+ */
3213
+ async function login(hooks = {}, signal) {
3214
+ const state = await requestAuthState(signal);
3215
+ hooks.onUrl?.(state.authUrl);
3216
+ if (hooks.openBrowser !== false) openInBrowser(state.authUrl);
3217
+ const token = await pollAuthToken(state.state, signal);
3218
+ if (token === void 0) throw new Error("CodeBuddy sign-in did not complete (it was refused, or it timed out).");
3219
+ const account = await getLoginAccount(state.state, token.accessToken, token.domain);
3220
+ const entry = buildAccount(token, account);
3221
+ await upsertAccount(entry);
3222
+ return {
3223
+ account: entry,
3224
+ nickname: account.nickname
3225
+ };
3226
+ }
3227
+ //#endregion
3228
+ //#region lib/types/model-enrich.js
3229
+ /**
3230
+ * 客户端模型面板的模型增强:推广/等级解析、按模型的覆盖值存储,以及
3231
+ * RPC 安全的目录投影。
3232
+ *
3233
+ * 推广是 CodeBuddy 的定时活动(徽章 + 悬浮提示 + 可选折扣);等级是订阅档位
3234
+ * 徽章("订阅优先")。二者都是提示性的展示数据:主机解析展示事实,客户端负责
3235
+ * 渲染。覆盖值让用户按模型限制上下文窗口 / 最大输出 token,统一存储在
3236
+ * `$DSH_HOME/data/plugins/llm-codebuddy-power/`。
3237
+ *
3238
+ * @module dsh-llm-codebuddy-power/model-enrich
3239
+ */
3240
+ /** 覆盖值文件的绝对路径。 */
3241
+ function overridesPath() {
3242
+ return dshHomePath("data", "plugins", "llm-codebuddy-power", "model-overrides.json");
3243
+ }
3244
+ /** 加载覆盖值表;文件缺失或损坏时按空表处理(丢失用户限制是安全的)。 */
3245
+ async function loadOverrides() {
3246
+ try {
3247
+ const raw = await promises.readFile(overridesPath(), "utf-8");
3248
+ const parsed = JSON.parse(raw);
3249
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
3250
+ const table = {};
3251
+ for (const [id, value] of Object.entries(parsed)) {
3252
+ if (typeof value !== "object" || value === null) continue;
3253
+ const entry = value;
3254
+ table[id] = {
3255
+ ...typeof entry.contextWindow === "number" && Number.isFinite(entry.contextWindow) && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {},
3256
+ ...typeof entry.maxOutputTokens === "number" && Number.isFinite(entry.maxOutputTokens) && entry.maxOutputTokens > 0 ? { maxOutputTokens: entry.maxOutputTokens } : {}
3257
+ };
3258
+ }
3259
+ return table;
3260
+ } catch {
3261
+ return {};
3262
+ }
3263
+ }
3264
+ /** 原子地持久化整张覆盖值表(读-改-写由调用方负责)。 */
3265
+ async function saveOverrides(table) {
3266
+ const path = overridesPath();
3267
+ await promises.mkdir(dirname(path), { recursive: true });
3268
+ const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
3269
+ try {
3270
+ await promises.writeFile(temp, JSON.stringify(table, null, 2), {
3271
+ encoding: "utf-8",
3272
+ mode: 384
3273
+ });
3274
+ await promises.rename(temp, path);
3275
+ } catch (error) {
3276
+ await promises.unlink(temp).catch(() => {});
3277
+ throw error;
3278
+ }
3279
+ }
3280
+ /**
3281
+ * 判断某个推广当前是否生效:已启用、处于绝对有效期内,且声明了每日时段时
3282
+ * 落在匹配的每日时段内(考虑时区)。
3283
+ * @param promotion - 待判定的推广活动。
3284
+ * @param now - 判定时刻,epoch 毫秒。
3285
+ * @returns 活动当前生效时为 true。
3286
+ */
3287
+ function isPromotionActive(promotion, now = Date.now()) {
3288
+ if (promotion.enabled === false) return false;
3289
+ const schedule = promotion.schedule;
3290
+ if (schedule === void 0) return true;
3291
+ if (schedule.validFrom !== void 0 && Date.parse(schedule.validFrom) > now) return false;
3292
+ if (schedule.validUntil !== void 0 && Date.parse(schedule.validUntil) <= now) return false;
3293
+ if (schedule.daily === void 0 || schedule.daily.length === 0) return true;
3294
+ const offsetMs = tzOffsetMs(schedule.timezone, now);
3295
+ const local = new Date(now + offsetMs);
3296
+ const minutes = local.getUTCHours() * 60 + local.getUTCMinutes();
3297
+ for (const window of schedule.daily) {
3298
+ const start = parseHhMm(window.start);
3299
+ const end = parseHhMm(window.end);
3300
+ if (start === void 0 || end === void 0) continue;
3301
+ if (start <= end && minutes >= start && minutes < end) return true;
3302
+ if (start > end && (minutes >= start || minutes < end)) return true;
3303
+ }
3304
+ return false;
3305
+ }
3306
+ /** IANA 时区 → 某一时刻相对 UTC 的毫秒偏移;查找失败时回退到 UTC。 */
3307
+ function tzOffsetMs(timezone, at) {
3308
+ if (timezone === void 0) return 0;
3309
+ try {
3310
+ const formatter = new Intl.DateTimeFormat("en-US", {
3311
+ timeZone: timezone,
3312
+ hour12: false,
3313
+ year: "numeric",
3314
+ month: "2-digit",
3315
+ day: "2-digit",
3316
+ hour: "2-digit",
3317
+ minute: "2-digit",
3318
+ second: "2-digit"
3319
+ });
3320
+ const parts = Object.fromEntries(formatter.formatToParts(new Date(at)).map((p) => [p.type, p.value]));
3321
+ const y = Number(parts.year);
3322
+ const mo = Number(parts.month) - 1;
3323
+ const d = Number(parts.day);
3324
+ const h = Number(parts.hour) === 24 ? 0 : Number(parts.hour);
3325
+ const mi = Number(parts.minute);
3326
+ const s = Number(parts.second);
3327
+ return Date.UTC(y, mo, d, h, mi, s) - (at - at % 1e3);
3328
+ } catch {
3329
+ return 0;
3330
+ }
3331
+ }
3332
+ /** 把 "HH:MM" 解析为自午夜起的分钟数,无法解析时为 undefined。 */
3333
+ function parseHhMm(value) {
3334
+ if (value === void 0) return void 0;
3335
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
3336
+ if (match === null) return void 0;
3337
+ const hours = Number(match[1]);
3338
+ const minutes = Number(match[2]);
3339
+ if (hours > 23 || minutes > 59) return void 0;
3340
+ return hours * 60 + minutes;
3341
+ }
3342
+ /**
3343
+ * 把倍率标签归一化为"x 在前"的写法。
3344
+ *
3345
+ * 服务端自身不一致:模型目录给的标价是 `x0.79`,`discount` 给的折后值是
3346
+ * `0.50x`/`0x`。两种写法并列时(原价划掉 + 折后价)看起来像两个不同量纲的
3347
+ * 数字,所以统一为 `x0.79`/`x0.50`/`x0`。
3348
+ * @param value - 服务端给的倍率标签。
3349
+ * @returns 归一化后的标签;无法识别时原样返回。
3350
+ */
3351
+ function normalizeMultiplier(value) {
3352
+ const trimmed = value.trim();
3353
+ const trailingX = /^([0-9]+(?:\.[0-9]+)?)x$/i.exec(trimmed);
3354
+ return trailingX === null ? trimmed : `x${trailingX[1]}`;
3355
+ }
3356
+ /**
3357
+ * 解析某个模型最终胜出的生效推广:在 `modelIds` 覆盖该模型且当前生效的活动
3358
+ * 中,优先级最高者胜出(CodeBuddy 自身的选择规则)。只投影展示事实。
3359
+ * @param promotions - 全部推广活动。
3360
+ * @param modelId - 待解析的模型。
3361
+ * @returns 胜出活动的展示事实,没有时为 undefined。
3362
+ */
3363
+ function promotionFor(promotions, modelId, now = Date.now()) {
3364
+ let winner;
3365
+ for (const promotion of promotions) {
3366
+ if (promotion.modelIds === void 0 || !promotion.modelIds.includes(modelId)) continue;
3367
+ if (!isPromotionActive(promotion, now)) continue;
3368
+ const { label, color } = promotion.badge ?? {};
3369
+ if (label === void 0 || color === void 0) continue;
3370
+ const priority = promotion.priority ?? 0;
3371
+ if (winner !== void 0 && winner.priority >= priority) continue;
3372
+ const { textZh, textEn } = promotion.hover ?? {};
3373
+ const action = promotion.hover?.action;
3374
+ winner = {
3375
+ priority,
3376
+ view: {
3377
+ label,
3378
+ color,
3379
+ ...promotion.discount?.discountedCredits === void 0 ? {} : { discountedCredits: normalizeMultiplier(promotion.discount.discountedCredits) },
3380
+ ...promotion.discount?.displayMode === void 0 ? {} : { displayMode: promotion.discount.displayMode },
3381
+ ...textZh === void 0 ? {} : { textZh },
3382
+ ...textEn === void 0 ? {} : { textEn },
3383
+ ...action === void 0 ? {} : { action: {
3384
+ ...action.type === void 0 ? {} : { type: action.type },
3385
+ ...action.labelZh === void 0 ? {} : { labelZh: action.labelZh },
3386
+ ...action.labelEn === void 0 ? {} : { labelEn: action.labelEn }
3387
+ } }
3388
+ }
3389
+ };
3390
+ }
3391
+ return winner?.view;
3392
+ }
3393
+ /**
3394
+ * 解析挂在某个模型上的等级徽章。
3395
+ * @param tiers - 全部模型等级。
3396
+ * @param modelId - 待解析的模型。
3397
+ * @returns 首个匹配等级的展示事实,没有时为 undefined。
3398
+ */
3399
+ function tierFor(tiers, modelId) {
3400
+ if (tiers === void 0) return void 0;
3401
+ for (const tier of tiers) {
3402
+ if (tier.enabled === false) continue;
3403
+ if (tier.modelIds === void 0 || !tier.modelIds.includes(modelId)) continue;
3404
+ const { label } = tier.badge ?? {};
3405
+ if (label === void 0) continue;
3406
+ return {
3407
+ label,
3408
+ ...tier.hover?.textZh === void 0 ? {} : { textZh: tier.hover.textZh },
3409
+ ...tier.hover?.textEn === void 0 ? {} : { textEn: tier.hover.textEn },
3410
+ ...tier.hover?.action === void 0 ? {} : { action: {
3411
+ ...tier.hover.action.type === void 0 ? {} : { type: tier.hover.action.type },
3412
+ ...tier.hover.action.labelZh === void 0 ? {} : { labelZh: tier.hover.action.labelZh },
3413
+ ...tier.hover.action.labelEn === void 0 ? {} : { labelEn: tier.hover.action.labelEn }
3414
+ } }
3415
+ };
3416
+ }
3417
+ }
3418
+ /**
3419
+ * 把目录模型及其推广/等级事实投影为可安全下发给客户端的增强行。
3420
+ * 提供 `withOverrides` 时应用按模型的用户限制。
3421
+ * @param model - 目录模型。
3422
+ * @param promotions - 全部推广活动(按模型逐个解析)。
3423
+ * @param tiers - 全部模型等级。
3424
+ * @param withOverrides - 按模型的用户限制,没有时为 undefined。
3425
+ * @returns 增强后的行。
3426
+ */
3427
+ function enrichModel(model, promotions, tiers, withOverrides) {
3428
+ const promotion = promotionFor(promotions, model.id);
3429
+ const tier = tierFor(tiers, model.id);
3430
+ const contextWindow = withOverrides?.contextWindow ?? model.maxAllowedSize;
3431
+ const maxOutputTokens = withOverrides?.maxOutputTokens ?? model.maxOutputTokens;
3432
+ return {
3433
+ id: model.id,
3434
+ name: model.name,
3435
+ ...model.credits === void 0 ? {} : { credits: normalizeMultiplier(model.credits) },
3436
+ ...promotion === void 0 ? {} : { promotion },
3437
+ ...tier === void 0 ? {} : { tier },
3438
+ ...model.supportsImages === void 0 ? {} : { supportsImages: model.supportsImages },
3439
+ ...model.supportsReasoning === void 0 ? {} : { supportsReasoning: model.supportsReasoning },
3440
+ ...model.supportsToolCall === void 0 ? {} : { supportsToolCall: model.supportsToolCall },
3441
+ ...contextWindow === void 0 ? {} : { contextWindow },
3442
+ ...maxOutputTokens === void 0 ? {} : { maxOutputTokens },
3443
+ ...model.maxAllowedSize === void 0 ? {} : { contextWindowCap: model.maxAllowedSize },
3444
+ ...model.maxOutputTokens === void 0 ? {} : { maxOutputTokensCap: model.maxOutputTokens },
3445
+ ...model.descriptionZh === void 0 ? {} : { descriptionZh: model.descriptionZh },
3446
+ ...model.descriptionEn === void 0 ? {} : { descriptionEn: model.descriptionEn }
3447
+ };
3448
+ }
3449
+ //#endregion
3450
+ //#region lib/types/auth-service.js
3451
+ /**
3452
+ * 通过私有 RPC 通道暴露给 Web 客户端的宿主侧 OAuth 服务。
3453
+ *
3454
+ * 浏览器登录是长时操作(要等待用户完成登录),因此拆成两个 RPC endpoint:
3455
+ * `startLogin` 生成握手并返回用户必须打开的 URL,`pollLogin` 检查该握手是否已完成;
3456
+ * 一次完成的登录会**追加**一个账号,而不是取代已有的。`accounts`、`activate`、
3457
+ * `setDefault`、`logout` 是设置页与会话账号按钮其余时间驱动的读写组合。
3458
+ *
3459
+ * @module dsh-llm-codebuddy-power/auth-service
3460
+ */
3461
+ /** 客户端调用认证服务所用的 RPC 通道。 */
3462
+ const CODEBUDDY_AUTH_CHANNEL = "/codebuddy";
3463
+ function ok(value) {
3464
+ return {
3465
+ ok: true,
3466
+ value
3467
+ };
3468
+ }
3469
+ function err(code, message) {
3470
+ return {
3471
+ ok: false,
3472
+ error: {
3473
+ code,
3474
+ message,
3475
+ details: {}
3476
+ }
3477
+ };
3478
+ }
3479
+ /** 从 RPC payload 中读一个字符串字段;缺失或类型不符时返回空字符串。 */
3480
+ function stringField(payload, key) {
3481
+ if (typeof payload !== "object" || payload === null) return "";
3482
+ const value = payload[key];
3483
+ return typeof value === "string" ? value : "";
3484
+ }
3485
+ /** 从 RPC payload 中读一个非空字符串数组字段;缺失或类型不符时返回空数组。 */
3486
+ function stringListField(payload, key) {
3487
+ if (typeof payload !== "object" || payload === null) return [];
3488
+ const value = payload[key];
3489
+ if (!Array.isArray(value)) return [];
3490
+ return value.filter((item) => typeof item === "string" && item.length > 0);
3491
+ }
3492
+ /** uid 在提示框里显示的位数。 */
3493
+ const CREDIT_UID_DISPLAY_CHARS = 8;
3494
+ /**
3495
+ * 把一条轮次记录投影为发给客户端的结构。
3496
+ *
3497
+ * uid 在**呈现边界**才截断:存储与解析都保留完整值,截断只影响这一个提示行。把它放在
3498
+ * 这里而不是存储层,是因为存储层丢掉的信息无法再恢复,而显示层的取舍可以随时改。
3499
+ * @param record - 已按账号表展开的轮次记录。
3500
+ * @returns 对客户端安全的投影。
3501
+ */
3502
+ function projectCreditRecord(record) {
3503
+ return {
3504
+ credit: record.credit,
3505
+ accounts: record.accounts.map((account) => ({
3506
+ nickname: account.nickname,
3507
+ uidPrefix: account.uid.slice(0, CREDIT_UID_DISPLAY_CHARS)
3508
+ }))
3509
+ };
3510
+ }
3511
+ /**
3512
+ * 把一个自有数据的 {@link UsageWindow} 投影为客户端收到的 RPC 安全结构,
3513
+ * 仅在可选字段存在时才展开它们。
3514
+ * @param window - 计量窗口。
3515
+ * @returns 对客户端安全的投影结果。
3516
+ */
3517
+ function projectWindow(window) {
3518
+ return {
3519
+ name: window.name,
3520
+ ...window.used === void 0 ? {} : { used: window.used },
3521
+ ...window.limit === void 0 ? {} : { limit: window.limit },
3522
+ ...window.resetsAt === void 0 ? {} : { resetsAt: window.resetsAt }
3523
+ };
3524
+ }
3525
+ /**
3526
+ * CodeBuddy 认证 RPC 服务。
3527
+ *
3528
+ * 握手由 `startLogin` 发起,由 `pollLogin` 轮询至完成,其账号由适配器的
3529
+ * `CodeBuddySession` 在下一个请求中取用 → 因此通过 UI 完成的登录无需重启
3530
+ * 即可到达运行中的 harness。`logout` 移除指定账号并使会话缓存失效。
3531
+ */
3532
+ var CodeBuddyAuthService = class {
3533
+ session;
3534
+ /** 按 state id 索引的进行中握手。 */
3535
+ pending = /* @__PURE__ */ new Map();
3536
+ constructor(ctx, session) {
3537
+ this.session = session;
3538
+ mountPrivateChannel(ctx, CODEBUDDY_AUTH_CHANNEL, (endpoint, payload, signal) => this.dispatch(endpoint, payload, signal));
3539
+ }
3540
+ /** 把一次 RPC endpoint 调用路由到对应的处理器。 */
3541
+ async dispatch(endpoint, payload, _signal) {
3542
+ switch (endpoint) {
3543
+ case "accounts": return ok(await this.accounts());
3544
+ case "startLogin": return ok(await this.startLogin());
3545
+ case "pollLogin": return ok(await this.pollLogin(stringField(payload, "state")));
3546
+ case "cancelLogin": return this.cancelLogin(stringField(payload, "state")) ? ok(void 0) : err("bad-request", "a login handshake id is required");
3547
+ case "sessionAccount": return ok(await this.sessionAccount(stringField(payload, "sessionId")));
3548
+ case "activate": return await this.activate(stringField(payload, "sessionId"), stringField(payload, "uid")) ? ok(void 0) : err("unknown-account", "the requested account is not stored");
3549
+ case "setDefault": {
3550
+ const uid = stringField(payload, "uid");
3551
+ return await this.setDefault(uid.length === 0 ? void 0 : uid) ? ok(void 0) : err("unknown-account", "the requested account is not stored");
3552
+ }
3553
+ case "logout":
3554
+ await this.logout(stringListField(payload, "uids"));
3555
+ return ok(void 0);
3556
+ case "reorder":
3557
+ await this.reorder(stringListField(payload, "uids"));
3558
+ return ok(void 0);
3559
+ case "usage": return ok(await this.usage(stringField(payload, "sessionId")));
3560
+ case "creditOf": {
3561
+ const sessionId = stringField(payload, "sessionId");
3562
+ const turn = typeof payload === "object" && payload !== null && "turn" in payload && typeof payload.turn === "number" ? payload.turn : NaN;
3563
+ if (Number.isNaN(turn)) return ok(void 0);
3564
+ const record = await this.session?.creditOf(sessionId, turn);
3565
+ return ok(record === void 0 ? void 0 : projectCreditRecord(record));
3566
+ }
3567
+ case "models": return ok(await this.enrichedModels(stringField(payload, "sessionId")));
3568
+ case "setOverride":
3569
+ await this.setOverride(stringField(payload, "modelId"), payload);
3570
+ return ok(void 0);
3571
+ default: return err("not-found", `unknown auth endpoint: ${endpoint}`);
3572
+ }
3573
+ }
3574
+ /**
3575
+ * 读取已存账号及其默认账号,不要求必须存在。
3576
+ * @returns 账号集合;未存任何内容时为空列表。
3577
+ */
3578
+ async accounts() {
3579
+ if (this.session === void 0) return { accounts: [] };
3580
+ return this.session.accountList();
3581
+ }
3582
+ /**
3583
+ * 发起一次浏览器登录握手。
3584
+ * @returns 用户必须打开的 URL。
3585
+ */
3586
+ async startLogin() {
3587
+ const handshake = await requestAuthState();
3588
+ const controller = new AbortController();
3589
+ const pending = {
3590
+ state: handshake.state,
3591
+ promise: this.runLogin(handshake.state, controller.signal),
3592
+ controller
3593
+ };
3594
+ this.pending.set(handshake.state, pending);
3595
+ pending.promise.finally(() => {
3596
+ if (this.pending.get(handshake.state) === pending) this.pending.delete(handshake.state);
3597
+ });
3598
+ return {
3599
+ authUrl: handshake.authUrl,
3600
+ state: handshake.state
3601
+ };
3602
+ }
3603
+ /**
3604
+ * 检查已发起的握手是否完成。
3605
+ * @param state - 来自 `startLogin` 的握手 id。
3606
+ * @returns 登录是否完成、账号是否已持久化。
3607
+ */
3608
+ async pollLogin(state) {
3609
+ const pending = this.pending.get(state);
3610
+ if (pending === void 0) return { done: false };
3611
+ const entry = await pending.promise;
3612
+ return {
3613
+ done: entry !== void 0,
3614
+ ...entry === void 0 ? {} : { nickname: entry.account.nickname }
3615
+ };
3616
+ }
3617
+ /**
3618
+ * 某会话此刻生效的账号。
3619
+ * @param sessionId - dsh 会话身份;为空时按未选定处理。
3620
+ * @returns 该会话的账号 uid;没有账号时缺省。
3621
+ */
3622
+ async sessionAccount(sessionId) {
3623
+ if (sessionId.length === 0) return {};
3624
+ const uid = await this.session?.activeUid(sessionId);
3625
+ return uid === void 0 ? {} : { uid };
3626
+ }
3627
+ /**
3628
+ * 把某会话的账号切换为指定账号。
3629
+ * @param sessionId - dsh 会话身份。
3630
+ * @param uid - 目标账号 uid。
3631
+ * @returns 该账号已存时为 true。
3632
+ */
3633
+ async activate(sessionId, uid) {
3634
+ if (sessionId.length === 0 || uid.length === 0) return false;
3635
+ return this.session?.activate(sessionId, uid) ?? false;
3636
+ }
3637
+ /**
3638
+ * 放弃一次进行中的登录握手。
3639
+ *
3640
+ * 中止底层的令牌轮询并丢弃该握手:用户点了取消之后,客户端不再轮询,而宿主也不该
3641
+ * 继续占着一个十分钟的轮询循环。未知或已完成的 state 视作已经结束,返回 true ——
3642
+ * 取消是幂等的收尾动作,没有"已经取消过"这种失败。
3643
+ * @param state - 来自 `startLogin` 的握手 id。
3644
+ * @returns 该握手是否已被放弃(未知 state 同样是 true)。
3645
+ */
3646
+ cancelLogin(state) {
3647
+ if (state.length === 0) return false;
3648
+ const pending = this.pending.get(state);
3649
+ if (pending === void 0) return true;
3650
+ pending.controller.abort();
3651
+ this.pending.delete(state);
3652
+ return true;
3653
+ }
3654
+ /**
3655
+ * 指定或取消默认账号。
3656
+ * @param uid - 默认账号 uid;`undefined` 表示取消默认账号。
3657
+ * @returns 指定时该账号已存、或取消成功时为 true。
3658
+ */
3659
+ async setDefault(uid) {
3660
+ if (uid !== void 0) {
3661
+ if (!(await this.accounts()).accounts.some((account) => account.uid === uid)) return false;
3662
+ }
3663
+ await setDefaultAccount(uid);
3664
+ this.session?.invalidate();
3665
+ return true;
3666
+ }
3667
+ /**
3668
+ * 移除若干账号。
3669
+ * @param uids - 要移除的账号 uid。
3670
+ */
3671
+ async logout(uids) {
3672
+ if (uids.length === 0) return;
3673
+ await removeAccounts(uids);
3674
+ this.session?.invalidate();
3675
+ }
3676
+ /**
3677
+ * 按给定顺序重排账号。
3678
+ *
3679
+ * 顺序即展示顺序,也是没有默认账号、也没有手动切换记录时的兜底账号,因此这里真的
3680
+ * 改变"用谁"。重排会让输入区控件组读到的账号集合顺序变化,故同样使会话缓存失效。
3681
+ * @param uids - 目标顺序上的账号 uid。
3682
+ */
3683
+ async reorder(uids) {
3684
+ if (uids.length === 0) return;
3685
+ await reorderAccounts(uids);
3686
+ this.session?.invalidate();
3687
+ }
3688
+ /**
3689
+ * 为界面读取某会话账号的 CodeBuddy 用量快照。
3690
+ *
3691
+ * 委托给会话完成:会话会在读取计量前解析刷新后的身份,且计量服务不可用时不会抛错。
3692
+ * 没有账号时按 `loggedIn: false` 上报,让客户端隐藏该呈现,
3693
+ * 而不是渲染出坏掉的进度条。
3694
+ * @param sessionId - dsh 会话身份。
3695
+ * @returns 用量投影;没有账号时返回未登录结构。
3696
+ */
3697
+ async usage(sessionId) {
3698
+ const snapshot = await this.session?.usage(sessionId);
3699
+ if (snapshot === void 0) return { loggedIn: false };
3700
+ const main = snapshot.main === void 0 ? void 0 : {
3701
+ used: snapshot.main.used,
3702
+ limit: snapshot.main.limit
3703
+ };
3704
+ const gift = snapshot.gift === void 0 ? void 0 : {
3705
+ used: snapshot.gift.used,
3706
+ limit: snapshot.gift.limit,
3707
+ soonest: snapshot.gift.soonest.map(projectWindow)
3708
+ };
3709
+ return {
3710
+ loggedIn: true,
3711
+ ...snapshot.primaryResetsAt === void 0 ? {} : { primaryResetsAt: snapshot.primaryResetsAt },
3712
+ ...main === void 0 ? {} : { main },
3713
+ ...gift === void 0 ? {} : { gift }
3714
+ };
3715
+ }
3716
+ /**
3717
+ * 把一次握手推进到持久化的账号。
3718
+ *
3719
+ * 复用 `buildAccount`,使落盘结构与 CLI 登录完全一致。
3720
+ * 任何失败都返回 `undefined`,让客户端的轮询得到 `done: false`,
3721
+ * 从而可以从 `startLogin` 重试。
3722
+ */
3723
+ async runLogin(state, signal) {
3724
+ try {
3725
+ const token = await pollAuthToken(state, signal);
3726
+ if (token === void 0) return void 0;
3727
+ const entry = buildAccount(token, await getLoginAccount(state, token.accessToken, token.domain));
3728
+ await upsertAccount(entry);
3729
+ this.session?.invalidate();
3730
+ return entry;
3731
+ } catch {
3732
+ return;
3733
+ }
3734
+ }
3735
+ /**
3736
+ * 供客户端面板使用的增强版 CodeBuddy 目录:应用了促销/等级展示信息与覆盖配置的模型。
3737
+ * @param sessionId - 用哪个会话的账号读目录;为空时用默认账号。
3738
+ * @returns 增强结果;会话不可用或该账号没有目录时为空行。
3739
+ */
3740
+ async enrichedModels(sessionId) {
3741
+ if (this.session === void 0) return { models: [] };
3742
+ const store = await loadOverrides();
3743
+ const catalog = await this.session.catalogDataOrEmpty(void 0, sessionId.length === 0 ? void 0 : sessionId);
3744
+ return { models: catalog.models.filter(hasDisclosedCapacity).filter((model) => !isBlockedModelId(model.id)).map((model) => enrichModel(model, catalog.promotions, catalog.tiers, store[model.id])) };
3745
+ }
3746
+ /**
3747
+ * 插入或更新(或清除)单个模型的用户覆盖配置,并持久化整张表。
3748
+ * @param modelId - 模型 id。
3749
+ * @param payload - `{ contextWindow?, maxOutputTokens? }`;显式缺省或非正数的字段
3750
+ * 会清除对应的上限。
3751
+ */
3752
+ async setOverride(modelId, payload) {
3753
+ if (modelId.length === 0) return;
3754
+ const table = await loadOverrides();
3755
+ const raw = typeof payload === "object" && payload !== null ? payload : {};
3756
+ const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
3757
+ const contextWindow = positive(raw.contextWindow);
3758
+ const maxOutputTokens = positive(raw.maxOutputTokens);
3759
+ const next = {
3760
+ ...contextWindow === void 0 ? {} : { contextWindow },
3761
+ ...maxOutputTokens === void 0 ? {} : { maxOutputTokens }
3762
+ };
3763
+ if (Object.keys(next).length === 0) {
3764
+ const rest = {};
3765
+ for (const id of Object.keys(table)) if (id !== modelId) rest[id] = table[id];
3766
+ await saveOverrides(rest);
3767
+ this.session?.invalidate();
3768
+ return;
3769
+ }
3770
+ table[modelId] = next;
3771
+ await saveOverrides(table);
3772
+ this.session?.invalidate();
3773
+ }
3774
+ };
3775
+ //#endregion
3776
+ //#region lib/types/prefs.js
3777
+ /**
3778
+ * CodeBuddy 界面偏好的命名空间契约,Host 与 Client 两面共享。
3779
+ *
3780
+ * 这些偏好是**插件自己的设置命名空间**里的一段:`Config` 提供部署级 base,
3781
+ * 用户在 Web 设置页的写入落在 harness 的用户设置文档里,因此换浏览器、重启
3782
+ * harness 后仍然生效 —— 其中「极简模式画图工具」还会被宿主读取来决定
3783
+ * `codebuddy-draw` 对模型是否可见,存在浏览器里会让同一次部署因浏览器而异。
3784
+ *
3785
+ * 本文件必须保持零依赖:两面都会编译它,而 Client 面若因此引入 schemastery
3786
+ * 就会把它打进浏览器产物(校验 schema 是宿主面的事,见 `./settings.ts`)。
3787
+ *
3788
+ * @module dsh-llm-codebuddy-power/prefs
3789
+ */
3790
+ /** 本插件拥有的设置命名空间,与插件名一致。 */
3791
+ const CODEBUDDY_SETTINGS_NAMESPACE = "llm-codebuddy-power";
3792
+ /** 未覆盖任何一层时使用的偏好默认值。 */
3793
+ const DEFAULT_CODEBUDDY_SETTINGS = {
3794
+ showUsage: true,
3795
+ dangerPct: 90,
3796
+ drawToolInMinimal: false
3797
+ };
3798
+ //#endregion
3799
+ //#region lib/types/settings.js
3800
+ /**
3801
+ * 本插件设置命名空间的宿主 schema。
3802
+ *
3803
+ * 校验只在宿主发生:浏览器面若也引入 schemastery,它会连同校验器一起被打进
3804
+ * `lib/client.js`,而客户端本来就把服务端返回的数据当作已校验结果接收。因此
3805
+ * schema 只在此文件出现,浏览器面经 `./prefs.ts` 取同一份类型与默认值。
3806
+ *
3807
+ * 字段的默认值一律取自 `DEFAULT_CODEBUDDY_SETTINGS`,不在此重复字面量 ——
3808
+ * 两处各写一份时,改了一处就会出现「宿主默认 90、界面显示 80」这种只有用户
3809
+ * 看得见的偏差。
3810
+ *
3811
+ * @module dsh-llm-codebuddy-power/settings
3812
+ */
3813
+ /** 用户设置文档中本插件命名空间的 schema。 */
3814
+ const CodeBuddySettingsSchema = z.object({
3815
+ showUsage: z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.showUsage),
3816
+ dangerPct: z.number().step(1).min(1).max(100).default(DEFAULT_CODEBUDDY_SETTINGS.dangerPct),
3817
+ drawToolInMinimal: z.boolean().default(DEFAULT_CODEBUDDY_SETTINGS.drawToolInMinimal)
3818
+ });
3819
+ //#endregion
3820
+ //#region lib/types/index.js
3821
+ /**
3822
+ * DeepSeek Harness 的 Tencent CodeBuddy provider 插件。
3823
+ *
3824
+ * 在 `ctx.llm` 上注册一条 `codebuddy` 路由,由浏览器 OAuth 登录而非 API key
3825
+ * 授权,并提供 CodeBuddy 自己的(非 OpenAI)目录端点所报告的模型。
3826
+ *
3827
+ * 通过 Web 设置 → CodeBuddy 页面登录,或在终端执行
3828
+ * `dsh plugin --profile web codebuddy-power`。无需 API key,运行中的 harness
3829
+ * 无需重启即可取得该凭据。
3830
+ *
3831
+ * @module dsh-llm-codebuddy-power
3832
+ */
3833
+ /** Cordis 插件名。 */
3834
+ const name = "llm-codebuddy-power";
3835
+ /**
3836
+ * 本插件的激活服务。`llm` 承载适配器路由,`tools` 接受文生图工具注册,
3837
+ * `agents` 用于极简模式会话中按 agent 限制画图工具可见性。生成图片经
3838
+ * 标准附件通道(attachments.saveImage + uiConversation.imageUrl)持久化与
3839
+ * 展示,工具注册在附件服务挂载后(`ctx.inject(['attachments'])`)。
3840
+ */
3841
+ const inject = [
3842
+ "llm",
3843
+ "tools",
3844
+ "agents"
3845
+ ];
3846
+ /**
3847
+ * 校验并补全原始配置。
3848
+ *
3849
+ * 编程式构造可以绕过任何 schema,因此边界在此判定,坏值在加载时就带字段名
3850
+ * 失败,而不是等到请求中途。
3851
+ * @param config - 原始入口配置。
3852
+ * @returns 解析出的连接事实。
3853
+ */
3854
+ function resolveConnectionOptions(config = {}) {
3855
+ const positiveInteger = (value, field, fallback) => {
3856
+ if (value === void 0) return fallback;
3857
+ if (!Number.isInteger(value) || value <= 0) throw new Error(`dsh-llm-codebuddy-power: ${field} must be a positive integer`);
3858
+ return value;
3859
+ };
3860
+ const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? 3e5;
3861
+ if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0) throw new Error("dsh-llm-codebuddy-power: streamIdleTimeoutMs must be a positive finite number");
3862
+ const baseURL = config.baseURL ?? CODEBUDDY_CHAT_BASE;
3863
+ if (baseURL.length === 0) throw new Error("dsh-llm-codebuddy-power: baseURL must not be empty");
3864
+ return {
3865
+ baseURL: baseURL.replace(/\/+$/, ""),
3866
+ defaultContextWindow: positiveInteger(config.defaultContextWindow, "defaultContextWindow", DEFAULT_CONTEXT_WINDOW),
3867
+ defaultMaxTokens: positiveInteger(config.defaultMaxTokens, "defaultMaxTokens", DEFAULT_MAX_TOKENS),
3868
+ streamIdleTimeoutMs
3869
+ };
3870
+ }
3871
+ /**
3872
+ * 解析界面偏好的部署级 base 层。用户设置文档中的写入覆盖它。
3873
+ *
3874
+ * 与 {@link resolveConnectionOptions} 同理,编程式构造可以绕过任何 schema,
3875
+ * 因此坏值在此判定并在加载时失败。
3876
+ * @param config - 原始入口配置。
3877
+ * @returns 设置命名空间的 base 值。
3878
+ */
3879
+ function resolvePrefsBase(config = {}) {
3880
+ const dangerPct = config.dangerPct ?? DEFAULT_CODEBUDDY_SETTINGS.dangerPct;
3881
+ if (!Number.isInteger(dangerPct) || dangerPct < 1 || dangerPct > 100) throw new Error(`dsh-llm-codebuddy-power: dangerPct must be an integer within 1..100`);
3882
+ return {
3883
+ showUsage: config.showUsage ?? DEFAULT_CODEBUDDY_SETTINGS.showUsage,
3884
+ dangerPct,
3885
+ drawToolInMinimal: config.drawToolInMinimal ?? DEFAULT_CODEBUDDY_SETTINGS.drawToolInMinimal
3886
+ };
3887
+ }
3888
+ /** 挂载插件:解析配置,然后注册路由。 */
3889
+ function apply(ctx, config = {}) {
3890
+ const resolved = resolveConnectionOptions(config);
3891
+ const prefsBase = resolvePrefsBase(config);
3892
+ let prefsSource = () => prefsBase;
3893
+ ctx.inject(["settings"], (settingsCtx) => {
3894
+ settingsCtx.settings.installSection(ctx, CODEBUDDY_SETTINGS_NAMESPACE, CodeBuddySettingsSchema, prefsBase, {
3895
+ setSource: (current) => {
3896
+ prefsSource = current;
3897
+ },
3898
+ onChange: () => {}
3899
+ });
3900
+ });
3901
+ const session = new CodeBuddySession(ctx.logger, () => {
3902
+ ctx.emit("llm/adapters-updated");
3903
+ });
3904
+ const adapter = new CodeBuddyAdapter({
3905
+ session,
3906
+ options: () => resolved,
3907
+ resolveAttachments: () => ctx.get("attachments"),
3908
+ resolveOverrides: async (modelId) => {
3909
+ return (await loadOverrides())[modelId];
3910
+ }
3911
+ });
3912
+ ctx.llm.registerAdapter([CODEBUDDY_PROVIDER], adapter);
3913
+ new CodeBuddyAuthService(ctx, session);
3914
+ ctx.inject(["attachments"], (imageCtx) => {
3915
+ const attachments = imageCtx.get("attachments");
3916
+ if (attachments === void 0) return;
3917
+ applyImageTool(imageCtx, session, attachments);
3918
+ });
3919
+ const restrictedAgents = /* @__PURE__ */ new Set();
3920
+ const restrictMinimal = (agent) => {
3921
+ if (prefsSource().drawToolInMinimal || restrictedAgents.has(agent.id)) return;
3922
+ if (ctx.get("agentPresets")?.composedPreset(agent.ctx) !== "minimal") return;
3923
+ try {
3924
+ agent.ctx.tools.restrict({ deny: [CODEBUDDY_DRAW_TOOL] });
3925
+ restrictedAgents.add(agent.id);
3926
+ } catch (error) {
3927
+ ctx.logger.warn(`dsh-codebuddy: could not restrict draw tool for minimal agent: ${String(error)}`);
3928
+ }
3929
+ };
3930
+ ctx.on("agent/created", ({ agent }) => {
3931
+ restrictMinimal(agent);
3932
+ }, { global: true });
3933
+ ctx.on("session/event", (observed, event) => {
3934
+ if (event.type !== "agent-preset/selected") return;
3935
+ const data = event.data;
3936
+ if ((typeof data?.agentPreset === "string" ? data.agentPreset : "") !== "minimal") return;
3937
+ const agent = ctx.agents.get(observed.id);
3938
+ if (agent === void 0) return;
3939
+ restrictMinimal(agent);
3940
+ }, { global: true });
3941
+ const onSessionEvent = (observed, event) => {
3942
+ if (event.type !== "turn/start") return;
3943
+ const parent = observed.header.parentSession;
3944
+ session.observeTurn(String(observed.id), event.data.turn, parent === void 0 ? void 0 : String(parent));
3945
+ };
3946
+ ctx.on("session/event", onSessionEvent, { global: true });
3947
+ const onSessionDisposed = (observed) => {
3948
+ session.forget(String(observed.id));
3949
+ };
3950
+ ctx.on("session/disposed", onSessionDisposed, { global: true });
3951
+ session.isLoggedIn().then((loggedIn) => {
3952
+ if (loggedIn) return;
3953
+ ctx.logger.info("llm-codebuddy-power: no CodeBuddy session stored; sign in through the Settings → CodeBuddy page, or run `dsh plugin --profile web codebuddy-power` (no API key needed).");
3954
+ }).catch(() => {});
3955
+ }
3956
+ //#endregion
3957
+ export { AUTH_PENDING_CODE, BLOCKED_MODEL_IDS, CODEBUDDY_AUTH_CHANNEL, CODEBUDDY_AXIOS_USER_AGENT, CODEBUDDY_CHAT_BASE, CODEBUDDY_DISPLAY_NAME, CODEBUDDY_ENDPOINT, CODEBUDDY_IDE_VERSION, CODEBUDDY_PRODUCT_NAME, CODEBUDDY_PROVIDER, CodeBuddyAdapter, CodeBuddyAuthService, CodeBuddySession, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, LOGIN_POLL_INTERVAL_MS, LOGIN_TIMEOUT_MS, NotLoggedInError, REQUEST_IMAGE_DIMENSION_LADDER, REQUEST_IMAGE_MAX_BYTES, apply, fetchEnterpriseUsage, fetchPersonalUsage, fetchUsage, getStoragePath, hasDisclosedCapacity, httpErrorCode, inject, isBlockedModelId, loadSessionInfo, loadStorage, login, name, parseUsage, removeAccounts, resolveConnectionOptions, resolvePrefsBase, setDefaultAccount, setLastAccount, updateSessionInfo, updateStorage, upsertAccount };