dsh-connect-agnes-token-plan 0.8.0 → 0.8.1

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,16 @@
2
2
 
3
3
  本文件只记**公开行为变化**(新增能力、破坏性改动、重要修复)。实现细节、重构与测试加固请直接看 `git log`。
4
4
 
5
+ ## [0.8.1] — 2026-10-03
6
+
7
+ ### 修复:AgnesCode provider 补全思考契约——档位可用,`off` 诚实不提供
8
+
9
+ - **背景**:AgnesCode 是桌面端上游,v1 一直按「wire 通道未验证」把 `reasoning` 钉成 `false`,DSH 里看不到思考档位。2026-10-03 对真 BFF(`agnes-3.0-flash`)补探 7 发,推翻两条假设。
10
+ - **实测**:`{bffBase}/v1/chat/completions` 在**不发任何思考字段**时仍返回 `message.reasoning_content`(406 字 / 320 reasoning_tokens)——默认即思考开;`reasoning_effort` 的 `none`/`low`/`medium`/`high` 全被接受(都 200)。
11
+ - **关键发现**:`reasoning_effort:"none"` **关不掉思考**(仍回 257 字 / 188 reasoning_tokens)。桌面端真正关思考的开关是 `request_params.agnes_thinking_enabled=false`(或 `thinking_effort:"off"`),而 pi-ai 的档位映射是字符串值、表达不了它。
12
+ - **落地**:descriptor 翻 `reasoning:true` + `agnescodeThinkingLevelMap()`(`off:null` 诚实不提供,避免「点关思考实际还开着」的静默谎言;`low/medium/high` 开;`xhigh/max` 关,BFF 未实测);profile 钉 `reasoning: DEFAULT_REASONING_EFFORT`(high),与 Token Plan 侧对齐。
13
+ - 影响面:`src/host/{agnescode-models, agnescode-llm-adapter}.ts`、`test/agnescode.test.mjs`、`docs/ROADMAP.md` §6.3、`lib/`(按 ADR-008 重建入库)。
14
+
5
15
  ## [0.8.0] — 2026-10-02
6
16
 
7
17
  ### 修复:AgnesCode provider 必须进 tab 才启用(mount seed 竞态)
package/README.md CHANGED
@@ -90,7 +90,7 @@ npm run doctor:json # 机器读:JSON(可进你的巡检 / 工单脚本
90
90
  - [docs/API.md](docs/API.md) — 路由与控制台端点
91
91
  - [docs/AGNES-API.md](docs/AGNES-API.md) — Agnes 接口全集(控制台额度侧 + 推理侧)
92
92
  - [docs/TESTING.md](docs/TESTING.md) — 测试体系
93
- - [docs/PITFALLS.md](docs/PITFALLS.md) — 真实踩坑(39 条)
93
+ - [docs/PITFALLS.md](docs/PITFALLS.md) — 真实踩坑(41 条)
94
94
  - [CHANGELOG.md](CHANGELOG.md) — 版本变化
95
95
 
96
96
  AI 协作会话请先读 [AGENTS.md](AGENTS.md)。
package/client.js CHANGED
@@ -1,4 +1,4 @@
1
- var dsh_connect_sensenova_token_plan_client = (function() {
1
+ var dsh_connect_agnes_token_plan_client = (function() {
2
2
 
3
3
  //#region \0rolldown/runtime.js
4
4
  var __esmMin = (fn, res, err) => () => {
@@ -300,8 +300,8 @@ var dsh_connect_sensenova_token_plan_client = (function() {
300
300
  "quota.perYear": " / yr",
301
301
  "quota.cycle.monthly": "monthly",
302
302
  "quota.cycle.yearly": "yearly",
303
- "quota.unit.requests": "",
304
- "quota.unit.images": "",
303
+ "quota.unit.requests": " times",
304
+ "quota.unit.images": " images",
305
305
  "quota.used": "Used",
306
306
  "quota.resetAt": "resets {time}",
307
307
  "quota.resetCountdown": "resets in ~{minutes} min",
@@ -587,6 +587,21 @@ var dsh_connect_sensenova_token_plan_client = (function() {
587
587
  //#endregion
588
588
  //#region src/client/snapshot.ts
589
589
  /**
590
+ * Whether a body is a DATA snapshot, as opposed to a refusal.
591
+ *
592
+ * The two `ok` checks in `interpretSnapshot` do the real work — this is the
593
+ * name for the fact they establish, so the caller types the payload as data
594
+ * instead of casting through `unknown`. A type predicate still trusts the
595
+ * wire (the Host owns the shape; `wire.ts` documents that types are loaded,
596
+ * never enforced at runtime), but it records that `ok:true` was seen, where
597
+ * `as unknown as` threw that fact away.
598
+ * @param {unknown} value - the body that reached the `ok:true` branch.
599
+ * @returns {boolean} whether the value is a data snapshot.
600
+ */
601
+ function isSnapshotData(value) {
602
+ return typeof value === "object" && value !== null && value.ok === true;
603
+ }
604
+ /**
590
605
  * Read one snapshot response into the (data, error) pair the panel renders.
591
606
  *
592
607
  * The Host answers HTTP 200 for every expected outcome and signals the
@@ -614,7 +629,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
614
629
  error: "unexpected payload"
615
630
  };
616
631
  return {
617
- data: payload,
632
+ data: isSnapshotData(payload) ? payload : null,
618
633
  error: null
619
634
  };
620
635
  }
@@ -666,6 +681,21 @@ var dsh_connect_sensenova_token_plan_client = (function() {
666
681
  shapeWarnings: Array.isArray(data?.shapeWarnings) ? data.shapeWarnings : []
667
682
  };
668
683
  }
684
+ /**
685
+ * Whether to show the account management section.
686
+ *
687
+ * Decision: show the account editor UNCONDITIONALLY whenever the snapshot
688
+ * carries the Host's auth block. Gating on `hasAccount` / `needsAccount` made
689
+ * the "middle state" (grant still alive, saved account cleared) a dead end:
690
+ * the full-screen setup form lives behind `!data`, and the section card
691
+ * vanished with `hasAccount` — the user was locked out of their own account
692
+ * with no re-entry path until the grant died. This is a single-point-of-truth
693
+ * declaration; callers should use `shouldShowAccountManagement(auth)` rather
694
+ * than duplicating `auth !== null`.
695
+ */
696
+ function shouldShowAccountManagement(auth) {
697
+ return auth !== null;
698
+ }
669
699
  var GUIDANCE_BY_CODE, FORM_EXCLUDED_CODES, CLIENT_CODE, COOLDOWN_TEXT, REFUSAL_TEXT;
670
700
  var init_snapshot = __esmMin((() => {
671
701
  init_format();
@@ -769,7 +799,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
769
799
  tabActive: {
770
800
  color: "var(--dsw-alias-label-primary)",
771
801
  fontWeight: 600,
772
- borderBottom: "2px solid var(--agnes-brand, #6C5CE7)"
802
+ borderBottom: "2px solid var(--agnes-brand, #1E40AF)"
773
803
  },
774
804
  updated: {
775
805
  color: "var(--dsw-alias-label-secondary)",
@@ -946,7 +976,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
946
976
  barFill: {
947
977
  height: "100%",
948
978
  borderRadius: 3,
949
- background: "var(--agnes-brand, #6C5CE7)"
979
+ background: "var(--agnes-brand, #1E40AF)"
950
980
  },
951
981
  barFillWarn: { background: "var(--dsw-alias-state-warn-primary)" },
952
982
  barFillError: { background: "var(--dsw-alias-state-error-primary)" },
@@ -1082,7 +1112,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
1082
1112
  width: "100%",
1083
1113
  minHeight: 2,
1084
1114
  borderRadius: 2,
1085
- background: "var(--agnes-brand, #6C5CE7)"
1115
+ background: "var(--agnes-brand, #1E40AF)"
1086
1116
  },
1087
1117
  usageAxis: {
1088
1118
  display: "flex",
@@ -1208,7 +1238,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
1208
1238
  width: 15,
1209
1239
  height: 15,
1210
1240
  cursor: "pointer",
1211
- accentColor: "var(--agnes-brand, #6C5CE7)",
1241
+ accentColor: "var(--agnes-brand, #1E40AF)",
1212
1242
  margin: 0
1213
1243
  },
1214
1244
  modelName: {
@@ -2642,6 +2672,42 @@ var dsh_connect_sensenova_token_plan_client = (function() {
2642
2672
  //#endregion
2643
2673
  //#region src/client/agnescode-tab.ts
2644
2674
  /**
2675
+ * The tab's rendering decisions, lifted out of the JSX so they can be nailed.
2676
+ *
2677
+ * Three decisions here were each a REAL repair, and each is explained in a
2678
+ * comment at its old site — but none of them had a test, so the next person to
2679
+ * "simplify" the JSX could undo any of them silently. Same shape as `barPlan`
2680
+ * in `panel-page.ts`: the decision is a pure function of (state, error), so the
2681
+ * suite drives it directly instead of scraping a layout.
2682
+ *
2683
+ * The three, in the order they were learned:
2684
+ *
2685
+ * 1. `showError` — a route that ANSWERS with a failure is not "no local login
2686
+ * state". Naming the status is what stops a broken Host route from looking
2687
+ * exactly like a signed-out desktop App.
2688
+ * 2. `credentialCard` — withheld ONLY while a failed read leaves us knowing
2689
+ * NOTHING (`state === null`). Then `linked` is false and the unlinked copy
2690
+ * would accuse the reader's desktop App of not being signed in — a claim
2691
+ * this tab has no evidence for. Once ANY reading has landed, the card stays
2692
+ * up through a later failed poll: the honest statement is the error line
2693
+ * plus the LAST KNOWN account, not a blank. (`load` therefore never calls
2694
+ * `setState(null)` — clearing on failure is exactly the regression this
2695
+ * pins.)
2696
+ * 3. `linked` — reads `loggedIn` off the state, never inferred from the
2697
+ * absence of an error.
2698
+ *
2699
+ * @param {AgnescodeState|null} state - the last body the route answered, if any.
2700
+ * @param {string|null} error - the last read failure, if any.
2701
+ * @returns {{showError: boolean, credentialCard: boolean, linked: boolean}}
2702
+ */
2703
+ function agnescodeView(state, error) {
2704
+ return {
2705
+ showError: error !== null,
2706
+ credentialCard: !(state === null && error !== null),
2707
+ linked: state?.loggedIn === true
2708
+ };
2709
+ }
2710
+ /**
2645
2711
  * The AgnesCode tab body.
2646
2712
  * @param {object} props
2647
2713
  * @param {Tt} props.tt - the dictionary.
@@ -2765,7 +2831,8 @@ var dsh_connect_sensenova_token_plan_client = (function() {
2765
2831
  if (alive.current) load();
2766
2832
  }, [load]);
2767
2833
  const enabled = state?.enabled === true;
2768
- const loggedIn = state?.loggedIn === true;
2834
+ const view = agnescodeView(state, error);
2835
+ const loggedIn = view.linked;
2769
2836
  const models = Array.isArray(state?.models) ? state.models : [];
2770
2837
  const attempts = Array.isArray(state?.harvest?.attempts) ? state.harvest.attempts : [];
2771
2838
  const balance = state?.balance ?? null;
@@ -2773,7 +2840,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
2773
2840
  fontSize: 12,
2774
2841
  color: "var(--dsw-alias-label-secondary)",
2775
2842
  marginBottom: 12
2776
- } }, tt("agnescode.desc")), error !== null ? h("div", {
2843
+ } }, tt("agnescode.desc")), view.showError ? h("div", {
2777
2844
  style: S.formError,
2778
2845
  role: "alert"
2779
2846
  }, format(tt("agnescode.error"), { error })) : null, h("label", { style: {
@@ -2805,7 +2872,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
2805
2872
  } }, tt("agnescode.awaitingHarvest")) : state.providerRegistered === true ? null : h("div", { style: {
2806
2873
  ...S.muted,
2807
2874
  fontSize: 12
2808
- } }, tt("agnescode.unregistered")) : null, state === null && error !== null ? null : h("div", { style: {
2875
+ } }, tt("agnescode.unregistered")) : null, view.credentialCard ? h("div", { style: {
2809
2876
  ...S.card,
2810
2877
  marginTop: 4
2811
2878
  } }, loggedIn ? h("div", null, h("div", { style: {
@@ -2842,7 +2909,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
2842
2909
  },
2843
2910
  onClick: () => void harvest(),
2844
2911
  disabled: harvestBusy
2845
- }, harvestBusy ? tt("agnescode.harvesting") : tt("agnescode.harvest")))), attempts.length > 0 ? h("div", {
2912
+ }, harvestBusy ? tt("agnescode.harvesting") : tt("agnescode.harvest")))) : null, attempts.length > 0 ? h("div", {
2846
2913
  style: {
2847
2914
  ...S.muted,
2848
2915
  fontSize: 12,
@@ -3258,7 +3325,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
3258
3325
  style: S.chip,
3259
3326
  title: auth.error || guidance || ""
3260
3327
  }, tt("auth.needsLogin")) : h("span", { style: S.chip }, tt("auth.selfRenew"));
3261
- const authManage = auth !== null;
3328
+ const authManage = shouldShowAccountManagement(auth);
3262
3329
  const tabBar = h("div", {
3263
3330
  style: S.tabBar,
3264
3331
  role: "tablist"
@@ -3546,6 +3613,7 @@ var dsh_connect_sensenova_token_plan_client = (function() {
3546
3613
  viewOf,
3547
3614
  errorOfStatus,
3548
3615
  barPlan,
3616
+ agnescodeView,
3549
3617
  dictionaries: Object.freeze({
3550
3618
  zh,
3551
3619
  en
@@ -0,0 +1,104 @@
1
+ import { t as DEFAULT_REASONING_EFFORT, w as name } from "./llm-models-BZfJOFax.js";
2
+ import { i as buildAgnescodeDescriptors, n as AGNESCODE_PROVIDER_ID, r as agnescodeRoster, t as AGNESCODE_DISPLAY_NAME } from "./agnescode-models-w02GyYgT.js";
3
+ import { i as buildRetryPolicyConfig, n as STREAM_IDLE_TIMEOUT_MS, r as createWrappedPiAiAdapter, t as REQUEST_IMAGE_BUDGETS } from "./pi-ai-adapter-core-D0iGPjK-.js";
4
+ import { createProvider } from "@earendil-works/pi-ai";
5
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
6
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
7
+
8
+ //#region src/host/agnescode-llm-adapter.ts
9
+ /**
10
+ * The peer-dependent half of the directly-registered AgnesCode provider —
11
+ * the desktop-app upstream (ROADMAP §6.3).
12
+ *
13
+ * ONE `PiAiAdapter` carrying ONE profile (`agnescode` → the credential's
14
+ * per-account BFF base), an INERT pi-ai auth plane (the AgnesCode JWT is
15
+ * resolved per request from the plugin's own credential store, pi-ai never
16
+ * manufactures it), and no token baked into the profile — the picker
17
+ * advertises models without one and a request fails at resolve time, where the
18
+ * panel status is visible.
19
+ *
20
+ * Both IMAGE hooks are wired, exactly as the Token Plan adapter
21
+ * (`llm-adapter.ts`) wires them — the shape a working vision route has. The
22
+ * catalogue still declares no modality field (`model_type: "text"`), so the
23
+ * vision CLAIM is borrowed from `PROBED_VISION` in the peer-free
24
+ * `agnescode-models.ts` (the Agnes family is the same gateway the Token Plan
25
+ * side probed accepting `image_url`); the descriptor's `input` and the adapter's
26
+ * hooks must agree, or a message carrying an image reaches the durable
27
+ * attachment service with no way to locate the image and fails. Wiring the
28
+ * hooks without the claim would be inert; making the claim without the hooks is
29
+ * the bug this closes.
30
+ *
31
+ * The descriptor mapping lives in the peer-free `agnescode-models.ts`, so this
32
+ * module holds only assembly against the runtime and is exercised by the
33
+ * wiring/e2e checks, exactly as `llm-adapter.ts` is. The assembly MECHANISM
34
+ * itself (auth plane, image budgets/hooks, 429 correction) is shared with the
35
+ * Token Plan route through `pi-ai-adapter-core.ts` — what stays here is the
36
+ * AgnesCode configuration: provider id, model builder, credential resolver, and
37
+ * a profile that pins the SAME effort default as the Token Plan route (probed
38
+ * live 2026-10-03: the BFF accepts `reasoning_effort` and defaults thinking ON,
39
+ * so an unselected effort must not reach pi-ai as "no effort").
40
+ *
41
+ * @module dsh-connect-agnes-token-plan/agnescode-llm-adapter
42
+ */
43
+ /**
44
+ * Assemble the AgnesCode adapter for one credential/catalogue snapshot.
45
+ *
46
+ * A fresh instance per rebuild is deliberate: `PiAiAdapter` memoizes the
47
+ * profiles snapshot internally, so the caller REPLACES the registered
48
+ * adapter when the credential or catalogue changes and emits
49
+ * `llm/adapters-updated`.
50
+ * @param {object} options - wiring.
51
+ * @param {object[]} [options.rows] - the AgnesCode roster rows (`agnescodeRoster`).
52
+ * @param {string} options.bffBase - the credential's pinned per-account BFF base.
53
+ * @param {() => Promise<string>} options.resolveToken - resolves the live
54
+ * AgnesCode JWT per request (re-harvested by the caller when expired).
55
+ * @param {(service: string) => unknown} [options.get] - service resolver for
56
+ * the image hooks (`attachments`, `fs`); resolved lazily per request because
57
+ * a service may register after this adapter is built.
58
+ * @returns {{adapter: object, providerIds: string[]}} the adapter and the ids
59
+ * it owns.
60
+ */
61
+ function createAgnescodeAdapter({ rows, bffBase, resolveToken, get } = {}) {
62
+ const models = buildAgnescodeDescriptors(rows ?? agnescodeRoster(null), { bffBase: String(bffBase ?? "") });
63
+ const provider = {
64
+ ...createProvider({
65
+ id: AGNESCODE_PROVIDER_ID,
66
+ name: AGNESCODE_DISPLAY_NAME,
67
+ auth: { apiKey: {
68
+ name: "AgnesCode access token",
69
+ async resolve({ credential } = {}) {
70
+ const token = credential?.key;
71
+ return token === void 0 || token === "" ? void 0 : {
72
+ auth: { apiKey: token },
73
+ source: AGNESCODE_DISPLAY_NAME
74
+ };
75
+ }
76
+ } },
77
+ models,
78
+ api: openAICompletionsApi()
79
+ }),
80
+ getModels: () => models
81
+ };
82
+ const profiles = /* @__PURE__ */ new Map([[AGNESCODE_PROVIDER_ID, {
83
+ provider: AGNESCODE_PROVIDER_ID,
84
+ displayName: AGNESCODE_DISPLAY_NAME,
85
+ streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
86
+ retryPolicy: resolveRetryPolicy(buildRetryPolicyConfig(), `${name}.${AGNESCODE_PROVIDER_ID}.retryPolicy`),
87
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
88
+ modelErrors: /* @__PURE__ */ new Map(),
89
+ reasoning: DEFAULT_REASONING_EFFORT,
90
+ ...REQUEST_IMAGE_BUDGETS,
91
+ piProvider: provider
92
+ }]]);
93
+ return {
94
+ adapter: createWrappedPiAiAdapter({
95
+ profiles,
96
+ resolveApiKey: async () => resolveToken?.() ?? "",
97
+ get
98
+ }),
99
+ providerIds: [AGNESCODE_PROVIDER_ID]
100
+ };
101
+ }
102
+
103
+ //#endregion
104
+ export { createAgnescodeAdapter };
@@ -1,4 +1,4 @@
1
- import { D as num, N as str, i as PROBED_VISION, k as obj, w as name } from "./llm-models-CvWAbIG8.js";
1
+ import { D as num, P as str, i as PROBED_VISION, k as obj, w as name } from "./llm-models-BZfJOFax.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";
@@ -610,8 +610,8 @@ function parse(raw) {
610
610
  * @typedef {object} CatalogStore
611
611
  * @property {() => Promise<object[]>} list - stored entries, `[]` when none usable.
612
612
  * @property {() => Promise<string[]>} listEnabledIds - allow-list; `[]` means "no filter".
613
- * @property {(entries: object[], enabledModelIds?: string[]) => Promise<void>} replace - swap the catalog, preserving the allow-list unless given a new one.
614
- * @property {(ids: string[]) => Promise<void>} setEnabledIds - swap ONLY the allow-list.
613
+ * @property {(entries: object[], enabledModelIds?: string[]) => Promise<boolean>} replace - swap the catalog, preserving the allow-list unless given a new one; returns whether the record actually reached disk.
614
+ * @property {(ids: string[]) => Promise<boolean>} setEnabledIds - swap ONLY the allow-list; same return contract.
615
615
  * @property {() => Promise<void>} clear - remove the stored catalog.
616
616
  */
617
617
  /**
@@ -662,15 +662,23 @@ function createFileCatalogStore(options = {}) {
662
662
  * The temp path is process-plus-clock unique (`state-store.ts`'s
663
663
  * `temporaryOf`), so two Host processes sharing this directory never write
664
664
  * the same temp name and `rename` each other's half-written file away.
665
+ *
666
+ * Returns whether the record actually reached disk. The failure is still
667
+ * swallowed — a read-only Home must not break the panel — but the caller has
668
+ * to know it happened: an in-memory record the disk does not hold must not
669
+ * be treated as settled (PITFALLS §40).
670
+ * @returns {Promise<boolean>} `true` when disk now holds `held`, `false` when the write failed.
665
671
  */
666
672
  const persist = async () => {
667
- if (held === null) return;
673
+ if (held === null) return true;
668
674
  const temporary = temporaryOf(stateDir$1, "catalog.json", now);
669
675
  try {
670
676
  await ensureStateDir(stateDir$1);
671
677
  await writeStateFile(file, JSON.stringify(held), { temporary });
678
+ return true;
672
679
  } catch {
673
680
  await rm(temporary, { force: true }).catch(() => {});
681
+ return false;
674
682
  }
675
683
  };
676
684
  return {
@@ -699,7 +707,7 @@ function createFileCatalogStore(options = {}) {
699
707
  * PRESERVED across a catalog refresh unless a new one is supplied.
700
708
  * @param {object[]} entries - the fresh catalog entries.
701
709
  * @param {string[]} [enabledModelIds] - an optional replacement allow-list.
702
- * @returns {Promise<void>}
710
+ * @returns {Promise<boolean>} whether the record reached disk.
703
711
  */
704
712
  async replace(entries, enabledModelIds) {
705
713
  const current = await seen();
@@ -711,9 +719,12 @@ function createFileCatalogStore(options = {}) {
711
719
  enabledModelIds: enabledModelIds === void 0 ? kept : normalizeEnabledIds(enabledModelIds)
712
720
  };
713
721
  cache.remember(held);
714
- await persist();
722
+ return persist();
715
723
  },
716
- /** Replace ONLY the curated allow-list, keeping the cached catalog. */
724
+ /**
725
+ * Replace ONLY the curated allow-list, keeping the cached catalog.
726
+ * @returns {Promise<boolean>} whether the record reached disk.
727
+ */
717
728
  async setEnabledIds(ids) {
718
729
  const current = await seen();
719
730
  const entries = current === null ? [] : current.entries;
@@ -725,7 +736,7 @@ function createFileCatalogStore(options = {}) {
725
736
  enabledModelIds: normalizeEnabledIds(ids)
726
737
  };
727
738
  cache.remember(held);
728
- await persist();
739
+ return persist();
729
740
  },
730
741
  /** Remove the stored catalog (used when the API key is forgotten). */
731
742
  async clear() {
@@ -1394,12 +1405,19 @@ const AGNESCODE_FALLBACK_MODELS = Object.freeze([
1394
1405
  * is the peer-dependent half.
1395
1406
  *
1396
1407
  * Three decisions carried here:
1397
- * 1. `reasoning: false` — the catalogue rows declare a `thinking_toggle`
1398
- * (default ON), but the WIRE field that would control it from OpenAI
1399
- * chat is UNVERIFIED (ROADMAP §6.3). Offering a thinking selector would
1400
- * promise something the descriptor cannot emit; the honest v1 shape is
1401
- * "no toggle; the BFF defaults to thinking ON". A stated limitation, not
1402
- * an oversight.
1408
+ * 1. `reasoning: true` + a BFF-specific `thinkingLevelMap` — probed LIVE on
1409
+ * 2026-10-03 against the per-account BFF (`agnes-3.0-flash`,
1410
+ * `{bffBase}/v1/chat/completions`): the OpenAI-compatible `reasoning_effort`
1411
+ * ladder `none`/`low`/`medium`/`high` is ACCEPTED (HTTP 200 under every
1412
+ * one) and the BFF returns `message.reasoning_content` under each — even
1413
+ * with NO thinking field at all (the v1 shape), which is why v1's
1414
+ * `reasoning: false` never actually turned thinking off, it only hid the
1415
+ * selector. `off` is `null` on purpose: `reasoning_effort:"none"` does NOT
1416
+ * disable thinking (still 188 reasoning tokens), and the desktop App's real
1417
+ * off switch (`request_params.agnes_thinking_enabled:false`) is not
1418
+ * expressible in pi-ai's string-valued map. Offering "off" would promise
1419
+ * "thinking off" and deliver "still thinking". `xhigh`/`max` stay closed
1420
+ * until a probe proves them on the BFF.
1403
1421
  * 2. The base URL is PER-ACCOUNT (`bffPublicBaseUrl` from the session
1404
1422
  * file), so it rides through the builder's options instead of a module
1405
1423
  * constant — the reference reverse-proxy's hardcoded `.com` constant is
@@ -1515,6 +1533,33 @@ function filterAgnescodeRows(rows, enabledIds) {
1515
1533
  return list.filter((row) => keep.has(str(row?.id, "")));
1516
1534
  }
1517
1535
  /**
1536
+ * The thinking level map this provider offers, keyed on pi-ai's ladder.
1537
+ *
1538
+ * The BFF's `reasoning_effort` ladder was probed live on 2026-10-03
1539
+ * (`agnes-3.0-flash`): `none`/`low`/`medium`/`high` are all ACCEPTED (HTTP 200)
1540
+ * — but `none` does NOT disable thinking (the response still carried
1541
+ * `reasoning_content`, 188 reasoning tokens). The desktop App's real off switch
1542
+ * is `request_params.agnes_thinking_enabled:false` (or
1543
+ * `request_params.thinking_effort:"off"`), which a string-valued thinking map
1544
+ * cannot emit. So `off` is DELIBERATELY `null`: offering it would promise
1545
+ * "thinking off" and deliver "still thinking" — the same class of silent lie
1546
+ * `docs/DSH-LLM-DEVELOP.md` §4 warns about for the opposite direction. The
1547
+ * levels pi-ai actually sends (`low`/`medium`/`high`) are the ones probed 200.
1548
+ * `xhigh`/`max` stay closed until a probe proves them on the BFF.
1549
+ * @returns {object} the level → wire-spelling map.
1550
+ */
1551
+ function agnescodeThinkingLevelMap() {
1552
+ return {
1553
+ off: null,
1554
+ minimal: null,
1555
+ low: "low",
1556
+ medium: "medium",
1557
+ high: "high",
1558
+ xhigh: null,
1559
+ max: null
1560
+ };
1561
+ }
1562
+ /**
1518
1563
  * Map one AgnesCode row onto the pi-ai model descriptor the adapter offers.
1519
1564
  * @param {object} row - an {@link agnescodeRoster} row (must carry `id`).
1520
1565
  * @param {object} [options] - `{ bffBase }` — the pinned per-account base.
@@ -1533,7 +1578,8 @@ function agnescodeToDescriptor(row, options = {}) {
1533
1578
  provider: AGNESCODE_PROVIDER_ID,
1534
1579
  baseUrl: bffBase,
1535
1580
  input: vision ? ["text", "image"] : ["text"],
1536
- reasoning: false,
1581
+ reasoning: true,
1582
+ thinkingLevelMap: agnescodeThinkingLevelMap(),
1537
1583
  cost: { ...NO_COST },
1538
1584
  contextWindow: num(row?.contextWindow) ?? 512e3,
1539
1585
  maxTokens: num(row?.maxOutputLength) ?? 32e3,