dsh-connect-agnes-token-plan 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  本文件只记**公开行为变化**(新增能力、破坏性改动、重要修复)。实现细节、重构与测试加固请直接看 `git log`。
4
4
 
5
+ ## [0.10.2] — 2026-10-10
6
+
7
+ ### 修复:视频状态轮询断点续查、额度渲染与模型花名册的几处真实缺陷 + 凭据隔离加固
8
+
9
+ - **修(视频工具):异步视频轮询被拒时原本不带 `video_id` 且只走「超时 / 超轮次」两路径**,agent 首次查询拿不到 task id 就无法续查,任务卡在「查不到」。`video-client` 现改为携带 `video_id` 并有限重试(`queryRetries=2` → 初查 + 2 次重试 = 3 次调用),与超时 / 超轮次两条路径对齐。新增 `video.test.mjs` 覆盖耗尽路径与 `queryRetries=0` 边界。
10
+ - **修(额度渲染):`usagePct` 有值而 `used` 缺失时,`count(null)` 被渲染成 `0`,面板会打印「已用 0 / N」**(F1)。`UsageTotals` 四个主计数另加 `typeof` 门:缺席字段不再画成「0」——真实数据流下此门不触发(`countOf` 已把缺席平台字段压成数字 0、`overview` 失败时 `quota.totals:null` 见红线⑥ / ADR-007),属渲染层自防御,防未来 partial 对象被构造出来。
11
+ - **修(AgnesCode 花名册):两处目录正确性缺陷**。(F5)排除别名 `id === EXCLUDED_IDS[0]` 改为 `.includes(id)`,第二个别名不再静默失效、继续混进花名册;(F10)`provider-controls` 的 `idList` 用 `String(id)` 强转,`null` 会变成字符串 `"null"` 这个假模型 id 出现在候选列表里,改用 `models.ts` 的 `rosterIds`(只收非空字符串)。
12
+ - **修(凭据隔离):AgnesCode 的 DPAPI PowerShell 子进程不再继承含凭据的父进程 env**,新增 `dpapiChildEnv` 剥除四枚凭据名(`AGNES_PASSWORD` / `AGNES_USERNAME` / `AGNES_TOKEN_PLAN_API_KEY` / `AGNESCODE_CREDENTIAL`),与全仓凭据 REF 集合逐字一致——降低解密第三方桌面端会话文件那一步的凭据泄漏半径(红线①)。`harvest attempts[].detail` 与 `state().error` 出口统一过 `redactSecrets`。
13
+ - **内部加固 / 清理(不属公开行为变化,详见 `git log`)**:收敛三轮四域代码审查的约 22 项缺陷与计数器 / 方法归一化 / 单飞泄漏 / JSDoc 归位等低危项;删除 25 个零读者 style token(`details*` / `grant*` / `models*` / `trend*` / `poolsGrid` / `primaryHover`)与全仓零引用的孤儿 `jsqr` devDependency;统一 `countOf` 与 `Number(x)||0` 两处数值惯用法、`requestMethod` 六路由共用、BFF 门控头收敛为单一 `AGNESCODE_BASE_HEADERS`;CI `build-gate` 确保暂存目录存在再 `mkdtemp`。上述均以新增断言钉住,无破坏性改动。
14
+ - 影响面:`src/{host,client}/`(多文件)、`lib/`、`client.js`(随 src 同 commit 重建,构建零漂移)、`test/`(多套件 + 断言)、`package.json`(版本 + 去孤儿依赖)。`npm test` 全绿:28 套件 + tsc / build / dup / e2e 四门禁通过,e2e 66/66 PASSED。
15
+
5
16
  ## [0.10.1] — 2026-10-06
6
17
 
7
18
  ### 修复:AgnesCode 截图里的真实账号昵称已脱敏(该图曾随 0.10.0 发布)
package/client.js CHANGED
@@ -550,11 +550,16 @@ var dsh_connect_agnes_token_plan_client = (function() {
550
550
  * Why two bases: the catalogue mixes them — windows and ceilings arrive as
551
551
  * powers of two (1048576), while the plugin's own 128k fallback is the round
552
552
  * decimal 128 000. A flat /1000 rounding once printed "1049k" for the 1M
553
- * window and it read like a placeholder bug; so figures divisible by 1000 keep
554
- * the decimal reading they were written with, binary-only figures (262144 →
555
- * 256K, 65536 → 64K) get the binary one, anything ≥ 1M goes to M and anything
556
- * ≥ 1G goes to G — the account-lifetime token total (billions) must not print
557
- * as a four-digit "5214M".
553
+ * window and it read like a placeholder bug. The guards below therefore run
554
+ * in the order the code checks them, largest first:
555
+ *
556
+ * - anything ≥ 1G goes to G — the account-lifetime token total (billions)
557
+ * must not print as a four-digit "5214M";
558
+ * - anything ≥ 1M goes to M — a 1M window (1048576) reads "1M", never
559
+ * "1049k" or "1024k";
560
+ * - below 1M, figures divisible by 1000 keep the decimal reading they were
561
+ * written with (128000 → "128K");
562
+ * - binary-only figures get the binary one (262144 → "256K", 65536 → "64K").
558
563
  */
559
564
  function tokenSize(value) {
560
565
  const number = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : 0;
@@ -977,12 +982,6 @@ var dsh_connect_agnes_token_plan_client = (function() {
977
982
  borderRadius: 12,
978
983
  padding: 16
979
984
  },
980
- poolsGrid: {
981
- display: "grid",
982
- gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 320px), 1fr))",
983
- gap: 12,
984
- alignItems: "start"
985
- },
986
985
  cardHead: {
987
986
  display: "flex",
988
987
  alignItems: "center",
@@ -1098,40 +1097,6 @@ var dsh_connect_agnes_token_plan_client = (function() {
1098
1097
  },
1099
1098
  barFillWarn: { background: "var(--dsw-alias-state-warn-primary)" },
1100
1099
  barFillError: { background: "var(--dsw-alias-state-error-primary)" },
1101
- details: {
1102
- marginTop: 12,
1103
- paddingTop: 10,
1104
- borderTop: "1px solid var(--dsw-alias-border-l1)"
1105
- },
1106
- detailsSummary: {
1107
- fontSize: 12,
1108
- color: "var(--dsw-alias-label-secondary)",
1109
- cursor: "pointer",
1110
- userSelect: "none"
1111
- },
1112
- detailsBody: {
1113
- display: "flex",
1114
- flexDirection: "column",
1115
- gap: 10,
1116
- marginTop: 10
1117
- },
1118
- grant: {
1119
- fontSize: 12,
1120
- color: "var(--dsw-alias-label-secondary)"
1121
- },
1122
- models: {
1123
- display: "flex",
1124
- flexWrap: "wrap",
1125
- gap: 6
1126
- },
1127
- modelTag: {
1128
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
1129
- fontSize: 11,
1130
- padding: "2px 6px",
1131
- borderRadius: 6,
1132
- background: "var(--dsw-alias-bg-layer-2)",
1133
- border: "1px solid var(--dsw-alias-border-l1)"
1134
- },
1135
1100
  trendHead: {
1136
1101
  display: "flex",
1137
1102
  alignItems: "baseline",
@@ -1145,39 +1110,6 @@ var dsh_connect_agnes_token_plan_client = (function() {
1145
1110
  color: "var(--dsw-alias-label-secondary)",
1146
1111
  fontWeight: 500
1147
1112
  },
1148
- trendRow: {
1149
- display: "flex",
1150
- flexDirection: "column",
1151
- gap: 8,
1152
- padding: "10px 0",
1153
- borderBottom: "1px solid var(--dsw-alias-border-l1)"
1154
- },
1155
- trendRowHead: {
1156
- display: "flex",
1157
- alignItems: "baseline",
1158
- justifyContent: "space-between",
1159
- gap: 12,
1160
- minWidth: 0
1161
- },
1162
- trendModel: {
1163
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
1164
- fontSize: 12,
1165
- minWidth: 0,
1166
- overflow: "hidden",
1167
- textOverflow: "ellipsis",
1168
- whiteSpace: "nowrap"
1169
- },
1170
- trendCredits: {
1171
- fontSize: 13,
1172
- fontWeight: 600,
1173
- fontVariantNumeric: "tabular-nums"
1174
- },
1175
- trendBar: {
1176
- height: 6,
1177
- borderRadius: 3,
1178
- background: "var(--dsw-alias-bg-layer-2)",
1179
- overflow: "hidden"
1180
- },
1181
1113
  trendLegend: {
1182
1114
  marginTop: 10,
1183
1115
  fontSize: 11,
@@ -1299,7 +1231,6 @@ var dsh_connect_agnes_token_plan_client = (function() {
1299
1231
  color: "var(--dsw-alias-label-primary-foreground)",
1300
1232
  cursor: "pointer"
1301
1233
  },
1302
- primaryHover: { background: "var(--dsw-alias-button-primary-hover)" },
1303
1234
  primaryBusy: {
1304
1235
  opacity: .6,
1305
1236
  cursor: "default"
@@ -1761,6 +1692,10 @@ var dsh_connect_agnes_token_plan_client = (function() {
1761
1692
  * @returns {unknown} the tools row element.
1762
1693
  */
1763
1694
  function RosterTools({ query, setQuery, name, busy, tickedCount, visible, bulk, tt }) {
1695
+ const countLabel = format(tt("llm.rosterCount"), {
1696
+ selected: tickedCount,
1697
+ total: visible.length
1698
+ });
1764
1699
  return h("div", { style: S.rosterTools }, h("input", {
1765
1700
  type: "search",
1766
1701
  style: {
@@ -1777,14 +1712,8 @@ var dsh_connect_agnes_token_plan_client = (function() {
1777
1712
  onChange: (event) => setQuery(event.target.value)
1778
1713
  }), h("span", {
1779
1714
  style: S.rosterCount,
1780
- title: format(tt("llm.rosterCount"), {
1781
- selected: tickedCount,
1782
- total: visible.length
1783
- })
1784
- }, format(tt("llm.rosterCount"), {
1785
- selected: tickedCount,
1786
- total: visible.length
1787
- })), h("button", {
1715
+ title: countLabel
1716
+ }, countLabel), h("button", {
1788
1717
  type: "button",
1789
1718
  style: S.rosterBulk,
1790
1719
  disabled: busy === true || visible.length === 0,
@@ -2445,10 +2374,6 @@ var dsh_connect_agnes_token_plan_client = (function() {
2445
2374
  role: "alert"
2446
2375
  }, switchError) : null);
2447
2376
  }
2448
- /** Ids off a snapshot array field, coerced to strings and guarded against `null`. */
2449
- function idList(value) {
2450
- return Array.isArray(value) ? value.map((id) => String(id)) : [];
2451
- }
2452
2377
  /**
2453
2378
  * The live draw-tool switch (docs/PROVIDER-HOT-RELOAD.md, same discipline
2454
2379
  * as `ProviderSwitch`). Posts `{ enabled }` / `{ modelId }` to the plugin's
@@ -2466,7 +2391,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2466
2391
  modelKey: "drawModelId",
2467
2392
  enabled: llm?.drawEnabled === true,
2468
2393
  hasKey: llm?.hasApiKey === true,
2469
- candidates: idList(llm?.drawCandidateIds),
2394
+ candidates: rosterIds(llm?.drawCandidateIds),
2470
2395
  preferred: llm?.drawPreferredModel != null ? String(llm.drawPreferredModel) : null,
2471
2396
  effective: String(llm?.drawModel ?? ""),
2472
2397
  onDone,
@@ -2487,7 +2412,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2487
2412
  * reader wondering why a seconds-based model sits next to a frame-based one.
2488
2413
  */
2489
2414
  function VideoSwitch({ llm, onDone, tt }) {
2490
- const excluded = idList(llm?.video25ModelIds);
2415
+ const excluded = rosterIds(llm?.video25ModelIds);
2491
2416
  const extraNote = excluded.length > 0 ? h("div", { style: {
2492
2417
  ...S.muted,
2493
2418
  fontSize: 12,
@@ -2502,7 +2427,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2502
2427
  modelKey: "videoModelId",
2503
2428
  enabled: llm?.videoEnabled === true,
2504
2429
  hasKey: llm?.hasApiKey === true,
2505
- candidates: idList(llm?.videoCandidateIds),
2430
+ candidates: rosterIds(llm?.videoCandidateIds),
2506
2431
  preferred: llm?.videoPreferredModel != null ? String(llm.videoPreferredModel) : null,
2507
2432
  effective: String(llm?.videoModel ?? ""),
2508
2433
  extraNote,
@@ -2537,6 +2462,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2537
2462
  init_format();
2538
2463
  init_http();
2539
2464
  init_model_picker();
2465
+ init_models();
2540
2466
  init_runtime();
2541
2467
  init_switch();
2542
2468
  init_styles();
@@ -2777,7 +2703,7 @@ var dsh_connect_agnes_token_plan_client = (function() {
2777
2703
  }, h("div", { style: {
2778
2704
  ...tone.fill,
2779
2705
  width: `${pct}%`
2780
- } })), pct === null && resetLine === null ? null : h("div", { style: S.quotaFoot }, pct === null ? null : h("span", { style: S.quotaUsed }, `${tt("quota.used")} ${count(used)} / ${count(limit)}`), resetLine === null ? null : h("span", { style: {
2706
+ } })), (pct === null || used === null) && resetLine === null ? null : h("div", { style: S.quotaFoot }, pct === null || used === null ? null : h("span", { style: S.quotaUsed }, `${tt("quota.used")} ${count(used)} / ${count(limit)}`), resetLine === null ? null : h("span", { style: {
2781
2707
  ...S.muted,
2782
2708
  fontSize: 11
2783
2709
  } }, resetLine)));
@@ -2881,29 +2807,34 @@ var dsh_connect_agnes_token_plan_client = (function() {
2881
2807
  {
2882
2808
  key: "requests",
2883
2809
  label: tt("quota.total.requests"),
2884
- value: count(source.totalRequests)
2810
+ value: count(source.totalRequests),
2811
+ on: typeof source.totalRequests === "number"
2885
2812
  },
2886
2813
  {
2887
2814
  key: "tokens",
2888
2815
  label: tt("quota.total.tokens"),
2889
- value: tokenSize(source.totalTokens) || count(source.totalTokens)
2816
+ value: tokenSize(source.totalTokens) || count(source.totalTokens),
2817
+ on: typeof source.totalTokens === "number"
2890
2818
  },
2891
2819
  {
2892
2820
  key: "images",
2893
2821
  label: tt("quota.total.images"),
2894
- value: count(source.totalImages)
2822
+ value: count(source.totalImages),
2823
+ on: typeof source.totalImages === "number"
2895
2824
  },
2896
2825
  {
2897
2826
  key: "video",
2898
2827
  label: tt("quota.total.video"),
2899
- value: `${count(source.totalVideoSeconds)} ${tt("quota.unit.seconds")}`
2828
+ value: `${count(source.totalVideoSeconds)} ${tt("quota.unit.seconds")}`,
2829
+ on: typeof source.totalVideoSeconds === "number"
2900
2830
  },
2901
- ...typeof source.activeDays === "number" ? [{
2831
+ {
2902
2832
  key: "days",
2903
2833
  label: tt("quota.total.activeDays"),
2904
- value: count(source.activeDays)
2905
- }] : []
2906
- ];
2834
+ value: count(source.activeDays),
2835
+ on: typeof source.activeDays === "number"
2836
+ }
2837
+ ].filter((cell) => cell.on);
2907
2838
  return h("div", null, h("div", { style: {
2908
2839
  ...S.muted,
2909
2840
  fontSize: 12,
@@ -1,4 +1,4 @@
1
- import { t as DEFAULT_REASONING_EFFORT, w as name } from "./llm-models.js";
1
+ import { T as name, t as DEFAULT_REASONING_EFFORT } from "./llm-models.js";
2
2
  import { i as buildAgnescodeDescriptors, n as AGNESCODE_PROVIDER_ID, r as agnescodeRoster, t as AGNESCODE_DISPLAY_NAME } from "./agnescode-models.js";
3
3
  import { i as buildRetryPolicyConfig, n as STREAM_IDLE_TIMEOUT_MS, r as createWrappedPiAiAdapter, t as REQUEST_IMAGE_BUDGETS } from "./pi-ai-adapter-core.js";
4
4
  import { createProvider } from "@earendil-works/pi-ai";
@@ -1,4 +1,4 @@
1
- import { A as numZeroOk, I as str, O as num, i as PROBED_VISION, j as obj, w as name } from "./llm-models.js";
1
+ import { L as str, M as obj, T as name, i as PROBED_VISION, j as numZeroOk, k as num } from "./llm-models.js";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
@@ -79,12 +79,12 @@ function isProfileSegment(value) {
79
79
  * **怎么读它**:`ctx.get(name)` —— Cordis 自己的 "read a service without the
80
80
  * inject requirement" 入口,未提供时安静返回 `undefined`。注意**别用属性访问**
81
81
  * 去探:`ctx.profileContext` 会在服务缺失时**抛错**(`cannot get property
82
- * "profileContext" without inject`,cordis `lib/index.js:676`)——这是本插件
82
+ * "profileContext" without inject`——cordis 的原文案)——这是本插件
83
83
  * 实测踩到的,不是推测。`readOptionalService` 把两个入口都包了,属性访问只作为
84
84
  * 测试桩的兜底留在最后。
85
85
  *
86
- * **为什么不用 `inject` 声明它**:`inject` 里的是**硬依赖**(`lib/index.js:688`
87
- * 的报错文案就叫 "cannot get required service"),缺了 Cordis 根本不加载本插件。
86
+ * **为什么不用 `inject` 声明它**:`inject` 里的是**硬依赖**(报错文案就叫
87
+ * "cannot get required service"),缺了 Cordis 根本不加载本插件。
88
88
  * 而 `profileContext` 在官方 runtime 里是**可选**的(`@linxin666/
89
89
  * dsh-client-ui-plugin-manager` 明确处理了"host 隐藏了它"的情形,
90
90
  * `dsh-better-sidebar` 同理)。把它变成硬依赖,会让那些主机上整个插件消失
@@ -607,13 +607,19 @@ function createFileCatalogStore(options = {}) {
607
607
  *
608
608
  * @module dsh-connect-agnes-token-plan/agnescode
609
609
  */
610
- /** The model-catalogue request headers the BFF gates on (probed: required). */
611
- const AGNESCODE_CATALOG_HEADERS = Object.freeze({
610
+ /** The request headers every BFF call carries, with or without a token. The
611
+ * `X-App-Id` / `X-Platform` pair is the catalogue gate (probed: required) and
612
+ * the language header mirrors the desktop client. Declared ONCE here and spread
613
+ * by both builders — `agnescodeHeaders` (authenticated) and
614
+ * `agnescodeRequestHeaders` in `agnescode-models.ts` (anonymous) — so the two
615
+ * cannot drift into disagreeing gate values. */
616
+ const AGNESCODE_BASE_HEADERS = Object.freeze({
617
+ Accept: "application/json",
618
+ "Content-Type": "application/json",
612
619
  "X-App-Id": "1",
613
- "X-Platform": "1"
620
+ "X-Platform": "1",
621
+ "X-User-Language": "zh-Hans"
614
622
  });
615
- /** The request-language header the desktop client sends. */
616
- const AGNESCODE_LANGUAGE_HEADER = "X-User-Language";
617
623
  /**
618
624
  * The session-file name pattern, matched case-insensitively against the App's
619
625
  * roaming directory. The CN build ships `code-auth-session.cn.v1`; other
@@ -712,6 +718,10 @@ const AGNESCODE_HARVEST_TIER = Object.freeze({
712
718
  * a non-model in the picker. Keyed by id on purpose: a rule tied to "has no
713
719
  * multiplier" would silently start leaking `auto` back in the day the platform
714
720
  * prices it.
721
+ *
722
+ * Matched as a SET (`includes`), never by index: comparing against `[0]` would
723
+ * make a second entry dead the moment it was added, and that failure would be
724
+ * silent.
715
725
  */
716
726
  const AGNESCODE_CATALOGUE_EXCLUDED_IDS = Object.freeze(["auto"]);
717
727
  /**
@@ -795,11 +805,8 @@ function decodeAgnescodeJwtExpMs(token) {
795
805
  */
796
806
  function agnescodeHeaders(credential) {
797
807
  return {
798
- Accept: "application/json",
799
- "Content-Type": "application/json",
800
- Authorization: `Bearer ${str(obj(credential).accessToken, "")}`,
801
- ...AGNESCODE_CATALOG_HEADERS,
802
- [AGNESCODE_LANGUAGE_HEADER]: "zh-Hans"
808
+ ...AGNESCODE_BASE_HEADERS,
809
+ Authorization: `Bearer ${str(obj(credential).accessToken, "")}`
803
810
  };
804
811
  }
805
812
  /**
@@ -851,6 +858,29 @@ async function unwrapAgnescodeLocalStateKey(localStateRaw, dpapiUnprotect) {
851
858
  return dpapiUnprotect(wrapped.subarray(5));
852
859
  }
853
860
  /**
861
+ * The env names that carry credentials. A spawned helper does not need them,
862
+ * and must not be able to read them off its own process environment.
863
+ */
864
+ const DPAPI_CHILD_SECRET_ENV = [
865
+ "AGNES_PASSWORD",
866
+ "AGNES_USERNAME",
867
+ "AGNES_TOKEN_PLAN_API_KEY",
868
+ "AGNESCODE_CREDENTIAL"
869
+ ];
870
+ /**
871
+ * The environment handed to the DPAPI PowerShell child: everything the shell
872
+ * actually needs (SystemRoot, PATH, TEMP, …) survives, only the credential
873
+ * names go. `spawn` inherits `process.env` by default; this is the explicit
874
+ * exception to that default.
875
+ * @param {object} [env] - the parent env (defaults to `process.env`).
876
+ * @returns {object} a copy with the credential names removed.
877
+ */
878
+ function dpapiChildEnv(env = process.env) {
879
+ const out = { ...env };
880
+ for (const name of DPAPI_CHILD_SECRET_ENV) delete out[name];
881
+ return out;
882
+ }
883
+ /**
854
884
  * The production DPAPI unwrap: a PowerShell child process. No native addon and
855
885
  * no build step (the Host half is build-free, ROADMAP §6.2), and the unwrapped
856
886
  * key transits the stdout pipe IN MEMORY — it is never printed, logged, or
@@ -881,7 +911,8 @@ async function defaultDpapiUnprotect(wrapped) {
881
911
  "pipe",
882
912
  "pipe"
883
913
  ],
884
- windowsHide: true
914
+ windowsHide: true,
915
+ env: dpapiChildEnv()
885
916
  });
886
917
  const chunks = [];
887
918
  let failure = "";
@@ -1113,7 +1144,7 @@ async function fetchAgnescodeCatalog(credential, fetcher) {
1113
1144
  const model = obj(raw);
1114
1145
  const id = str(model.id, "");
1115
1146
  if (id === "" || seen.has(id)) continue;
1116
- if (id === AGNESCODE_CATALOGUE_EXCLUDED_IDS[0]) continue;
1147
+ if (AGNESCODE_CATALOGUE_EXCLUDED_IDS.includes(id)) continue;
1117
1148
  if (str(model.model_type, "text") !== "text") continue;
1118
1149
  seen.add(id);
1119
1150
  const allowed = Array.isArray(model.allowed_subscription) ? model.allowed_subscription : [];
@@ -1172,12 +1203,26 @@ async function fetchAgnescodeBalance(credential, fetcher) {
1172
1203
  }
1173
1204
  }
1174
1205
  /**
1175
- * The static fallback roster: the eight models the CN BFF listed at probe time
1176
- * (2026-10-01 night re-probe, ROADMAP §6.3.1). All `model_type: text`, no
1177
- * multiplier concept — confirmed by that re-probe: no model row carries a
1178
- * credit/rate/price/cost key and all five rate-candidate endpoints 404
1179
- * (billing is the account-level credit pool, not per-model rates). Used ONLY
1180
- * when `fetchAgnescodeCatalog` comes back empty; a fresh catalogue always wins.
1206
+ * The static fallback roster: the eight models the CN BFF's `/v2/models`
1207
+ * listed at probe time (2026-10-01 night re-probe; rows and rates re-probed
1208
+ * 2026-10-04 against v2, ROADMAP §6.3.1). Used ONLY when
1209
+ * `fetchAgnescodeCatalog` comes back empty; a fresh catalogue always wins.
1210
+ *
1211
+ * `multiplier` is the BFF's `points_cost_multiplier` — a rate the PLATFORM
1212
+ * publishes per model, read once through `numZeroOk()`. A `0` is a published
1213
+ * price ("free"), NOT an absence: `num()` would turn it into "unpriced" and
1214
+ * the panel would lose the chip. The one v2 row with no rate at all is the
1215
+ * `auto` routing alias, which this list does not contain.
1216
+ *
1217
+ * `displayLabel` is the BFF's `display_label` promotional tag, present on one
1218
+ * row only, and passed through verbatim.
1219
+ *
1220
+ * SUPERSEDED READING, kept so the reversal is auditable: the 2026-10-01 v1
1221
+ * snapshot genuinely had "no multiplier concept — no row carries a rate key,
1222
+ * and all five rate-candidate endpoints 404". That was `/v1/models`, which
1223
+ * omits the field; the plugin reads v2 since 2026-10-04, where 8 of 9 rows
1224
+ * carry it. The lesson is the one in ROADMAP §6.3.1: deciding a field is
1225
+ * absent from a whole table requires scanning every row, not reading `rows[0]`.
1181
1226
  */
1182
1227
  const AGNESCODE_FALLBACK_MODELS = Object.freeze([
1183
1228
  {
@@ -1312,15 +1357,13 @@ const NO_COST = Object.freeze({
1312
1357
  cacheRead: 0,
1313
1358
  cacheWrite: 0
1314
1359
  });
1315
- /** The catalogue/identity headers every request carries (probed: required). */
1360
+ /** The catalogue/identity headers every request carries (probed: required).
1361
+ * The anonymous half of one pair: `agnescodeHeaders`' map with its
1362
+ * `Authorization` line removed. The gate values come from
1363
+ * `AGNESCODE_BASE_HEADERS`, never a second copy here, so the two builders
1364
+ * cannot disagree. */
1316
1365
  function agnescodeRequestHeaders() {
1317
- return {
1318
- Accept: "application/json",
1319
- "Content-Type": "application/json",
1320
- "X-App-Id": "1",
1321
- "X-Platform": "1",
1322
- "X-User-Language": "zh-Hans"
1323
- };
1366
+ return { ...AGNESCODE_BASE_HEADERS };
1324
1367
  }
1325
1368
  /**
1326
1369
  * Whether an AgnesCode roster row accepts image input.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as numZeroOk, C as isAdmitted, D as forget, E as resolveSettings, F as retryBounded, I as str, L as verbatim, M as pluginError, N as redactError, O as num, P as redactSecrets, S as inject, T as resolveAuthOverrides, _ as parseUsageSeries, b as CONFIG_DEFAULTS, c as summarizeCatalog, d as isVideoGenModel, f as checkShape, g as parseUsageOverview, h as parseSubscriptionUsage, j as obj, k as numOrNull, l as visionOf, m as parsePlans, n as LLM_DISPLAY_NAME, o as filterByEnabled, p as matchCurrentPlan, r as LLM_PROVIDER_ID, s as rosterWithAvailability, t as DEFAULT_REASONING_EFFORT, u as isImageGenModel, v as quotaWindows, w as name, x as hostName, y as readSubscriptionExpiry } from "./llm-models.js";
1
+ import { A as numOrNull, C as inject, D as resolveSettings, E as resolveAuthOverrides, F as redactSecrets, I as retryBounded, L as str, M as obj, N as pluginError, O as forget, P as redactError, R as verbatim, S as hostName, T as name, _ as parseUsageOverview, b as readSubscriptionExpiry, c as summarizeCatalog, d as isVideoGenModel, f as checkShape, g as parseSubscriptionUsage, h as parsePlans, j as numZeroOk, k as num, l as visionOf, m as matchCurrentPlan, n as LLM_DISPLAY_NAME, o as filterByEnabled, p as countOf, r as LLM_PROVIDER_ID, s as rosterWithAvailability, t as DEFAULT_REASONING_EFFORT, u as isImageGenModel, v as parseUsageSeries, w as isAdmitted, x as CONFIG_DEFAULTS, y as quotaWindows } from "./llm-models.js";
2
2
  import { S as writeStateFile, _ as profileSegment, a as filterAgnescodeRows, b as stateDir, c as fetchAgnescodeBalance, d as trustAgnescodeBffBase, f as createFileCatalogStore, g as ensureStateDir, h as createStateReadCache, l as fetchAgnescodeCatalog, m as STATE_READ_TTL_MS, n as AGNESCODE_PROVIDER_ID, o as AGNESCODE_FALLBACK_MODELS, p as normalizeEnabledIds, s as decodeAgnescodeJwtExpMs, t as AGNESCODE_DISPLAY_NAME, u as harvestAgnescodeLocalSession, v as profileStateDir, x as temporaryOf, y as readStateJson } from "./agnescode-models.js";
3
3
  import { rm } from "node:fs/promises";
4
4
  import { join } from "node:path";
@@ -1673,7 +1673,7 @@ function createTokenStore(options) {
1673
1673
  ephemeral: ephemeral(),
1674
1674
  retryAfterMs: inForceWaitMs$1(held),
1675
1675
  needsUserAction: held !== null && held.parked,
1676
- error: isFresh$1(stored) ? null : state.lastError === null ? null : state.lastError instanceof Error ? state.lastError.message : String(state.lastError)
1676
+ error: isFresh$1(stored) ? null : state.lastError === null ? null : redactSecrets(state.lastError instanceof Error ? state.lastError.message : String(state.lastError))
1677
1677
  };
1678
1678
  }
1679
1679
  };
@@ -2127,7 +2127,7 @@ function createApiKeyStore({ credentials = null, env = process.env } = {}) {
2127
2127
  */
2128
2128
  async save(apiKey) {
2129
2129
  const value = verbatim(apiKey, "");
2130
- if (typeof value !== "string" || value.trim() === "") throw new Error("an API key is required");
2130
+ if (value.trim() === "") throw new Error("an API key is required");
2131
2131
  const service = resolveService();
2132
2132
  if (service !== null && typeof service.set === "function") {
2133
2133
  await service.set(API_KEY_REF, value);
@@ -2749,6 +2749,15 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
2749
2749
  *
2750
2750
  * The module is loaded once and the factory is read off it once, so a Host
2751
2751
  * whose peers resolve slowly pays that cost one time, not per publish.
2752
+ *
2753
+ * Only the SUCCESS is memoized. The memoized value used to be the promise
2754
+ * itself, which pinned a rejection in the slot for the life of the process:
2755
+ * after one failed `import()` every later publish rethrew the same error and
2756
+ * the provider could never register again — even after the operator fixed the
2757
+ * missing peer, which is exactly the remedy this file's own
2758
+ * `describeBuildFailure` prints for `ERR_MODULE_NOT_FOUND`. A recoverable
2759
+ * install problem cost a Host restart. Clearing the slot on failure keeps
2760
+ * "loaded once" for the happy path and gives the next publish a real retry.
2752
2761
  * @param {() => Promise<unknown>} loadModule - resolves the adapter module.
2753
2762
  * @param {string} exportName - the factory export to read off the module.
2754
2763
  * @returns {() => Promise<any>} the memoized factory resolver.
@@ -2756,7 +2765,10 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
2756
2765
  function createAdapterFactoryResolver(loadModule, exportName) {
2757
2766
  let adapterFactoryPromise;
2758
2767
  return async () => {
2759
- if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]);
2768
+ if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]).catch((error) => {
2769
+ adapterFactoryPromise = void 0;
2770
+ throw error;
2771
+ });
2760
2772
  return adapterFactoryPromise;
2761
2773
  };
2762
2774
  }
@@ -3190,7 +3202,6 @@ function wireAgnescodePublisher({ store, panelSwitch, enabledIds, getLlm, loadAd
3190
3202
  if (credential === null) return "";
3191
3203
  if (await store.isExpired().catch(() => false)) {
3192
3204
  if (harvest.inFlight === null && Date.now() >= harvest.blockedUntil) harvest.inFlight = harvestAgnescodeLocalSession().then(async (walk) => {
3193
- harvest.inFlight = null;
3194
3205
  if (walk?.ok !== true) {
3195
3206
  harvest.blockedUntil = Date.now() + AGNESCODE_REHARVEST_BACKOFF_MS;
3196
3207
  logger?.warn?.(`agnescode: re-harvest found no usable session (${walk?.attempts?.length ?? 0} probed files); riding the stored token until ${new Date(harvest.blockedUntil).toISOString()}`);
@@ -3209,9 +3220,10 @@ function wireAgnescodePublisher({ store, panelSwitch, enabledIds, getLlm, loadAd
3209
3220
  });
3210
3221
  return walk;
3211
3222
  }).catch(() => {
3212
- harvest.inFlight = null;
3213
3223
  harvest.blockedUntil = Date.now() + AGNESCODE_REHARVEST_BACKOFF_MS;
3214
3224
  return null;
3225
+ }).finally(() => {
3226
+ harvest.inFlight = null;
3215
3227
  });
3216
3228
  await Promise.resolve(harvest.inFlight).catch(() => null);
3217
3229
  }
@@ -3861,26 +3873,25 @@ function buildDrawEndpoint(apiBase) {
3861
3873
  /**
3862
3874
  * The draw-capable model ids of one catalog, de-duplicated in first-seen order.
3863
3875
  *
3864
- * Deduping keeps the LAST occurrence at its first-seen position, exactly like
3865
- * `rosterOf` / `buildDescriptors` / `catalog-store.normalizeEntries`, so the
3866
- * draw list and the chat roster can never disagree about which ids exist.
3876
+ * The ids are unique in first-seen order — a `Set` is all this needs. At the
3877
+ * id level there is no "which row survived" question the way `rosterOf` /
3878
+ * `buildDescriptors` / `catalog-store.normalizeEntries` have (they keep the
3879
+ * LAST row for an id, at its first-seen position), so the draw list and the
3880
+ * chat roster agree about which ids exist without sharing that row-level rule.
3881
+ * It WAS copied here, and the duplicate branch re-assigned the same id back to
3882
+ * its own slot — dead code that implied an ordering rule this function cannot
3883
+ * express.
3867
3884
  * @param {object[]} entries - the normalized catalog entries.
3868
3885
  * @returns {string[]}
3869
3886
  */
3870
3887
  function imageGenModelIds(entries) {
3871
- const position = /* @__PURE__ */ new Map();
3872
- const out = [];
3888
+ const ids = /* @__PURE__ */ new Set();
3873
3889
  for (const entry of Array.isArray(entries) ? entries : []) {
3874
3890
  if (!isImageGenModel(entry)) continue;
3875
3891
  const id = str(entry?.id, "");
3876
- if (id === "") continue;
3877
- if (position.has(id)) out[position.get(id)] = id;
3878
- else {
3879
- position.set(id, out.length);
3880
- out.push(id);
3881
- }
3892
+ if (id !== "") ids.add(id);
3882
3893
  }
3883
- return out;
3894
+ return [...ids];
3884
3895
  }
3885
3896
  /**
3886
3897
  * Choose the model one draw call addresses.
@@ -4223,27 +4234,24 @@ function isVideo25Flash(model) {
4223
4234
  return isVideo25Family(model) && /flash/i.test(str(model, ""));
4224
4235
  }
4225
4236
  /**
4226
- * The video-generation model ids of one catalog, de-duplicated in first-seen order.
4237
+ * The video-generation model ids of one catalog, unique in first-seen order.
4227
4238
  *
4228
4239
  * Recognition is delegated to `modality.ts`, so this list and the chat roster
4229
- * can never disagree about which entries exist.
4240
+ * can never disagree about which entries exist. A `Set` is all this needs: at
4241
+ * the id level there is no "which row survived" question, so the row-level
4242
+ * dedupe (`normalizeEntries`) is not copied here — see `imageGenModelIds` for
4243
+ * the same call.
4230
4244
  * @param {object[]} entries - the normalized catalog entries.
4231
4245
  * @returns {string[]}
4232
4246
  */
4233
4247
  function videoGenModelIds(entries) {
4234
- const position = /* @__PURE__ */ new Map();
4235
- const out = [];
4248
+ const ids = /* @__PURE__ */ new Set();
4236
4249
  for (const entry of Array.isArray(entries) ? entries : []) {
4237
4250
  if (!isVideoGenModel(entry)) continue;
4238
4251
  const id = str(entry?.id, "");
4239
- if (id === "") continue;
4240
- if (position.has(id)) out[position.get(id)] = id;
4241
- else {
4242
- position.set(id, out.length);
4243
- out.push(id);
4244
- }
4252
+ if (id !== "") ids.add(id);
4245
4253
  }
4246
- return out;
4254
+ return [...ids];
4247
4255
  }
4248
4256
  /**
4249
4257
  * The subset of video ids this module can actually address — the V2.0 family.
@@ -4397,25 +4405,32 @@ async function soft(run) {
4397
4405
  /**
4398
4406
  * The panel's projection of one catalogue entry: no `featureTexts`, no
4399
4407
  * envelope leftovers — only the fields the quota screen reads or compares.
4408
+ *
4409
+ * The numeric pass is defensive rather than a conversion: `parsePlans` already
4410
+ * ran `countOf` over every figure, so by the time a row reaches here each
4411
+ * number is a number. `countOf` is kept because a plan may arrive by another
4412
+ * route (the doctor and the tests build rows directly), and `Number(x) || 0`
4413
+ * would let `Infinity` through while `countOf` reads it as 0 — the panel must
4414
+ * show a missing figure, never an unbounded one.
4400
4415
  * @param {object} plan - one {@link parsePlans} entry.
4401
4416
  * @returns {object} the projection.
4402
4417
  */
4403
4418
  function planSummary(plan) {
4404
4419
  return {
4405
4420
  uuid: str(plan?.uuid, ""),
4406
- planId: Number(plan?.planId) || 0,
4421
+ planId: countOf(plan?.planId),
4407
4422
  name: str(plan?.name, ""),
4408
4423
  displayName: str(plan?.displayName, ""),
4409
4424
  billingCycle: str(plan?.billingCycle, ""),
4410
4425
  displayCycle: str(plan?.displayCycle, ""),
4411
- priceMinor: Number(plan?.priceMinor) || 0,
4426
+ priceMinor: countOf(plan?.priceMinor),
4412
4427
  currency: str(plan?.currency, ""),
4413
4428
  limits: {
4414
- requests5h: Number(plan?.concurrencyLimit) || 0,
4415
- requestsWindowH: Number(plan?.concurrencyWindowH) || 0,
4416
- requestsWeekly: Number(plan?.textWeeklyLimit) || 0,
4417
- imagesDaily: Number(plan?.imageDailyLimit) || 0,
4418
- videoDaily: Number(plan?.videoDailyLimit) || 0
4429
+ requests5h: countOf(plan?.concurrencyLimit),
4430
+ requestsWindowH: countOf(plan?.concurrencyWindowH),
4431
+ requestsWeekly: countOf(plan?.textWeeklyLimit),
4432
+ imagesDaily: countOf(plan?.imageDailyLimit),
4433
+ videoDaily: countOf(plan?.videoDailyLimit)
4419
4434
  }
4420
4435
  };
4421
4436
  }
@@ -4546,38 +4561,6 @@ function buildShapeWarnings(overview, series, plans) {
4546
4561
  ];
4547
4562
  }
4548
4563
  /**
4549
- * Fetch the four console sources plus the model catalog and aggregate them
4550
- * into the snapshot body the route writes.
4551
- *
4552
- * All five are fetched in parallel through the shared `cache` + `inflight`
4553
- * maps (a single-flight per URL so concurrent polls share one call) and the
4554
- * `tokenStore` (so a 401 triggers one renewal before the call). Only the
4555
- * catalog is authenticated by the API key instead; a missing key degrades the
4556
- * model lists, not the quota — resolved per poll so a key that arrives after
4557
- * the plugin mounted still lights the lists on the next poll.
4558
- *
4559
- * @param {object} context
4560
- * @param {object} context.settings - the resolved settings row.
4561
- * @param {Map} context.cache - the console-response cache (shared across polls).
4562
- * @param {Map} context.inflight - the single-flight map (shared across polls).
4563
- * @param {object} context.tokenStore - the `createTokenStore` instance.
4564
- * @param {object} context.apiKeyStore - the `createApiKeyStore` instance.
4565
- * @param {object} context.publisher - the `createProviderPublisher` instance.
4566
- * @param {object} context.catalogStore - the `createFileCatalogStore` instance.
4567
- * @param {() => Promise<boolean|null>} context.panelSwitch - the panel-saved
4568
- * provider switch (`provider-store.enabled()`); null when untouched.
4569
- * @param {() => Promise<boolean|null>} context.drawSwitch - the panel-saved
4570
- * draw-tool switch (`draw-store.enabled()`), same shape and precedence.
4571
- * @param {() => Promise<string|null>} context.drawModelId - the panel-saved
4572
- * draw-model preference (`draw-store.modelId()`); null when untouched.
4573
- * @param {() => Promise<boolean|null>} context.videoSwitch - the panel-saved
4574
- * video-tool switch (`video-store.enabled()`). A SEPARATE opt-in from the
4575
- * draw switch: the two modalities are independent.
4576
- * @param {() => Promise<string|null>} context.videoModelId - the panel-saved
4577
- * video-model preference (`video-store.modelId()`); null when untouched.
4578
- * @returns {Promise<object>} the snapshot body.
4579
- */
4580
- /**
4581
4564
  * The ONLY side effects `buildSnapshotBody` performs, isolated here so the
4582
4565
  * aggregator above stays a pure projection of the console results.
4583
4566
  *
@@ -4604,7 +4587,7 @@ async function applyCatalogEffects({ catalogStore, publisher, providerState, cat
4604
4587
  const freshSignature = catalogSignature(catalog, enabledIds);
4605
4588
  if (freshSignature !== providerState.signature) {
4606
4589
  catalogChanged = true;
4607
- if (await catalogStore.replace(catalog, enabledIds).catch(() => false)) providerState.signature = freshSignature;
4590
+ if (await catalogStore.replace(catalog, enabledIds)) providerState.signature = freshSignature;
4608
4591
  await publisher.publish(catalog, enabledIds, unavailableModelIds);
4609
4592
  }
4610
4593
  offered = catalog;
@@ -4619,6 +4602,38 @@ async function applyCatalogEffects({ catalogStore, publisher, providerState, cat
4619
4602
  catalogChanged
4620
4603
  };
4621
4604
  }
4605
+ /**
4606
+ * Fetch the four console sources plus the model catalog and aggregate them
4607
+ * into the snapshot body the route writes.
4608
+ *
4609
+ * All five are fetched in parallel through the shared `cache` + `inflight`
4610
+ * maps (a single-flight per URL so concurrent polls share one call) and the
4611
+ * `tokenStore` (so a 401 triggers one renewal before the call). Only the
4612
+ * catalog is authenticated by the API key instead; a missing key degrades the
4613
+ * model lists, not the quota — resolved per poll so a key that arrives after
4614
+ * the plugin mounted still lights the lists on the next poll.
4615
+ *
4616
+ * @param {object} context
4617
+ * @param {object} context.settings - the resolved settings row.
4618
+ * @param {Map} context.cache - the console-response cache (shared across polls).
4619
+ * @param {Map} context.inflight - the single-flight map (shared across polls).
4620
+ * @param {object} context.tokenStore - the `createTokenStore` instance.
4621
+ * @param {object} context.apiKeyStore - the `createApiKeyStore` instance.
4622
+ * @param {object} context.publisher - the `createProviderPublisher` instance.
4623
+ * @param {object} context.catalogStore - the `createFileCatalogStore` instance.
4624
+ * @param {() => Promise<boolean|null>} context.panelSwitch - the panel-saved
4625
+ * provider switch (`provider-store.enabled()`); null when untouched.
4626
+ * @param {() => Promise<boolean|null>} context.drawSwitch - the panel-saved
4627
+ * draw-tool switch (`draw-store.enabled()`), same shape and precedence.
4628
+ * @param {() => Promise<string|null>} context.drawModelId - the panel-saved
4629
+ * draw-model preference (`draw-store.modelId()`); null when untouched.
4630
+ * @param {() => Promise<boolean|null>} context.videoSwitch - the panel-saved
4631
+ * video-tool switch (`video-store.enabled()`). A SEPARATE opt-in from the
4632
+ * draw switch: the two modalities are independent.
4633
+ * @param {() => Promise<string|null>} context.videoModelId - the panel-saved
4634
+ * video-model preference (`video-store.modelId()`); null when untouched.
4635
+ * @returns {Promise<object>} the snapshot body.
4636
+ */
4622
4637
  async function buildSnapshotBody({ settings, cache, inflight, tokenStore, apiKeyStore, publisher, catalogStore, panelSwitch, drawSwitch, drawModelId, videoSwitch, videoModelId }) {
4623
4638
  const providerState = publisher.state;
4624
4639
  const resolveApiKey = async () => (await apiKeyStore.resolve()).value;
@@ -4829,6 +4844,22 @@ function refuseOrigin(response) {
4829
4844
  });
4830
4845
  }
4831
4846
  /**
4847
+ * The request's method, with the route's own fallback for a caller that omits
4848
+ * it.
4849
+ *
4850
+ * The web server always supplies a method, so the fallback is for the route
4851
+ * tests and any in-process caller. It is the ROUTE's choice, not a family
4852
+ * constant: a write route falls back to POST, a read route to GET, and the
4853
+ * wrong one turns a methodless request into a write. Shared here rather than
4854
+ * repeated, so the family's fallback wording has one home.
4855
+ * @param request - the incoming HTTP request.
4856
+ * @param fallback - the method to assume when the caller sent none.
4857
+ * @returns {string} the method.
4858
+ */
4859
+ function requestMethod(request, fallback) {
4860
+ return request.method === void 0 ? fallback : request.method;
4861
+ }
4862
+ /**
4832
4863
  * Refuse a disallowed method with the family's 405 shape.
4833
4864
  *
4834
4865
  * The 405 carries no `cache-control`: unlike a snapshot, a method refusal is
@@ -5099,7 +5130,7 @@ async function recordOriginlessWrite(method, options = {}) {
5099
5130
  lastMethod
5100
5131
  };
5101
5132
  const temporary = temporaryOf(dir, FILE);
5102
- await writeStateFile(file, JSON.stringify(payload), { temporary });
5133
+ await writeStateFile(file, payload, { temporary });
5103
5134
  } catch {}
5104
5135
  }
5105
5136
 
@@ -5196,7 +5227,7 @@ function registerAccountRoute(ctx, wiring) {
5196
5227
  refuseOrigin(response);
5197
5228
  return;
5198
5229
  }
5199
- const method = request.method === void 0 ? "POST" : request.method;
5230
+ const method = requestMethod(request, "POST");
5200
5231
  if (method === "GET") {
5201
5232
  writeJson(response, 200, {
5202
5233
  ok: true,
@@ -5288,7 +5319,7 @@ function registerApiKeyRoute(ctx, wiring) {
5288
5319
  refuseOrigin(response);
5289
5320
  return;
5290
5321
  }
5291
- const method = request.method === void 0 ? "GET" : request.method;
5322
+ const method = requestMethod(request, "GET");
5292
5323
  const answer = async (extra = {}) => writeJson(response, 200, {
5293
5324
  ok: true,
5294
5325
  ...await apiKeyStore.state().catch(() => ({
@@ -5372,7 +5403,7 @@ function registerProviderRoute(ctx, wiring) {
5372
5403
  refuseOrigin(response);
5373
5404
  return;
5374
5405
  }
5375
- const method = request.method === void 0 ? "GET" : request.method;
5406
+ const method = requestMethod(request, "GET");
5376
5407
  const answer = async (extra = {}) => {
5377
5408
  const providerSwitch = resolveSwitchEnabled(await readPanelValue(() => providerStore.enabled()), settings.registerProvider);
5378
5409
  writeJson(response, 200, {
@@ -5450,7 +5481,7 @@ function registerModelsRoute(ctx, wiring) {
5450
5481
  refuseOrigin(response);
5451
5482
  return;
5452
5483
  }
5453
- if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
5484
+ if (requestMethod(request, "POST") !== "POST") {
5454
5485
  refuseMethod(response);
5455
5486
  return;
5456
5487
  }
@@ -5552,7 +5583,7 @@ function registerToolSwitchRoute(ctx, { path, label, store, enabledKey, enabledS
5552
5583
  refuseOrigin(response);
5553
5584
  return;
5554
5585
  }
5555
- const method = request.method === void 0 ? "GET" : request.method;
5586
+ const method = requestMethod(request, "GET");
5556
5587
  const answer = async (extra = {}) => {
5557
5588
  const panelEnabled = await readPanelValue(async () => await store?.enabled() ?? null);
5558
5589
  const panelModel = await readPanelValue(async () => await store?.modelId() ?? null);
@@ -5720,6 +5751,13 @@ function registerVideoRoute(ctx, wiring) {
5720
5751
  *
5721
5752
  * @module dsh-connect-agnes-token-plan/routes/agnescode
5722
5753
  */
5754
+ /** Map the harvest rows through `redactSecrets`, at the single choke point. */
5755
+ function redactHarvestAttempts(attempts) {
5756
+ return attempts.map((attempt) => ({
5757
+ ...attempt,
5758
+ detail: redactSecrets(attempt.detail)
5759
+ }));
5760
+ }
5723
5761
  /** The AgnesCode provider route (ROADMAP §6.3 "third upstream provider"). */
5724
5762
  const AGNESCODE_PATH = `/api/${name}/agnescode`;
5725
5763
  /** Ceiling on an AgnesCode action body: every action posts a bare `{action}`. */
@@ -5754,6 +5792,7 @@ function registerAgnescodeRoute(ctx, wiring) {
5754
5792
  let expiresAtMs = null;
5755
5793
  let balance = null;
5756
5794
  let error = null;
5795
+ let credential = null;
5757
5796
  try {
5758
5797
  if (agnescodeStore !== null && agnescodeStore !== void 0) {
5759
5798
  const state = await agnescodeStore.state().catch(() => null);
@@ -5761,20 +5800,15 @@ function registerAgnescodeRoute(ctx, wiring) {
5761
5800
  nickname = state?.nickname ?? "";
5762
5801
  bffBase = state?.bffBase ?? "";
5763
5802
  expiresAtMs = state?.expiresAtMs ?? null;
5764
- if (loggedIn) {
5765
- const { credential } = await agnescodeStore.resolve().catch(() => ({ credential: null }));
5766
- if (credential?.accessToken) balance = await fetchAgnescodeBalance(credential).catch(() => null);
5767
- }
5803
+ credential = (await agnescodeStore.resolve().catch(() => ({ credential: null }))).credential ?? null;
5804
+ if (loggedIn && credential?.accessToken) balance = await fetchAgnescodeBalance(credential).catch(() => null);
5768
5805
  }
5769
5806
  } catch (why) {
5770
5807
  error = redactSecrets(why instanceof Error ? why.message : String(why));
5771
5808
  }
5772
5809
  let models = null;
5773
5810
  try {
5774
- if (agnescodeStore !== null && agnescodeStore !== void 0) {
5775
- const { credential } = await agnescodeStore.resolve().catch(() => ({ credential: null }));
5776
- if (credential?.accessToken) models = await fetchAgnescodeCatalog(credential).catch(() => null);
5777
- }
5811
+ if (credential?.accessToken) models = await fetchAgnescodeCatalog(credential).catch(() => null);
5778
5812
  } catch {
5779
5813
  models = null;
5780
5814
  }
@@ -5816,7 +5850,7 @@ function registerAgnescodeRoute(ctx, wiring) {
5816
5850
  const enabledIds = agnescodeModels !== null && agnescodeModels !== void 0 ? await agnescodeModels.listEnabledIds().catch(() => []) : [];
5817
5851
  await agnescodePublisher.publish(filterAgnescodeRows(rows, enabledIds), bffBase);
5818
5852
  };
5819
- const method = request.method === void 0 ? "GET" : request.method;
5853
+ const method = requestMethod(request, "GET");
5820
5854
  if (method === "GET") {
5821
5855
  let state = await agnescodeState();
5822
5856
  const staleRegistration = state.providerRegistered !== true && state.providerError !== null && state.providerError !== void 0 && state.providerError !== "";
@@ -5915,13 +5949,14 @@ function registerAgnescodeRoute(ctx, wiring) {
5915
5949
  try {
5916
5950
  if (agnescodeHarvestInFlight === null) agnescodeHarvestInFlight = (async () => {
5917
5951
  const walk = await harvestAgnescodeLocalSession();
5952
+ const attempts = redactHarvestAttempts(walk.attempts);
5918
5953
  lastHarvest = {
5919
5954
  ok: walk.ok,
5920
- attempts: walk.attempts
5955
+ attempts
5921
5956
  };
5922
5957
  if (walk.ok !== true) return {
5923
5958
  ok: false,
5924
- attempts: walk.attempts
5959
+ attempts
5925
5960
  };
5926
5961
  const expMs = decodeAgnescodeJwtExpMs(walk.session.accessToken);
5927
5962
  await agnescodeStore.save({
@@ -5933,7 +5968,7 @@ function registerAgnescodeRoute(ctx, wiring) {
5933
5968
  });
5934
5969
  return {
5935
5970
  ok: true,
5936
- attempts: walk.attempts
5971
+ attempts
5937
5972
  };
5938
5973
  })().finally(() => {
5939
5974
  agnescodeHarvestInFlight = null;
@@ -6712,27 +6747,40 @@ function defaultSleep(ms) {
6712
6747
  * @param {string} [options.model] - sent as `model_name`.
6713
6748
  * @param {number} [options.timeoutMs] - the poll budget.
6714
6749
  * @param {number} [options.pollIntervalMs] - the gap between queries.
6750
+ * @param {number} [options.queryRetries] - how many times a REFUSED query is
6751
+ * retried before the task id is handed back to the agent (default 2).
6715
6752
  * @param {Function} [options.sleep] - injected sleeper.
6716
6753
  * @param {Function} [options.now] - injected clock.
6717
6754
  * @param {Function} [options.isDisposed] - `() => boolean`, true after unmount.
6718
6755
  * @returns {Promise<object>} the terminal {@link parseVideoQuery} result.
6719
6756
  * @throws {Error} on timeout, disposal, or a refused query.
6720
6757
  */
6721
- async function pollVideoResult({ fetchImpl, queryEndpoint, apiKey, videoId, model, timeoutMs = VIDEO_DEFAULT_TIMEOUT_MS, pollIntervalMs = VIDEO_POLL_INTERVAL_MS, sleep = defaultSleep, now = Date.now, isDisposed = () => false }) {
6758
+ async function pollVideoResult({ fetchImpl, queryEndpoint, apiKey, videoId, model, timeoutMs = VIDEO_DEFAULT_TIMEOUT_MS, pollIntervalMs = VIDEO_POLL_INTERVAL_MS, queryRetries = 2, sleep = defaultSleep, now = Date.now, isDisposed = () => false }) {
6722
6759
  const budget = Math.max(1e3, Math.floor(num(timeoutMs, VIDEO_DEFAULT_TIMEOUT_MS)));
6723
6760
  const interval = Math.max(100, Math.floor(num(pollIntervalMs, VIDEO_POLL_INTERVAL_MS)));
6761
+ const retries = Math.max(0, Math.floor(num(queryRetries, 2)));
6724
6762
  const deadline = now() + budget;
6725
6763
  let attempts = 0;
6726
6764
  for (;;) {
6727
6765
  if (isDisposed()) throw new Error("Agnes video tool is no longer mounted");
6728
6766
  if (++attempts > 1e3) throw new Error(`视频生成轮询超过 ${VIDEO_MAX_POLLS} 次仍未结束(video_id=${videoId})——任务仍在平台侧运行,请稍后用 video_id 重新查询,不要重复提交`);
6729
- const value = await queryVideoTask({
6730
- fetchImpl,
6731
- queryEndpoint,
6732
- apiKey,
6733
- videoId,
6734
- model
6735
- });
6767
+ let value;
6768
+ let retried = 0;
6769
+ for (;;) try {
6770
+ value = await queryVideoTask({
6771
+ fetchImpl,
6772
+ queryEndpoint,
6773
+ apiKey,
6774
+ videoId,
6775
+ model
6776
+ });
6777
+ break;
6778
+ } catch (queryError) {
6779
+ const cause = queryError instanceof Error ? queryError.message : String(queryError);
6780
+ if (retried >= retries || isDisposed() || deadline - now() <= 0) throw new Error(`视频生成查询被拒(video_id=${videoId}):${cause}——任务仍在平台侧运行,可用 video_id=${videoId} 稍后重新查询,不要重复提交`);
6781
+ retried += 1;
6782
+ await sleep(Math.min(interval, deadline - now()));
6783
+ }
6736
6784
  if (isVideoTerminal(value.status)) return value;
6737
6785
  const remaining = deadline - now();
6738
6786
  if (remaining <= 0) throw new Error(`视频生成超时(超过 ${budget}ms,任务仍处于 ${value.status === "" ? "unknown" : value.status})——任务仍在平台侧运行,可用 video_id=${videoId} 稍后重新查询,不要重复提交`);
@@ -1,4 +1,4 @@
1
- import { a as buildDescriptors, n as LLM_DISPLAY_NAME, r as LLM_PROVIDER_ID, t as DEFAULT_REASONING_EFFORT, w as name } from "./llm-models.js";
1
+ import { T as name, a as buildDescriptors, n as LLM_DISPLAY_NAME, r as LLM_PROVIDER_ID, t as DEFAULT_REASONING_EFFORT } from "./llm-models.js";
2
2
  import { i as buildRetryPolicyConfig, n as STREAM_IDLE_TIMEOUT_MS, r as createWrappedPiAiAdapter, t as REQUEST_IMAGE_BUDGETS } from "./pi-ai-adapter-core.js";
3
3
  import { createProvider } from "@earendil-works/pi-ai";
4
4
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
package/lib/llm-models.js CHANGED
@@ -24,7 +24,6 @@
24
24
  function num(value, fallback) {
25
25
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
26
26
  }
27
- /** Read a non-empty string, else the fallback. */
28
27
  function str(value, fallback) {
29
28
  return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
30
29
  }
@@ -72,14 +71,6 @@ function redactError(error) {
72
71
  function obj(value) {
73
72
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
74
73
  }
75
- /**
76
- * Read a string exactly as it was given, else the fallback.
77
- *
78
- * The companion to `str()` for secrets: a password is stored, read back and
79
- * sent as typed, because trimming it is a change the user cannot see. A
80
- * password of only whitespace is still "not filled in", which the caller
81
- * judges with `.trim()`.
82
- */
83
74
  function verbatim(value, fallback) {
84
75
  return typeof value === "string" ? value : fallback;
85
76
  }
@@ -1036,6 +1027,22 @@ function finiteOrNull(value) {
1036
1027
  const parsed = Number(value);
1037
1028
  return Number.isFinite(parsed) ? parsed : null;
1038
1029
  }
1030
+ /**
1031
+ * Read `subscription.usage` into the panel's per-window facts.
1032
+ *
1033
+ * This is the missing half of the quota story. `quotaWindows` states the
1034
+ * LIMIT from the plan; this states what has actually been consumed inside the
1035
+ * current window, plus the window's own bounds and the moment it resets — all
1036
+ * figures the PLATFORM reported, not derived here. Nothing is subtracted:
1037
+ * `used` and `limit` are quoted side by side exactly as the console shows
1038
+ * them, so the panel's bar is a transcription, not an arithmetic claim.
1039
+ *
1040
+ * Returns `null` when the payload carries no `usage` at all — an account that
1041
+ * has never consumed anything simply may not report one, and that absence
1042
+ * must read as "no bar", never as "used 0".
1043
+ * @param {unknown} subscription - the unwrapped `/api/cn/user/subscription` data.
1044
+ * @returns {Record<string, SubscriptionUsageCell>|null}
1045
+ */
1039
1046
  function parseSubscriptionUsage(subscription) {
1040
1047
  const usage = obj(obj(subscription).usage);
1041
1048
  if (Object.keys(usage).length === 0) return null;
@@ -1536,32 +1543,6 @@ function maxOutputLengthOf(entry) {
1536
1543
  return 0;
1537
1544
  }
1538
1545
  /**
1539
- * The picker's 思考强度 levels, pinned to platform-valid wire spellings.
1540
- *
1541
- * DSH's picker offers levels from `getSupportedThinkingLevels(model)`
1542
- * (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), and pi-ai's
1543
- * openai-completions dispatch sends `reasoning_effort = map[level] ?? level`.
1544
- * Agnes's OpenAI-compat gateway rejects `off` (the OpenAI spelling) and
1545
- * `minimal`; the extended levels are PER-MODEL, per the 2026-10-01 live
1546
- * ladder (see PROBED_EFFORT): `xhigh` only on agnes-3.0-flash, `max` on
1547
- * every Agnes chat model. So:
1548
- *
1549
- * - `off: "none"` — the picker's "关闭" must send `none`, not `off`;
1550
- * - `low`/`medium` — 200 on every known chat model; a model ABSENT from
1551
- * the table (an unknown id) still gets them via the Agnes safe-set so
1552
- * the picker is never empty. A model PRESENT in the table with a level
1553
- * set `false` keeps it closed. The live-contract replay
1554
- * (`test/live-contract.mjs`) probes the per-model levels and flips the
1555
- * table cells once a model's 200 is recorded.
1556
- * - `xhigh`/`max` — per the table; the safe-set keeps both closed for
1557
- * unknown ids.
1558
- *
1559
- * A value of `null` means "the picker must not offer this level"; a string is
1560
- * the wire spelling the level dispatches to.
1561
- * @param {object} entry - one normalized catalog entry.
1562
- * @returns {object} the thinkingLevelMap.
1563
- */
1564
- /**
1565
1546
  * Per-model 思考档位 probe table (frozen 2026-10-01 from the live ladder
1566
1547
  * replay, mirrored from `test/baselines/agnes-contract.json`
1567
1548
  * §reasoningEffort — every cell is a platform answer, not a guess).
@@ -1614,6 +1595,32 @@ const PROBED_EFFORT = Object.freeze({
1614
1595
  max: true
1615
1596
  }
1616
1597
  });
1598
+ /**
1599
+ * The picker's 思考强度 levels, pinned to platform-valid wire spellings.
1600
+ *
1601
+ * DSH's picker offers levels from `getSupportedThinkingLevels(model)`
1602
+ * (`off`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), and pi-ai's
1603
+ * openai-completions dispatch sends `reasoning_effort = map[level] ?? level`.
1604
+ * Agnes's OpenAI-compat gateway rejects `off` (the OpenAI spelling) and
1605
+ * `minimal`; the extended levels are PER-MODEL, per the 2026-10-01 live
1606
+ * ladder (see PROBED_EFFORT): `xhigh` only on agnes-3.0-flash, `max` on
1607
+ * every Agnes chat model. So:
1608
+ *
1609
+ * - `off: "none"` — the picker's "关闭" must send `none`, not `off`;
1610
+ * - `low`/`medium` — 200 on every known chat model; a model ABSENT from
1611
+ * the table (an unknown id) still gets them via the Agnes safe-set so
1612
+ * the picker is never empty. A model PRESENT in the table with a level
1613
+ * set `false` keeps it closed. The live-contract replay
1614
+ * (`test/live-contract.mjs`) probes the per-model levels and flips the
1615
+ * table cells once a model's 200 is recorded.
1616
+ * - `xhigh`/`max` — per the table; the safe-set keeps both closed for
1617
+ * unknown ids.
1618
+ *
1619
+ * A value of `null` means "the picker must not offer this level"; a string is
1620
+ * the wire spelling the level dispatches to.
1621
+ * @param {object} entry - one normalized catalog entry.
1622
+ * @returns {object} the thinkingLevelMap.
1623
+ */
1617
1624
  function thinkingLevelMapFor(entry) {
1618
1625
  const id = str(entry?.id, "");
1619
1626
  const probed = PROBED_EFFORT[id];
@@ -1866,4 +1873,4 @@ function summarizeCatalog(entries) {
1866
1873
  }
1867
1874
 
1868
1875
  //#endregion
1869
- export { numZeroOk as A, isAdmitted as C, forget as D, resolveSettings as E, retryBounded as F, str as I, verbatim as L, pluginError as M, redactError as N, num as O, redactSecrets as P, inject as S, resolveAuthOverrides as T, parseUsageSeries as _, buildDescriptors as a, CONFIG_DEFAULTS as b, summarizeCatalog as c, isVideoGenModel as d, checkShape as f, parseUsageOverview as g, parseSubscriptionUsage as h, PROBED_VISION as i, obj as j, numOrNull as k, visionOf as l, parsePlans as m, LLM_DISPLAY_NAME as n, filterByEnabled as o, matchCurrentPlan as p, LLM_PROVIDER_ID as r, rosterWithAvailability as s, DEFAULT_REASONING_EFFORT as t, isImageGenModel as u, quotaWindows as v, name as w, hostName as x, readSubscriptionExpiry as y };
1876
+ export { numOrNull as A, inject as C, resolveSettings as D, resolveAuthOverrides as E, redactSecrets as F, retryBounded as I, str as L, obj as M, pluginError as N, forget as O, redactError as P, verbatim as R, hostName as S, name as T, parseUsageOverview as _, buildDescriptors as a, readSubscriptionExpiry as b, summarizeCatalog as c, isVideoGenModel as d, checkShape as f, parseSubscriptionUsage as g, parsePlans as h, PROBED_VISION as i, numZeroOk as j, num as k, visionOf as l, matchCurrentPlan as m, LLM_DISPLAY_NAME as n, filterByEnabled as o, countOf as p, LLM_PROVIDER_ID as r, rosterWithAvailability as s, DEFAULT_REASONING_EFFORT as t, isImageGenModel as u, parseUsageSeries as v, isAdmitted as w, CONFIG_DEFAULTS as x, quotaWindows as y };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-agnes-token-plan",
3
3
  "displayName": "DSH Connect Agnes Token Plan",
4
4
  "icon": "./icon.svg",
5
- "version": "0.10.1",
5
+ "version": "0.10.2",
6
6
  "description": "Agnes connect bundle for the Harness Web UI: Token Plan quota panel (account-wide quota windows, cumulative usage, console sign-in) plus optional provider registration that brings Agnes models into DSH conversation, with host-side rate-limit self-healing.",
7
7
  "keywords": [
8
8
  "dsh-plugin",
@@ -95,7 +95,6 @@
95
95
  "@commitlint/config-conventional": "^21.2.3",
96
96
  "@types/node": "^26.6.3",
97
97
  "jscpd": "^5.4.0",
98
- "jsqr": "^1.4.0",
99
98
  "tsdown": "^0.23.0",
100
99
  "typescript": "^5.9.3"
101
100
  }