dsh-connect-agnes-token-plan 0.5.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.
@@ -0,0 +1,713 @@
1
+ import { c as num, p as str, u as obj } from "./host-config-BNGgm_j7.js";
2
+ import { createDecipheriv } from "node:crypto";
3
+
4
+ //#region src/host/agnescode.ts
5
+ /**
6
+ * The AgnesCode(爱思编程)protocol layer — the pure, peer-free API half of the
7
+ * third upstream provider (ROADMAP §6.3 "third upstream").
8
+ *
9
+ * This is the plugin's FIRST "local-login-state harvest" line (the workbuddy /
10
+ * qoder / trae precedent family, §5.3): the credential is NOT obtained by a
11
+ * login this plugin performs — the user signs into the AgnesCode desktop App
12
+ * (WeChat scan, `app_id: agnes`), the App encrypts its session file with
13
+ * Chromium os_crypt, and this module reads THAT file. Nothing here writes to
14
+ * the App's directory; the harvested JWT lands in the DSH credentials service
15
+ * (`agnescode-store.ts`), never in this plugin's directory, git, or logs
16
+ * (red line 1).
17
+ *
18
+ * Wire facts this module encodes (all probed live 2026-10-01, ROADMAP §6.3):
19
+ * - the session file is `%APPDATA%/AgnesCode/code-auth-session.cn.v1`,
20
+ * Chromium os_crypt: `v10` prefix + AES-256-GCM, key = DPAPI-unwrapped
21
+ * `os_crypt.encrypted_key` from the sibling `Local State` JSON;
22
+ * - the decrypted JSON carries `accessToken` (JWT, ~28-day `exp`), the user
23
+ * object, and `bffPublicBaseUrl` — the per-account API base, which is why
24
+ * NO base URL is hardcoded here (the reference reverse-proxy pins the
25
+ * INTERNATIONAL `.com` host as a constant — the same trap video.ts hit);
26
+ * - chat is plain OpenAI-compatible `{bff}/chat/completions`; models list at
27
+ * `{bff}/models` (needs `X-App-Id: 1` + `X-Platform: 1`); the credit pool
28
+ * lives OUTSIDE the `/v1` prefix at `{origin}/api/v2/subscription/
29
+ * credits-balance` (a subscription-pool envelope, NOT Token Plan semantics).
30
+ *
31
+ * Safety stance for third-party data: `bffPublicBaseUrl` is PINNED to the
32
+ * Agnes origin family (https + `*.agnes-ai.cn` / `*.agnes-ai.com`) before any
33
+ * Authorization header is aimed at it — a corrupted or hostile session file
34
+ * must not be able to redirect the stored token to a foreign host.
35
+ *
36
+ * Everything here takes injected io/fetchers and pure data, so the offline
37
+ * suites exercise it without a network or a real DPAPI call; no Host peer is
38
+ * imported.
39
+ *
40
+ * @module dsh-connect-agnes-token-plan/agnescode
41
+ */
42
+ /** The model-catalogue request headers the BFF gates on (probed: required). */
43
+ const AGNESCODE_CATALOG_HEADERS = Object.freeze({
44
+ "X-App-Id": "1",
45
+ "X-Platform": "1"
46
+ });
47
+ /** The request-language header the desktop client sends. */
48
+ const AGNESCODE_LANGUAGE_HEADER = "X-User-Language";
49
+ /**
50
+ * The session-file name pattern, matched case-insensitively against the App's
51
+ * roaming directory. The CN build ships `code-auth-session.cn.v1`; other
52
+ * region variants (`code-auth-session.v1`, a future `.com` tag) share the
53
+ * shape, so the harvest matches the FAMILY rather than one literal — but each
54
+ * match is diagnosed individually and the first usable one wins.
55
+ */
56
+ const AGNESCODE_SESSION_FILE_PATTERN = /^code-auth-session[^/\\]*\.v1$/i;
57
+ /** Where the desktop App keeps its Chromium profile, per platform. */
58
+ const AGNESCODE_APP_DATA_CANDIDATES = Object.freeze([
59
+ {
60
+ platform: "win32",
61
+ dirEnv: "APPDATA",
62
+ leaf: "AgnesCode"
63
+ },
64
+ {
65
+ platform: "darwin",
66
+ dirEnv: "HOME",
67
+ leaf: "Library/Application Support/AgnesCode"
68
+ },
69
+ {
70
+ platform: "linux",
71
+ dirEnv: "HOME",
72
+ leaf: ".config/AgnesCode"
73
+ }
74
+ ]);
75
+ /**
76
+ * The tiers of a failed harvest, one per row of the panel's diagnosis list
77
+ * (the workbuddy five-tier discipline, restated for this file family). The
78
+ * tiers deliberately split "the App is not here" from "the App is here but we
79
+ * cannot read its crypto" — they want different user advice.
80
+ */
81
+ const AGNESCODE_HARVEST_TIER = Object.freeze({
82
+ FILE_MISSING: "file_missing",
83
+ UNREADABLE: "unreadable",
84
+ MALFORMED: "malformed",
85
+ NO_KEY: "no_key",
86
+ DECRYPT_FAILED: "decrypt_failed",
87
+ NO_TOKEN: "no_token",
88
+ UNTRUSTED_BASE: "untrusted_base",
89
+ UNSUPPORTED_PLATFORM: "unsupported_platform",
90
+ OK: "ok"
91
+ });
92
+ /**
93
+ * Whether a BFF base may carry the account's Authorization header. Pinned to
94
+ * the Agnes origin family over https — see the module header. Anything else
95
+ * reads as `UNTRUSTED_BASE` and the harvest keeps walking.
96
+ * @param {unknown} value - the `bffPublicBaseUrl` from the session file.
97
+ * @returns {string|null} the normalized base (no trailing slash), or `null`.
98
+ */
99
+ function trustAgnescodeBffBase(value) {
100
+ if (typeof value !== "string" || value.trim() === "") return null;
101
+ let url;
102
+ try {
103
+ url = new URL(value.trim());
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (url.protocol !== "https:") return null;
108
+ if (url.port !== "") return null;
109
+ const host = url.hostname.toLowerCase();
110
+ if (!(host === "agnes-ai.cn" || host === "agnes-ai.com" || host.endsWith(".agnes-ai.cn") || host.endsWith(".agnes-ai.com"))) return null;
111
+ return url.origin + url.pathname.replace(/\/+$/, "");
112
+ }
113
+ /**
114
+ * Parse one DECRYPTED session document into the shape the store persists.
115
+ * Malformed input reads as `null`, never throws — one re-harvest, never a
116
+ * crash. Only secret-FREE facts plus the token itself survive; the raw user
117
+ * object (avatar URL, provider ids) is dropped, not stored.
118
+ * @param {unknown} raw - the decrypted JSON value.
119
+ * @returns {{accessToken: string, userId: string, nickname: string, bffBase: string}|null}
120
+ */
121
+ function parseAgnescodeSession(raw) {
122
+ const source = obj(raw);
123
+ const accessToken = str(source.accessToken, "");
124
+ if (accessToken === "") return null;
125
+ const bffBase = trustAgnescodeBffBase(source.bffPublicBaseUrl);
126
+ if (bffBase === null) return null;
127
+ const user = obj(source.userInfo);
128
+ return {
129
+ accessToken,
130
+ userId: str(user.id, ""),
131
+ nickname: str(user.username, ""),
132
+ bffBase
133
+ };
134
+ }
135
+ /**
136
+ * Derive the API root the account-scoped (`/api/...`) endpoints address —
137
+ * the BFF base WITHOUT its `/v1` suffix. The credits endpoint does NOT live
138
+ * under `/v1` (probed: `{origin}/api/v2/...`), so a caller that concatenated
139
+ * the full base would 404.
140
+ * @param {string} bffBase - a {@link trustAgnescodeBffBase}-normalized base.
141
+ * @returns {string} the origin (plus any non-`/v1` prefix, preserved).
142
+ */
143
+ function agnescodeApiRoot(bffBase) {
144
+ const base = str(bffBase, "").replace(/\/+$/, "");
145
+ return base.endsWith("/v1") ? base.slice(0, -3) : base;
146
+ }
147
+ /** Decode a JWT's `exp` claim to MILLISECONDS; `undefined` on any failure. */
148
+ function decodeAgnescodeJwtExpMs(token) {
149
+ if (typeof token !== "string" || token.length === 0) return void 0;
150
+ const parts = token.split(".");
151
+ if (parts.length < 2) return void 0;
152
+ try {
153
+ const payload = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8"));
154
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
155
+ const seconds = num(payload.exp);
156
+ return typeof seconds === "number" ? seconds * 1e3 : void 0;
157
+ } catch {
158
+ return;
159
+ }
160
+ }
161
+ /**
162
+ * The header set one AgnesCode BFF request carries. The catalogue headers are
163
+ * REQUIRED on `/v1/models` (probed) and harmless elsewhere; the language
164
+ * header mirrors the desktop client.
165
+ * @param {object} credential - `{ accessToken }`.
166
+ * @returns {object} the header map.
167
+ */
168
+ function agnescodeHeaders(credential) {
169
+ return {
170
+ Accept: "application/json",
171
+ "Content-Type": "application/json",
172
+ Authorization: `Bearer ${str(obj(credential).accessToken, "")}`,
173
+ ...AGNESCODE_CATALOG_HEADERS,
174
+ [AGNESCODE_LANGUAGE_HEADER]: "zh-Hans"
175
+ };
176
+ }
177
+ /**
178
+ * Chromium os_crypt blob → plaintext: strip the `v10` prefix, AES-256-GCM with
179
+ * a 12-byte nonce and a trailing 16-byte auth tag.
180
+ * @param {Buffer|Uint8Array} blob - the raw session-file bytes.
181
+ * @param {Buffer|Uint8Array} key - the 32-byte os_crypt key (already unwrapped).
182
+ * @returns {Buffer} the decrypted bytes.
183
+ * @throws when the prefix, the key size, or the auth tag does not match.
184
+ */
185
+ function decryptAgnescodeSessionBlob(blob, key) {
186
+ const bytes = Buffer.isBuffer(blob) ? blob : Buffer.from(blob ?? []);
187
+ const keyBytes = Buffer.isBuffer(key) ? key : Buffer.from(key ?? []);
188
+ if (keyBytes.length !== 32) throw new Error(`os_crypt key must be 32 bytes, got ${keyBytes.length}`);
189
+ const prefix = bytes.subarray(0, 3).toString("latin1");
190
+ if (prefix !== "v10") throw new Error(`unexpected session-blob prefix ${JSON.stringify(prefix)}`);
191
+ const body = bytes.subarray(3);
192
+ if (body.length < 28) throw new Error("session blob too short for nonce + tag");
193
+ const nonce = body.subarray(0, 12);
194
+ const tag = body.subarray(body.length - 16);
195
+ const ciphertext = body.subarray(12, body.length - 16);
196
+ const decipher = createDecipheriv("aes-256-gcm", keyBytes, nonce);
197
+ decipher.setAuthTag(tag);
198
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
199
+ }
200
+ /**
201
+ * Unwrap the os_crypt key from a `Local State` document. The stored value is
202
+ * base64 of `DPAPI` + the DPAPI-protected key bytes; the DPAPI call itself is
203
+ * injected (the real one is a PowerShell child process — no native deps).
204
+ * @param {unknown} localStateRaw - the parsed (or raw) `Local State` JSON.
205
+ * @param {(wrapped: Buffer) => Promise<Buffer>} dpapiUnprotect - injected DPAPI.
206
+ * @returns {Promise<Buffer>} the 32-byte os_crypt key.
207
+ * @throws on any shape mismatch — the caller maps it to `NO_KEY`/`DECRYPT_FAILED`.
208
+ */
209
+ async function unwrapAgnescodeLocalStateKey(localStateRaw, dpapiUnprotect) {
210
+ let source;
211
+ if (typeof localStateRaw === "string" || Buffer.isBuffer(localStateRaw) || localStateRaw instanceof Uint8Array) {
212
+ const text = typeof localStateRaw === "string" ? localStateRaw : Buffer.from(localStateRaw).toString("utf8");
213
+ try {
214
+ source = JSON.parse(text);
215
+ } catch {
216
+ throw new Error("Local State is not valid JSON");
217
+ }
218
+ } else source = obj(localStateRaw);
219
+ const encryptedKeyB64 = str(obj(obj(source).os_crypt).encrypted_key, "");
220
+ if (encryptedKeyB64 === "") throw new Error("Local State carries no os_crypt.encrypted_key");
221
+ const wrapped = Buffer.from(encryptedKeyB64, "base64");
222
+ if (wrapped.length < 6 || wrapped.subarray(0, 5).toString("latin1") !== "DPAPI") throw new Error("os_crypt.encrypted_key lacks the DPAPI prefix");
223
+ return dpapiUnprotect(wrapped.subarray(5));
224
+ }
225
+ /**
226
+ * The production DPAPI unwrap: a PowerShell child process. No native addon and
227
+ * no build step (the Host half is build-free, ROADMAP §6.2), and the unwrapped
228
+ * key transits the stdout pipe IN MEMORY — it is never printed, logged, or
229
+ * written anywhere. Runs only on win32; every other platform maps to
230
+ * `UNSUPPORTED_PLATFORM` upstream of this call.
231
+ * @param {Buffer} wrapped - the DPAPI-protected key bytes.
232
+ * @returns {Promise<Buffer>} the unwrapped key bytes.
233
+ */
234
+ async function defaultDpapiUnprotect(wrapped) {
235
+ const { spawn } = await import("node:child_process");
236
+ const script = [
237
+ "Add-Type -AssemblyName System.Security",
238
+ "$in = [Console]::OpenStandardInput()",
239
+ "$ms = New-Object IO.MemoryStream",
240
+ "$in.CopyTo($ms)",
241
+ "$pt = [Security.Cryptography.ProtectedData]::Unprotect($ms.ToArray(), $null, 'CurrentUser')",
242
+ "[Console]::OpenStandardOutput().Write($pt, 0, $pt.Length)"
243
+ ].join("; ");
244
+ return new Promise((resolve, reject) => {
245
+ const child = spawn("powershell.exe", [
246
+ "-NoProfile",
247
+ "-NonInteractive",
248
+ "-Command",
249
+ script
250
+ ], {
251
+ stdio: [
252
+ "pipe",
253
+ "pipe",
254
+ "pipe"
255
+ ],
256
+ windowsHide: true
257
+ });
258
+ const chunks = [];
259
+ let failure = "";
260
+ const timer = setTimeout(() => {
261
+ child.kill();
262
+ reject(/* @__PURE__ */ new Error("DPAPI unprotect timed out (10s)"));
263
+ }, 1e4);
264
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
265
+ child.stderr.on("data", (chunk) => {
266
+ failure += chunk.toString();
267
+ });
268
+ child.stdin.on("error", () => {});
269
+ child.on("error", (why) => {
270
+ clearTimeout(timer);
271
+ reject(why);
272
+ });
273
+ child.on("close", (code) => {
274
+ clearTimeout(timer);
275
+ if (code !== 0) {
276
+ reject(/* @__PURE__ */ new Error(`DPAPI unprotect failed (exit ${code})${failure ? `: ${failure.slice(0, 200)}` : ""}`));
277
+ return;
278
+ }
279
+ resolve(Buffer.concat(chunks));
280
+ });
281
+ child.stdin.write(wrapped);
282
+ child.stdin.end();
283
+ });
284
+ }
285
+ /**
286
+ * Harvest the AgnesCode session from the local desktop App — the walk behind
287
+ * the panel's「重新检测」. One diagnostic row per candidate file, in the order
288
+ * the candidates were tried; the FIRST usable session wins and the walk stops.
289
+ * A walk that finds nothing usable returns `ok: false` WITH the rows, so the
290
+ * panel can show exactly what was probed and why it failed (the workbuddy
291
+ * discipline: never a bare "not logged in" when the App state is the problem).
292
+ *
293
+ * The token appears ONLY in the return value's `session` — the `attempts` rows
294
+ * carry tier codes and shape facts (lengths, file sizes), never values.
295
+ * @param {object} [options]
296
+ * @param {object} [options.env] - the process env (defaults to `process.env`).
297
+ * @param {object} [options.platform] - `process.platform` (defaults to real).
298
+ * @param {(path: string) => Promise<Buffer>} [options.readFile] - injected reader.
299
+ * @param {(path: string) => Promise<string[]>} [options.readDir] - injected lister.
300
+ * @param {(wrapped: Buffer) => Promise<Buffer>} [options.dpapiUnprotect] - injected DPAPI.
301
+ * @returns {Promise<{ok: boolean, session?: object, attempts: object[]}>}
302
+ */
303
+ async function harvestAgnescodeLocalSession(options = {}) {
304
+ const env = options.env ?? process.env;
305
+ const platform = options.platform ?? process.platform;
306
+ const readFile = options.readFile ?? (async (path) => (await import("node:fs/promises")).readFile(path));
307
+ const readDir = options.readDir ?? (async (path) => (await import("node:fs/promises")).readdir(path));
308
+ const dpapiUnprotect = options.dpapiUnprotect ?? defaultDpapiUnprotect;
309
+ /** One tier row: {file, tier, detail?} — detail carries shapes, never values. */
310
+ const attempts = [];
311
+ if (platform !== "win32") return {
312
+ ok: false,
313
+ attempts: [{
314
+ file: null,
315
+ tier: AGNESCODE_HARVEST_TIER.UNSUPPORTED_PLATFORM,
316
+ detail: `no verified harvest path for ${platform} yet (Windows only; macOS reads state.vscdb — unimplemented)`
317
+ }]
318
+ };
319
+ const appDirs = AGNESCODE_APP_DATA_CANDIDATES.filter((candidate) => candidate.platform === platform).map((candidate) => {
320
+ const base = str(env[candidate.dirEnv], "");
321
+ return base === "" ? null : `${base.replace(/[\\/]+$/, "")}/${candidate.leaf}`;
322
+ }).filter(Boolean);
323
+ if (appDirs.length === 0) return {
324
+ ok: false,
325
+ attempts: [{
326
+ file: null,
327
+ tier: AGNESCODE_HARVEST_TIER.UNSUPPORTED_PLATFORM,
328
+ detail: `no app-data candidate for platform ${platform}`
329
+ }]
330
+ };
331
+ for (const appDir of appDirs) {
332
+ let fileNames = [];
333
+ try {
334
+ fileNames = (await readDir(appDir)).filter((name) => AGNESCODE_SESSION_FILE_PATTERN.test(name));
335
+ } catch {
336
+ attempts.push({
337
+ file: appDir,
338
+ tier: AGNESCODE_HARVEST_TIER.FILE_MISSING,
339
+ detail: "the AgnesCode app directory is absent"
340
+ });
341
+ continue;
342
+ }
343
+ if (fileNames.length === 0) {
344
+ attempts.push({
345
+ file: appDir,
346
+ tier: AGNESCODE_HARVEST_TIER.FILE_MISSING,
347
+ detail: "no code-auth-session*.v1 in the app directory"
348
+ });
349
+ continue;
350
+ }
351
+ fileNames.sort((a, b) => a.endsWith(".cn.v1") === b.endsWith(".cn.v1") ? a.localeCompare(b) : a.endsWith(".cn.v1") ? -1 : 1);
352
+ for (const fileName of fileNames) {
353
+ const filePath = `${appDir}/${fileName}`;
354
+ let blob;
355
+ try {
356
+ blob = await readFile(filePath);
357
+ } catch (why) {
358
+ attempts.push({
359
+ file: filePath,
360
+ tier: AGNESCODE_HARVEST_TIER.UNREADABLE,
361
+ detail: why instanceof Error ? why.message : String(why)
362
+ });
363
+ continue;
364
+ }
365
+ let localStateRaw;
366
+ try {
367
+ localStateRaw = await readFile(`${appDir}/Local State`);
368
+ } catch {
369
+ attempts.push({
370
+ file: filePath,
371
+ tier: AGNESCODE_HARVEST_TIER.NO_KEY,
372
+ detail: "the sibling Local State is absent"
373
+ });
374
+ continue;
375
+ }
376
+ let key;
377
+ try {
378
+ key = await unwrapAgnescodeLocalStateKey(localStateRaw, dpapiUnprotect);
379
+ } catch (why) {
380
+ attempts.push({
381
+ file: filePath,
382
+ tier: AGNESCODE_HARVEST_TIER.NO_KEY,
383
+ detail: why instanceof Error ? why.message : String(why)
384
+ });
385
+ continue;
386
+ }
387
+ let plain;
388
+ try {
389
+ plain = decryptAgnescodeSessionBlob(blob, key);
390
+ } catch (why) {
391
+ attempts.push({
392
+ file: filePath,
393
+ tier: AGNESCODE_HARVEST_TIER.DECRYPT_FAILED,
394
+ detail: `${why instanceof Error ? why.message : String(why)} (blob ${blob.length} B)`
395
+ });
396
+ continue;
397
+ }
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(plain.toString("utf8"));
401
+ } catch {
402
+ attempts.push({
403
+ file: filePath,
404
+ tier: AGNESCODE_HARVEST_TIER.MALFORMED,
405
+ detail: "decrypted bytes are not JSON"
406
+ });
407
+ continue;
408
+ }
409
+ const session = parseAgnescodeSession(parsed);
410
+ if (session === null) {
411
+ attempts.push({
412
+ file: filePath,
413
+ tier: str(obj(parsed).accessToken, "") === "" ? AGNESCODE_HARVEST_TIER.NO_TOKEN : AGNESCODE_HARVEST_TIER.UNTRUSTED_BASE,
414
+ detail: "the decrypted session lacks a usable token or a pinned Agnes base"
415
+ });
416
+ continue;
417
+ }
418
+ attempts.push({
419
+ file: filePath,
420
+ tier: AGNESCODE_HARVEST_TIER.OK,
421
+ detail: `JWT ${session.accessToken.length} chars`
422
+ });
423
+ return {
424
+ ok: true,
425
+ session,
426
+ attempts
427
+ };
428
+ }
429
+ }
430
+ return {
431
+ ok: false,
432
+ attempts
433
+ };
434
+ }
435
+ /**
436
+ * Fetch the live model catalogue, or `null` when it cannot be read. The
437
+ * adapter falls back to the static roster on `null` (the `console-client`
438
+ * silent-fallback discipline).
439
+ *
440
+ * The BFF rows carry `is_member_only` — a fact about gating, not visibility,
441
+ * so it is KEPT (the panel badges it; offering a member-gated model is
442
+ * preferable to silently hiding one, mirroring red line 7's spirit of stating
443
+ * account-level limits instead of dropping models).
444
+ * @param {object} credential - `{ accessToken, bffBase }`.
445
+ * @param {typeof fetch} [fetcher] - injected fetch.
446
+ * @returns {Promise<object[]|null>} `[{id, name, vision, memberOnly, contextWindow, maxOutputLength}]`, or `null`.
447
+ */
448
+ async function fetchAgnescodeCatalog(credential, fetcher) {
449
+ const effective = fetcher ?? globalThis.fetch;
450
+ const source = obj(credential);
451
+ const bffBase = trustAgnescodeBffBase(source.bffBase);
452
+ if (bffBase === null) return null;
453
+ try {
454
+ const response = await effective(`${bffBase}/models`, {
455
+ headers: agnescodeHeaders(source),
456
+ signal: AbortSignal.timeout(3e4)
457
+ });
458
+ if (!response.ok) return null;
459
+ const body = obj(await response.json().catch(() => ({})));
460
+ const models = Array.isArray(body.data) ? body.data : [];
461
+ const seen = /* @__PURE__ */ new Set();
462
+ const out = [];
463
+ for (const raw of models) {
464
+ const model = obj(raw);
465
+ const id = str(model.id, "");
466
+ if (id === "" || seen.has(id)) continue;
467
+ if (str(model.model_type, "text") !== "text") continue;
468
+ seen.add(id);
469
+ out.push({
470
+ id,
471
+ name: str(model.name, id),
472
+ vision: false,
473
+ memberOnly: model.is_member_only === true,
474
+ contextWindow: num(model.max_input_tokens),
475
+ maxOutputLength: num(model.max_output_tokens)
476
+ });
477
+ }
478
+ return out.length > 0 ? out : null;
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+ /**
484
+ * Read the account's credit pool. Read-only; the envelope is a SUBSCRIPTION
485
+ * pool (level / time-sensitive vs permanent credits), NOT a Token Plan
486
+ * usage-overview — the panel must not render it with quota semantics.
487
+ * @param {object} credential - `{ accessToken, bffBase }`.
488
+ * @param {typeof fetch} [fetcher] - injected fetch.
489
+ * @returns {Promise<object|null>} `{totalBalance, timeSensitiveBalance, permanentBalance, subscriptionCredits, dailyFreeCredits, level}` or `null`.
490
+ */
491
+ async function fetchAgnescodeBalance(credential, fetcher) {
492
+ const effective = fetcher ?? globalThis.fetch;
493
+ const source = obj(credential);
494
+ const bffBase = trustAgnescodeBffBase(source.bffBase);
495
+ if (bffBase === null) return null;
496
+ try {
497
+ const response = await effective(`${agnescodeApiRoot(bffBase)}/api/v2/subscription/credits-balance`, {
498
+ headers: agnescodeHeaders(source),
499
+ signal: AbortSignal.timeout(3e4)
500
+ });
501
+ if (!response.ok) return null;
502
+ const envelope = obj(await response.json().catch(() => ({})));
503
+ if (str(envelope.code, "") !== "000000" || envelope.data === null || typeof envelope.data !== "object") return null;
504
+ const data = obj(envelope.data);
505
+ const total = numOrNullSafe(data.total_balance);
506
+ if (total === null) return null;
507
+ return {
508
+ totalBalance: total,
509
+ timeSensitiveBalance: numOrNullSafe(data.time_sensitive_balance) ?? 0,
510
+ permanentBalance: numOrNullSafe(data.permanent_balance) ?? 0,
511
+ subscriptionCredits: numOrNullSafe(data.subscription_credits) ?? 0,
512
+ dailyFreeCredits: numOrNullSafe(data.daily_free_credits) ?? 0,
513
+ level: num(data.level)
514
+ };
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+ /** Read a finite non-negative number, else `null`. */
520
+ function numOrNullSafe(value) {
521
+ const parsed = Number(value);
522
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
523
+ }
524
+ /**
525
+ * The static fallback roster: the seven models the CN BFF listed at probe time
526
+ * (2026-10-01, ROADMAP §6.3). All `model_type: text`, no multiplier concept
527
+ * (billing is the credit pool, not per-model rates). Used ONLY when
528
+ * `fetchAgnescodeCatalog` comes back empty; a fresh catalogue always wins.
529
+ */
530
+ const AGNESCODE_FALLBACK_MODELS = Object.freeze([
531
+ {
532
+ id: "agnes-3.0-flash",
533
+ name: "Agnes 3.0 Flash",
534
+ memberOnly: false,
535
+ vision: false,
536
+ contextWindow: 512e3,
537
+ maxOutputLength: 65536
538
+ },
539
+ {
540
+ id: "agnes-2.5-flash",
541
+ name: "Agnes 2.5 Flash",
542
+ memberOnly: false,
543
+ vision: false,
544
+ contextWindow: 512e3,
545
+ maxOutputLength: 65536
546
+ },
547
+ {
548
+ id: "agnes-2.5-pro",
549
+ name: "Agnes 2.5 Pro",
550
+ memberOnly: false,
551
+ vision: false,
552
+ contextWindow: 512e3,
553
+ maxOutputLength: 65536
554
+ },
555
+ {
556
+ id: "deepseek-v4-flash",
557
+ name: "DeepSeek V4 Flash",
558
+ memberOnly: true,
559
+ vision: false,
560
+ contextWindow: 1e6,
561
+ maxOutputLength: 393216
562
+ },
563
+ {
564
+ id: "glm-5.2",
565
+ name: "GLM-5.2",
566
+ memberOnly: true,
567
+ vision: false,
568
+ contextWindow: 1e6,
569
+ maxOutputLength: 131072
570
+ },
571
+ {
572
+ id: "kimi-k3",
573
+ name: "Kimi K3",
574
+ memberOnly: true,
575
+ vision: false,
576
+ contextWindow: 1048576,
577
+ maxOutputLength: 131072
578
+ },
579
+ {
580
+ id: "deepseek-v4-pro",
581
+ name: "DeepSeek V4 Pro",
582
+ memberOnly: true,
583
+ vision: false,
584
+ contextWindow: 1e6,
585
+ maxOutputLength: 393216
586
+ }
587
+ ]);
588
+
589
+ //#endregion
590
+ //#region src/host/agnescode-models.ts
591
+ /**
592
+ * AgnesCode catalogue entry → pi-ai model descriptor mapping — the peer-free
593
+ * half of the third upstream provider. Mirrors `raccoon-models.ts`'s
594
+ * discipline: plain objects only (no Host peer import); `agnescode-llm-adapter.ts`
595
+ * is the peer-dependent half.
596
+ *
597
+ * Three decisions carried here:
598
+ * 1. `reasoning: false` — the catalogue rows declare a `thinking_toggle`
599
+ * (default ON), but the WIRE field that would control it from OpenAI
600
+ * chat is UNVERIFIED (ROADMAP §6.3). Offering a thinking selector would
601
+ * promise something the descriptor cannot emit; the honest v1 shape is
602
+ * "no toggle; the BFF defaults to thinking ON". A stated limitation, not
603
+ * an oversight.
604
+ * 2. The base URL is PER-ACCOUNT (`bffPublicBaseUrl` from the session
605
+ * file), so it rides through the builder's options instead of a module
606
+ * constant — the reference reverse-proxy's hardcoded `.com` constant is
607
+ * the trap this avoids.
608
+ * 3. `memberOnly` models are OFFERED, not dropped: gating is account state,
609
+ * not model truth (red line 7's spirit — state limits, never silently
610
+ * retract models). The panel badges them; a request that hits the gate
611
+ * fails visibly at the upstream.
612
+ *
613
+ * @module dsh-connect-agnes-token-plan/agnescode-models
614
+ */
615
+ /** The provider id this plugin registers under for AgnesCode. */
616
+ const AGNESCODE_PROVIDER_ID = "sensenova-agnescode";
617
+ /** What the DSH model picker shows as the AgnesCode provider's name. */
618
+ const AGNESCODE_DISPLAY_NAME = "SenseNova AgnesCode";
619
+ /** The zero-cost sentinel: per-token prices are unknowable (credit-gated). */
620
+ const NO_COST = Object.freeze({
621
+ input: 0,
622
+ output: 0,
623
+ cacheRead: 0,
624
+ cacheWrite: 0
625
+ });
626
+ /** The catalogue/identity headers every request carries (probed: required). */
627
+ function agnescodeRequestHeaders() {
628
+ return {
629
+ Accept: "application/json",
630
+ "Content-Type": "application/json",
631
+ "X-App-Id": "1",
632
+ "X-Platform": "1",
633
+ "X-User-Language": "zh-Hans"
634
+ };
635
+ }
636
+ /**
637
+ * The AgnesCode model rows the panel shows and the adapter offers.
638
+ *
639
+ * A live catalogue row always beats the static fallback roster. The row keeps
640
+ * what the picker and panel need — plus `memberOnly`, which this provider HAS
641
+ * a platform-declared fact for and the panel badges.
642
+ * @param {object[]|null} [catalog] - the `fetchAgnescodeCatalog` result.
643
+ * @returns {object[]} the AgnesCode model rows.
644
+ */
645
+ function agnescodeRoster(catalog) {
646
+ const rows = Array.isArray(catalog) && catalog.length > 0 ? catalog : AGNESCODE_FALLBACK_MODELS;
647
+ const out = [];
648
+ for (const row of rows) {
649
+ const id = str(row?.id, "");
650
+ if (id === "") continue;
651
+ out.push({
652
+ id,
653
+ name: str(row?.name, id),
654
+ vision: row?.vision === true,
655
+ memberOnly: row?.memberOnly === true,
656
+ multiplier: typeof row?.multiplier === "number" ? row.multiplier : void 0,
657
+ contextWindow: num(row?.contextWindow),
658
+ maxOutputLength: num(row?.maxOutputLength)
659
+ });
660
+ }
661
+ return out;
662
+ }
663
+ /**
664
+ * Map one AgnesCode row onto the pi-ai model descriptor the adapter offers.
665
+ * @param {object} row - an {@link agnescodeRoster} row (must carry `id`).
666
+ * @param {object} [options] - `{ bffBase }` — the pinned per-account base.
667
+ * @returns {object} the pi-ai descriptor.
668
+ */
669
+ function agnescodeToDescriptor(row, options = {}) {
670
+ const id = str(row?.id, "");
671
+ if (id === "") throw new Error("agnescodeToDescriptor: row has no id");
672
+ const bffBase = str(options.bffBase, "");
673
+ if (bffBase === "") throw new Error("agnescodeToDescriptor: a pinned bffBase is required");
674
+ const vision = row?.vision === true;
675
+ return {
676
+ id,
677
+ name: str(row?.name, id),
678
+ api: "openai-completions",
679
+ provider: AGNESCODE_PROVIDER_ID,
680
+ baseUrl: bffBase,
681
+ input: vision ? ["text", "image"] : ["text"],
682
+ reasoning: false,
683
+ cost: { ...NO_COST },
684
+ contextWindow: num(row?.contextWindow) ?? 512e3,
685
+ maxTokens: num(row?.maxOutputLength) ?? 32e3,
686
+ headers: agnescodeRequestHeaders(),
687
+ compat: {
688
+ maxTokensField: "max_tokens",
689
+ supportsDeveloperRole: false
690
+ }
691
+ };
692
+ }
693
+ /**
694
+ * Build the whole descriptor list for one AgnesCode roster.
695
+ * @param {object[]} [roster] - the {@link agnescodeRoster} result.
696
+ * @param {object} [options] - `{ bffBase }`.
697
+ * @returns {object[]} the pi-ai descriptors.
698
+ */
699
+ function buildAgnescodeDescriptors(roster, options = {}) {
700
+ const list = Array.isArray(roster) ? roster : agnescodeRoster(null);
701
+ const out = [];
702
+ const seen = /* @__PURE__ */ new Set();
703
+ for (const row of list) {
704
+ const id = str(row?.id, "");
705
+ if (id === "" || seen.has(id)) continue;
706
+ seen.add(id);
707
+ out.push(agnescodeToDescriptor(row, options));
708
+ }
709
+ return out;
710
+ }
711
+
712
+ //#endregion
713
+ export { AGNESCODE_FALLBACK_MODELS as a, fetchAgnescodeCatalog as c, buildAgnescodeDescriptors as i, harvestAgnescodeLocalSession as l, AGNESCODE_PROVIDER_ID as n, decodeAgnescodeJwtExpMs as o, agnescodeRoster as r, fetchAgnescodeBalance as s, AGNESCODE_DISPLAY_NAME as t, trustAgnescodeBffBase as u };