@lynn123411/dsh-llm-agentrouter 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # @lynn123411/dsh-llm-agentrouter
2
2
 
3
- AgentRouter 中转聚合插件:把同一家中转的多个模型收拢为一条 pi-ai 路由(模型选择器只出现一个 AgentRouter 分组),国内 / 国际端点在「设置 - API中转 - AgentRouter 中转站」一键切换、下一请求即生效,外加出站请求的 User-Agent 改写与 402 配额耗尽提示围栏。分叉自 `aqiu817/dsh-llm-agentrouter`,适配 DSH `0.1.2-alpha.5` 起的新 settings/slots API,已在 `0.1.5-rc.1` 逐项实证兼容。
3
+ AgentRouter 中转聚合插件:把同一家中转的多个模型收拢为一条 pi-ai 路由(模型选择器只出现一个 AgentRouter 分组),国内 / 国际端点在「设置 - API中转 - AgentRouter 中转站」一键切换、下一请求即生效,外加出站请求的 User-Agent 改写与 402 配额耗尽提示围栏。模型列表在卡片内直接编辑,写入内核「设置 - 模型」页所编辑的同一份 `llm-pi-ai` 路由数据。
4
4
 
5
5
  ## 特性
6
6
 
7
- - **单路由多模型**:Claude Opus 5、GPT-5.6-sol 等走同一 `agentrouter` 路由,`reasoningEffort` 档位与兼容开关按实测探针手写声明,模型列表不翻倍。
8
- - **模型列表可编辑**:设置卡片里直接列出这条路由的模型,`更新` 向中转站询问它现有的模型 ID(只补缺失的、不动已有条目),每个模型的 ID / 显示名 / 上下文窗口 / 最大输出 / 输入模态 / 各推理档位与线上取值都可改,保存后下一个请求即生效;新补入的模型默认 1048576 上下文、131072 输出、text+image、off..max 七档。改的就是路由自己的 `models`(与内核「设置 - 模型」页同一份数据),patch 里手写的那几条是「重置」的基线。
7
+ - **单路由多模型**:Claude Opus 5、GPT-5.6-sol 等走同一 `agentrouter` 路由,`reasoningEfforts` 各档位与 `compat` 开关按实测探针手写声明,模型列表不翻倍。
8
+ - **模型列表可编辑**:设置卡片里直接列出这条路由的模型,`更新` 向中转站询问它现有的模型 ID(只补缺失的、不动已有条目),每个模型的 ID / 显示名 / 上下文窗口 / 最大输出 / 输入模态 / 各推理档位与线上取值都可改,保存后下一个请求即生效;新补入的模型默认 1048576 上下文、131072 输出、text+image、off..max 七档。改的就是路由自己的 `models`——`llm-pi-ai` 这条 profile 插件配置里的 `providers.agentrouter.models`,内核「设置 - 模型」页对该路由注册的写入地址也正是这里,两边同一份数据;patch 里手写的那几条是「重置」的基线。
9
9
  - **端点一键切换**:设置卡片两个大选项(国内端点 / 国际端点,附带真实 host 展示),点击即写即生效,无需重启;API Key 只存 `$DSH_HOME/.credentials.yaml` 的 `AGENTROUTER_API_KEY` 引用。
10
10
  - **共享「API中转」设置页**:本插件的端点卡片与 `dsh-a6api` 的面板挂在同一个设置页里(`settings.section` id `relay` 的页内两个 tab,参与者可增删)。内核不允许一页被多个插件共同声明,因此每个参与者各持一份运行时逻辑一致的页壳,**先加载者当选页面宿主**、其余只注册卡片;当选者被卸载后下次启动自动改选。做法与约束见仓库 `docs/rules/shared-settings-page.md`。
11
11
  - **请求围栏**:重写 relay 要求的 `User-Agent`(适配器强制署名下沉到 `fetch` 层替换),哨兵 host(`.internal` 不可解析)兜底防裸奔,402 配额耗尽的 JSON 错误体改写为可读提示。
12
- - **alpha.5 兼容**:宿主沿用 `ctx.settings.installSection` + `ctx.inject(['settings'])`,客户端卡片改挂共享设置页的子 slot;沿用 `llm-agentrouter` 设置命名空间,老配置无缝继承。
12
+ - **内核设置接入**:本插件 `Config` 里需要实时生效的字段都声明为 `.volatile()`,设置面据此派生表单、并把配置写回同一批引用(`ctx.inject(['settings'])` + `ctx.settings.configure({ auto: false })` 记下「本插件自带 UI」);客户端卡片挂在共享设置页的子 slot,读写的是 `llm-agentrouter` 这条 profile 插件配置,条目 id 即设置命名空间。
13
13
 
14
14
  ## 安装
15
15
 
package/cordis.patch.yml CHANGED
@@ -56,15 +56,15 @@
56
56
  # name and an adapter-supplied description a hand-declared pi-ai route has no
57
57
  # field for. The group title is therefore the only reachable surface — and it is
58
58
  # a name, not a notice, so it stays a name. The endpoint is explained where it is
59
- # changed: 设置 → 插件 → AgentRouter 中转站.
59
+ # changed: 设置 → API中转 → AgentRouter 中转站.
60
60
  #
61
61
  # WHOLE-OBJECT OVERWRITE, NOT A DEEP MERGE — RE-CHECK ON EVERY dsh-base BUMP
62
62
  # The loader applies a patch entry as `target[key] = value`
63
- # (applyEntryPatches, 0.1.5-rc.1), so the `config:` below REPLACES the
64
- # llm-pi-ai row's whole config object: nothing a default config carried survives,
65
- # at any depth. That is safe today only because dsh-base mounts
66
- # `- id: llm-pi-ai` with a `name:` and no `config:` at all
67
- # (dsh-base/cordis.patch.yml:107), so there is nothing to lose. If a future
63
+ # (applyEntryPatches, @deepseek-ai/dsh-app-boot), so the `config:` below
64
+ # REPLACES the llm-pi-ai row's whole config object: nothing a default config
65
+ # carried survives, at any depth. That is safe today only because dsh-base
66
+ # mounts `- id: llm-pi-ai` with a `name:` and no `config:` at all
67
+ # (dsh-base/cordis.patch.yml:127-128), so there is nothing to lose. If a future
68
68
  # dsh-base gives that row a default config, this patch swallows it SILENTLY —
69
69
  # diff the shipped llm-pi-ai row against this file on every dsh-base bump and
70
70
  # fold whatever it adds into the `providers:` map below.
package/lib/client.js CHANGED
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let jsx = require("react/jsx-runtime");
8
8
  let react = require("react");
9
+ const { Button, Checkbox, Input } = require("@deepseek-ai/dsh-client-ui-primitives");
9
10
 
10
11
  //#region styles
11
12
  /*
@@ -16,44 +17,40 @@ window.__ModuleLoader__.load({
16
17
  */
17
18
  const CSS = [
18
19
  ".dshAr_card{display:flex;flex-direction:column;gap:10px}",
19
- ".dshAr_lead{margin:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}",
20
+ ".dshAr_lead{margin:0;color:var(--dsw-alias-label-tertiary);font:var(--dsw-font-xs-13)}",
20
21
  ".dshAr_choices{display:flex;gap:10px;margin:0;padding:0;border:0;flex-wrap:wrap}",
21
- ".dshAr_choice{flex:1 1 200px;min-width:0;display:flex;gap:9px;align-items:flex-start;padding:11px 13px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);cursor:pointer}",
22
+ ".dshAr_choice{flex:1 1 200px;min-width:0;display:flex;gap:9px;align-items:flex-start;padding:11px 13px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3);cursor:pointer;transition:background-color var(--ds-transition-duration) var(--ds-ease-in-out),border-color var(--ds-transition-duration) var(--ds-ease-in-out)}",
22
23
  ".dshAr_choice:hover{background:var(--dsw-alias-interactive-bg-hover)}",
23
24
  ".dshAr_choice[data-selected=true]{border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 1px var(--dsw-alias-state-business-primary) inset}",
24
25
  ".dshAr_choice[data-disabled=true]{cursor:default;opacity:.55}",
25
- ".dshAr_choice input{margin:3px 0 0}",
26
+ ".dshAr_choice input{margin:3px 0 0;accent-color:var(--dsw-alias-brand-primary)}",
27
+ ".dshAr_choice input:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary)}",
26
28
  ".dshAr_choiceText{display:flex;flex-direction:column;gap:2px;min-width:0}",
27
- ".dshAr_choiceTitle{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}",
28
- ".dshAr_choiceHost{overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code);font-size:12px;line-height:17px}",
29
- ".dshAr_choiceHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px}",
30
- ".dshAr_status{margin:0;min-height:18px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}",
29
+ ".dshAr_choiceTitle{color:var(--dsw-alias-label-primary);font:var(--dsw-font-s-strong-14)}",
30
+ ".dshAr_choiceHost{overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary);font:var(--dsw-font-xxs-12);font-family:var(--ds-font-family-code)}",
31
+ ".dshAr_choiceHint{color:var(--dsw-alias-label-tertiary);font:var(--dsw-font-xxs-12)}",
32
+ ".dshAr_status{margin:0;min-height:18px;color:var(--dsw-alias-label-tertiary);font:var(--dsw-font-xxs-12)}",
31
33
  ".dshAr_status[data-kind=error]{color:var(--dsw-alias-state-error-primary)}",
32
- ".dshAr_models{display:flex;flex-direction:column;gap:8px;margin-top:4px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}",
34
+ ".dshAr_models{display:flex;flex-direction:column;gap:8px;margin-top:4px;padding-top:12px;border-top:0.5px solid var(--dsw-alias-border-l2)}",
33
35
  ".dshAr_modelsHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}",
34
- ".dshAr_modelsHead h4{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:20px}",
36
+ ".dshAr_modelsHead h4{margin:0;color:var(--dsw-alias-label-primary);font:var(--dsw-font-s-strong-14)}",
35
37
  ".dshAr_actions{display:flex;gap:8px;flex-wrap:wrap}",
36
38
  ".dshAr_modelsHead .dshAr_actions{margin-left:auto}",
37
- ".dshAr_button{appearance:none;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer}",
38
- ".dshAr_button:hover:enabled{background:var(--dsw-alias-interactive-bg-hover)}",
39
- ".dshAr_button:disabled{cursor:default;opacity:.5}",
40
39
  ".dshAr_modelsList{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}",
41
- ".dshAr_model{margin:0;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3)}",
42
- ".dshAr_modelHead{display:flex;gap:8px;align-items:baseline;cursor:pointer;color:var(--dsw-alias-label-primary);font-size:13px;line-height:19px}",
40
+ ".dshAr_model{margin:0;padding:8px 10px;border:0.5px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3)}",
41
+ ".dshAr_modelHead{display:flex;gap:8px;align-items:baseline;cursor:pointer;color:var(--dsw-alias-label-primary);font:var(--dsw-font-xs-13)}",
43
42
  ".dshAr_modelName{font-weight:600}",
44
- ".dshAr_modelId{overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code);font-size:11px;line-height:17px}",
43
+ ".dshAr_modelId{overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary);font:var(--dsw-font-xxxs-11);font-family:var(--ds-font-family-code)}",
45
44
  ".dshAr_fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:10px}",
46
45
  ".dshAr_field{display:flex;flex-direction:column;gap:3px;min-width:0}",
47
- ".dshAr_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:15px}",
48
- ".dshAr_input{min-width:0;padding:4px 7px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px}",
49
- ".dshAr_input:disabled{opacity:.55}",
46
+ ".dshAr_fieldLabel{color:var(--dsw-alias-label-secondary);font:var(--dsw-font-xxxs-11)}",
50
47
  ".dshAr_inputs{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:10px}",
51
- ".dshAr_toggle{display:flex;gap:5px;align-items:center;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
52
48
  ".dshAr_efforts{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px;margin-top:10px}",
53
- ".dshAr_effort{display:flex;gap:6px;align-items:center;color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}",
54
- ".dshAr_effortLevel{min-width:38px;color:var(--dsw-alias-label-secondary)}",
55
- ".dshAr_effortWire{flex:1 1 60px}",
49
+ ".dshAr_effort{display:flex;gap:6px;align-items:center;min-width:0}",
50
+ ".dshAr_effortLevel{min-width:76px}",
51
+ ".dshAr_effortWire{flex:1 1 60px;min-width:0}",
56
52
  ".dshAr_modelActions{display:flex;justify-content:flex-end;margin-top:10px}",
53
+ "@media (prefers-reduced-motion: reduce){.dshAr_choice{transition:none}}",
57
54
  ].join("");
58
55
  const CSS_TAG_ID = "dsh-llm-agentrouter/EndpointCard.css";
59
56
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG_ID) + "]") === null) {
@@ -108,6 +105,7 @@ window.__ModuleLoader__.load({
108
105
  modalityText: "文本",
109
106
  modalityImage: "图片",
110
107
  wireNone: "留空 = 不发送",
108
+ wireLabel: "「%s」档位发送给中转站的取值",
111
109
  levelOff: "off",
112
110
  levelMinimal: "minimal",
113
111
  levelLow: "low",
@@ -164,6 +162,7 @@ window.__ModuleLoader__.load({
164
162
  modalityText: "Text",
165
163
  modalityImage: "Image",
166
164
  wireNone: "empty = send nothing",
165
+ wireLabel: "Wire value sent for the %s level",
167
166
  levelOff: "off",
168
167
  levelMinimal: "minimal",
169
168
  levelLow: "low",
@@ -186,9 +185,9 @@ window.__ModuleLoader__.load({
186
185
  const FIELD = "endpoint";
187
186
 
188
187
  /**
189
- * Read the endpoint from a resolved section, falling back to the default the
190
- * schema documents when the section is not readable yet.
191
- * @param {unknown} value - the scope snapshot's resolved value.
188
+ * Read the endpoint from a resolved entry, falling back to the default the
189
+ * schema documents when the entry is not readable yet.
190
+ * @param {unknown} value - the form snapshot's resolved value.
192
191
  * @returns {string} an endpoint key.
193
192
  */
194
193
  function endpointOf(value) {
@@ -199,7 +198,7 @@ window.__ModuleLoader__.load({
199
198
  /**
200
199
  * Read the host table from a resolved section so each choice can show the
201
200
  * origin it actually means.
202
- * @param {unknown} value - the scope snapshot's resolved value.
201
+ * @param {unknown} value - the form snapshot's resolved value.
203
202
  * @returns {Record<string, string>} host per endpoint key.
204
203
  */
205
204
  function hostsOf(value) {
@@ -208,23 +207,23 @@ window.__ModuleLoader__.load({
208
207
  }
209
208
 
210
209
  /**
211
- * The relay card: the endpoint radio group over this plugin's own settings
212
- * namespace, above the model list the route in `llm-pi-ai` actually serves.
210
+ * The relay card: the endpoint radio group over this plugin's own profile
211
+ * entry config, above the model list the route in `llm-pi-ai` actually serves.
213
212
  *
214
213
  * A choice writes immediately rather than staging behind a Save button. The
215
214
  * namespace has exactly one user-facing field and the write is reversible in
216
215
  * one click, so a staged form would add a step without protecting anything —
217
- * and `scope.set` already fences the write with the revision it read. The
218
- * two halves are independent: each reads and writes its own namespace, so a
219
- * deployment that exposes only one of them still renders the other.
216
+ * and the form's own `set` already fences the write with the revision it read.
217
+ * The two halves are independent: each reads and writes its own entry's form,
218
+ * so a deployment that exposes only one of them still renders the other.
220
219
  *
221
- * @param {object} props - the injected scope faces plus the bound translator.
220
+ * @param {object} props - the injected config forms plus the bound translator.
222
221
  * @returns {JSX.Element} the card.
223
222
  */
224
- function EndpointCard({ scope, t, routeScope, operations }) {
223
+ function EndpointCard({ form, t, routeForm, operations }) {
225
224
  const snapshot = react.useSyncExternalStore(
226
- react.useCallback((listener) => scope.subscribe(listener), [scope]),
227
- () => scope.getSnapshot(),
225
+ react.useCallback((listener) => form.subscribe(listener), [form]),
226
+ () => form.getSnapshot(),
228
227
  );
229
228
  const [pending, setPending] = react.useState(null);
230
229
  const [failed, setFailed] = react.useState(false);
@@ -239,10 +238,14 @@ window.__ModuleLoader__.load({
239
238
  setPending(endpoint);
240
239
  setFailed(false);
241
240
  Promise.resolve()
242
- .then(() => scope.set(FIELD, endpoint))
241
+ .then(() => form.set(FIELD, endpoint))
243
242
  .then(
244
- () => {
243
+ // The form answers whether the Host accepted the write and runs its own
244
+ // recovery read; a refusal and a transport rejection are one outcome here:
245
+ // the choice did not land, so the card says so and leaves the section alone.
246
+ (accepted) => {
245
247
  setPending(null);
248
+ if (!accepted) setFailed(true);
246
249
  },
247
250
  () => {
248
251
  setPending(null);
@@ -314,7 +317,7 @@ window.__ModuleLoader__.load({
314
317
  role: shown.kind === "error" ? "alert" : "status",
315
318
  children: shown.text,
316
319
  }),
317
- routeScope === undefined ? null : jsx.jsx(ModelList, { routeScope, operations, t }),
320
+ routeForm === undefined ? null : jsx.jsx(ModelList, { routeForm, operations, t }),
318
321
  ],
319
322
  });
320
323
  }
@@ -322,12 +325,14 @@ window.__ModuleLoader__.load({
322
325
 
323
326
  //#region model list
324
327
  /**
325
- * The pi-ai settings namespace the relay route is declared in. This plugin's
326
- * model list is NOT its own preference: it is the `agentrouter` route's
327
- * `models` array, the same data the kernel's Models page edits, which is why
328
- * editing it is what changes the model picker. Spelled rather than imported,
329
- * for the same reason as SETTINGS_NS in the plugin region below: a browser
330
- * bundle must not depend on a Host package.
328
+ * The profile entry id the relay route is declared in — `llm-pi-ai`'s own
329
+ * patch row, which the adapter also registers as the settings namespace of
330
+ * its configurable providers (`settingsPath: ["providers", route]`). This
331
+ * plugin's model list is NOT its own preference: it is the `agentrouter`
332
+ * route's `models` array, the same entry and the same path the kernel's
333
+ * Models page edits, which is why editing it is what changes the model
334
+ * picker. Spelled rather than imported, for the same reason as SETTINGS_NS in
335
+ * the plugin region below: a browser bundle must not depend on a Host package.
331
336
  */
332
337
  const PI_AI_SETTINGS_NS = "llm-pi-ai";
333
338
  /** The route key inside that namespace this plugin owns. */
@@ -560,8 +565,7 @@ window.__ModuleLoader__.load({
560
565
  className: "dshAr_field",
561
566
  children: [
562
567
  jsx.jsx("span", { className: "dshAr_fieldLabel", children: t(label) }),
563
- jsx.jsx("input", {
564
- className: "dshAr_input",
568
+ jsx.jsx(Input, {
565
569
  type: "text",
566
570
  value: row[name],
567
571
  disabled: readOnly,
@@ -605,25 +609,21 @@ window.__ModuleLoader__.load({
605
609
  className: "dshAr_inputs",
606
610
  children: [
607
611
  jsx.jsx("span", { className: "dshAr_fieldLabel", children: t("fieldInput") }),
608
- ...MODALITIES.map((modality) => jsx.jsxs(
609
- "label",
612
+ ...MODALITIES.map((modality) => jsx.jsx(
613
+ "span",
610
614
  {
611
- className: "dshAr_toggle",
612
- children: [
613
- jsx.jsx("input", {
614
- type: "checkbox",
615
- checked: row.input.includes(modality),
616
- disabled: readOnly,
617
- "data-model": String(index),
618
- "data-modality": modality,
619
- onChange: () => onPatch(index, {
620
- input: row.input.includes(modality)
621
- ? row.input.filter((entry) => entry !== modality)
622
- : [...row.input, modality],
623
- }),
615
+ "data-modality": modality,
616
+ "data-model": String(index),
617
+ children: jsx.jsx(Checkbox, {
618
+ checked: row.input.includes(modality),
619
+ disabled: readOnly,
620
+ label: t(MODALITY_LABELS[modality]),
621
+ onChange: (next) => onPatch(index, {
622
+ input: next
623
+ ? [...row.input, modality]
624
+ : row.input.filter((entry) => entry !== modality),
624
625
  }),
625
- jsx.jsx("span", { children: t(MODALITY_LABELS[modality]) }),
626
- ],
626
+ }),
627
627
  },
628
628
  modality,
629
629
  )),
@@ -635,30 +635,28 @@ window.__ModuleLoader__.load({
635
635
  children: EFFORT_LEVELS.map((level) => {
636
636
  const entry = row.efforts.find((candidate) => candidate.level === level)
637
637
  ?? { level, on: false, wire: "" };
638
+ const levelKey = "level" + level.charAt(0).toUpperCase() + level.slice(1);
638
639
  return jsx.jsxs(
639
- "label",
640
+ "div",
640
641
  {
641
642
  className: "dshAr_effort",
642
643
  "data-level": level,
644
+ "data-model": String(index),
643
645
  children: [
644
- jsx.jsx("input", {
645
- type: "checkbox",
646
+ jsx.jsx(Checkbox, {
646
647
  checked: entry.on,
647
648
  disabled: readOnly,
648
- "data-model": String(index),
649
- "data-effort": level,
650
- onChange: () => onEffort(index, level, { on: !entry.on }),
651
- }),
652
- jsx.jsx("span", {
653
649
  className: "dshAr_effortLevel",
654
- children: t("level" + level.charAt(0).toUpperCase() + level.slice(1)),
650
+ label: t(levelKey),
651
+ onChange: (next) => onEffort(index, level, { on: next }),
655
652
  }),
656
- jsx.jsx("input", {
657
- className: "dshAr_input dshAr_effortWire",
653
+ jsx.jsx(Input, {
654
+ className: "dshAr_effortWire",
658
655
  type: "text",
659
656
  value: entry.wire,
660
657
  placeholder: level === "off" ? t("wireNone") : level,
661
658
  disabled: readOnly || !entry.on,
659
+ "aria-label": fill(t("wireLabel"), [t(levelKey)]),
662
660
  "data-model": String(index),
663
661
  "data-wire": level,
664
662
  onChange: (event) => onEffort(index, level, { wire: event.target.value }),
@@ -671,9 +669,9 @@ window.__ModuleLoader__.load({
671
669
  }),
672
670
  jsx.jsx("div", {
673
671
  className: "dshAr_modelActions",
674
- children: jsx.jsx("button", {
675
- type: "button",
676
- className: "dshAr_button",
672
+ children: jsx.jsx(Button, {
673
+ variant: "ghost",
674
+ size: "sm",
677
675
  "data-action": "remove",
678
676
  disabled: readOnly,
679
677
  onClick: () => onRemove(index),
@@ -689,8 +687,8 @@ window.__ModuleLoader__.load({
689
687
  }
690
688
 
691
689
  /**
692
- * Store faces for a caller that injects no Host operations — an older Host,
693
- * or a test rendering this component directly.
690
+ * Store faces for a caller that injects no Host operations — a test rendering
691
+ * this component directly, or a registration whose optional Remote is absent.
694
692
  */
695
693
  const NO_OPERATIONS_STORE = { subscribe: () => () => {}, snapshot: () => 0 };
696
694
 
@@ -699,24 +697,25 @@ window.__ModuleLoader__.load({
699
697
  * serves, the 更新 action that asks the relay what it currently offers, and
700
698
  * the write that puts the result back on the route.
701
699
  *
702
- * Reads come from the shared settings mirror through the scope bound to
703
- * `llm-pi-ai`, so the card and the kernel's Models page can never disagree
704
- * about the route. Writes go through the Remote call the operations were
705
- * built with, NOT through `scope.set`: the mirror's write path reports a
706
- * refusal by silently reloading, while this card has to show the Host's own
707
- * diagnostic — a stale revision, or a payload the adapter's schema refused.
700
+ * Reads come from the shared config form of the `llm-pi-ai` profile entry, so
701
+ * the card and the kernel's Models page can never disagree about the route.
702
+ * Writes go through the Remote call the operations were built with, NOT
703
+ * through the form's `mutate`: that path answers only whether the Host
704
+ * accepted, while this card has to show the Host's own diagnostic — a stale
705
+ * revision, or a payload the adapter's schema refused. The kernel's own
706
+ * Models page writes the same namespace the same way.
708
707
  *
709
708
  * An edit lives in this component until 保存; 重置 drops the draft and, when
710
709
  * the user layer owns the list, removes that override so the composition's
711
710
  * hand-declared list serves again.
712
711
  *
713
- * @param {object} props - the route scope, the Host operations, and `t`.
712
+ * @param {object} props - the route form, the Host operations, and `t`.
714
713
  * @returns {JSX.Element} the model list.
715
714
  */
716
- function ModelList({ routeScope, operations, t }) {
715
+ function ModelList({ routeForm, operations, t }) {
717
716
  const snapshot = react.useSyncExternalStore(
718
- react.useCallback((listener) => routeScope.subscribe(listener), [routeScope]),
719
- () => routeScope.getSnapshot(),
717
+ react.useCallback((listener) => routeForm.subscribe(listener), [routeForm]),
718
+ () => routeForm.getSnapshot(),
720
719
  );
721
720
  const ops = operations ?? {};
722
721
  // The optional Remote faces mount after this plugin activates, so the
@@ -869,9 +868,9 @@ window.__ModuleLoader__.load({
869
868
  const shown = status === null
870
869
  ? { kind: "info", text: "" }
871
870
  : { kind: status.kind, text: fill(t(status.key), status.values) };
872
- const action = (name, label, disabled, onClick) => jsx.jsx("button", {
873
- type: "button",
874
- className: "dshAr_button",
871
+ const action = (name, label, disabled, onClick) => jsx.jsx(Button, {
872
+ variant: name === "save" ? "primary" : "outline",
873
+ size: "sm",
875
874
  "data-action": name,
876
875
  disabled,
877
876
  onClick,
@@ -985,13 +984,11 @@ window.__ModuleLoader__.load({
985
984
  position: "relative",
986
985
  padding: "7px 1px 11px",
987
986
  cursor: "pointer",
988
- font: "inherit",
989
- fontSize: "13px",
990
- lineHeight: "20px",
991
- color: "var(--dsw-alias-label-tertiary, inherit)",
987
+ font: "var(--dsw-font-xs-13)",
988
+ color: "var(--dsw-alias-label-tertiary)",
992
989
  };
993
990
  const TAB_ACTIVE_STYLE = Object.assign({}, TAB_STYLE, {
994
- color: "var(--dsw-alias-label-primary, inherit)",
991
+ color: "var(--dsw-alias-label-primary)",
995
992
  });
996
993
  /** The kernel's own tab marker: a 2px rounded bar under the active label. */
997
994
  const TAB_MARKER_STYLE = {
@@ -1001,7 +998,7 @@ window.__ModuleLoader__.load({
1001
998
  bottom: 0,
1002
999
  height: "2px",
1003
1000
  borderRadius: "2px 2px 0 0",
1004
- background: "var(--dsw-alias-label-primary, currentColor)",
1001
+ background: "var(--dsw-alias-label-primary)",
1005
1002
  };
1006
1003
  /**
1007
1004
  * Panels stay mounted (hidden) so each card keeps its local state. `display:
@@ -1144,27 +1141,30 @@ window.__ModuleLoader__.load({
1144
1141
  /** Dictionary namespace owned by this plugin. */
1145
1142
  const NS = "settings.agentrouter";
1146
1143
  /**
1147
- * Settings namespace the Host half registers. Spelled rather than imported:
1148
- * a browser bundle must not depend on a Host package, so both halves state
1149
- * the same literal (the Host's is `AGENTROUTER_SETTINGS_NAMESPACE`).
1144
+ * This plugin's profile entry id — its settings namespace, and the key its
1145
+ * config form is addressed by. Spelled rather than imported: a browser bundle
1146
+ * must not depend on a Host package, so both halves state the same literal
1147
+ * (the Host's is `AGENTROUTER_SETTINGS_NAMESPACE`).
1150
1148
  */
1151
1149
  const SETTINGS_NS = "llm-agentrouter";
1152
1150
  /** Services this plugin needs from the browser runtime. */
1153
- const inject = ["slots", "locale", "settingsScope"];
1151
+ const inject = ["slots", "locale", "configForms"];
1154
1152
 
1155
1153
  /**
1156
1154
  * Register the endpoint card inside the shared 「API中转」 page, which this
1157
- * plugin carries the shell for and claims when it activates first.
1155
+ * plugin carries the shell for and claims when it activates first, and bind
1156
+ * the two config forms it reads (this plugin's own entry, and the adapter's
1157
+ * route) out of the shared configuration forms service.
1158
1158
  * @param {object} ctx - the browser plugin context.
1159
1159
  */
1160
1160
  function apply(ctx) {
1161
1161
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "llm-agentrouter: dictionaries");
1162
1162
  const t = ctx.locale.bind(NS);
1163
- const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
1164
- // The model list is not this plugin's own section: it is the `agentrouter`
1165
- // route inside the adapter's namespace, the same document the kernel's
1166
- // Models page edits, so the card derives from that namespace's mirror.
1167
- const routeScope = ctx.settingsScope.bind({ namespace: PI_AI_SETTINGS_NS });
1163
+ const form = ctx.configForms.get(SETTINGS_NS);
1164
+ // The model list is not this plugin's own entry: it is the `agentrouter`
1165
+ // route inside the adapter's entry, the same document the kernel's Models
1166
+ // page edits, so the card derives from that entry's shared config form.
1167
+ const routeForm = ctx.configForms.get(PI_AI_SETTINGS_NS);
1168
1168
  // Both Host faces are OPTIONAL reads: a deployment whose Client assembly
1169
1169
  // mounts neither still shows the endpoint switch, and the model list
1170
1170
  // degrades to read-only with the reason rendered instead of going blank.
@@ -1230,14 +1230,14 @@ window.__ModuleLoader__.load({
1230
1230
  ctx.slots.register(
1231
1231
  {
1232
1232
  name: RELAY_ITEM_SLOT,
1233
- // id = the Host settings namespace; the shared page filters its
1233
+ // id = this plugin's profile entry id; the shared page filters its
1234
1234
  // panels by this id, so it must be the plugin's own key.
1235
1235
  id: SETTINGS_NS,
1236
1236
  order: 20,
1237
1237
  // Rendered as the card's tab title inside the shared page.
1238
1238
  label: () => t("title"),
1239
1239
  locale: NS,
1240
- inject: () => ({ scope, t, routeScope, operations }),
1240
+ inject: () => ({ form, t, routeForm, operations }),
1241
1241
  },
1242
1242
  EndpointCard,
1243
1243
  ),
package/lib/index.js CHANGED
@@ -38,10 +38,11 @@ import z from '@deepseek-ai/schemastery'
38
38
  const name = 'llm-agentrouter'
39
39
 
40
40
  /**
41
- * Settings namespace this plugin owns.
41
+ * This plugin's profile entry id, which is also its settings namespace and the
42
+ * license key its configuration form is addressed by.
42
43
  *
43
- * It is also the key the browser half registers its card under, so the two
44
- * halves meet here without either importing the other.
44
+ * The id is declared by the bundle patch (`cordis.patch.yml`), and the browser
45
+ * half addresses the same literal without importing this module.
45
46
  */
46
47
  const AGENTROUTER_SETTINGS_NAMESPACE = 'llm-agentrouter'
47
48
 
@@ -53,6 +54,7 @@ const Config = z.object({
53
54
  endpoint: z
54
55
  .union([z.const('cn'), z.const('intl')])
55
56
  .default('cn')
57
+ .volatile()
56
58
  .description('relay endpoint requests are sent to: cn (domestic) or intl (international)'),
57
59
  /**
58
60
  * Host per endpoint key. Configuration rather than a constant so a moved
@@ -61,6 +63,7 @@ const Config = z.object({
61
63
  endpoints: z
62
64
  .dict(z.string())
63
65
  .default({ cn: 'ps.air-outer.com', intl: 'agentrouter.org' })
66
+ .volatile()
64
67
  .description('host for each endpoint key'),
65
68
  /**
66
69
  * The host the route's `baseURL` names. Requests to it are rewritten to the
@@ -70,6 +73,7 @@ const Config = z.object({
70
73
  sentinel: z
71
74
  .string()
72
75
  .default('relay.agentrouter.internal')
76
+ .volatile()
73
77
  .description('placeholder host in the route baseURL that the fence replaces with the selected endpoint'),
74
78
  /**
75
79
  * The exact User-Agent the relay accepts. It is the whole authentication of
@@ -79,6 +83,7 @@ const Config = z.object({
79
83
  userAgent: z
80
84
  .string()
81
85
  .default('claude-cli/2.1.161 (external, cli)')
86
+ .volatile()
82
87
  .description('User-Agent value sent to the relay in place of the harness attribution'),
83
88
  /** Report the installed fence once on activation. */
84
89
  announce: z.boolean().default(true),
@@ -91,9 +96,19 @@ const Config = z.object({
91
96
  quotaHint: z
92
97
  .string()
93
98
  .default('Claude / GPT 本批额度已用完,请等待下一批投放。')
99
+ .volatile()
94
100
  .description('text appended to relay 402 quota errors'),
95
101
  })
96
102
 
103
+ /**
104
+ * Every field the fence reads carries `.volatile()` and is therefore a live
105
+ * reference rather than a snapshot: the settings plane writes the reference in
106
+ * place, the next request reads the new value, and no reload intervenes. That
107
+ * is also what makes these fields the ones the schema-derived config form
108
+ * shows and accepts writes to (`ctx.settings.describe`/`mutate`). `announce`
109
+ * is deliberately plain: it is read once, on activation.
110
+ */
111
+
97
112
  /**
98
113
  * Resolve a `fetch` argument to its URL without consuming a request body.
99
114
  * @param {unknown} input - the first `fetch` argument.
@@ -122,12 +137,13 @@ function urlOf(input) {
122
137
  */
123
138
  function routingTable(config) {
124
139
  const table = new Map()
125
- const selected = config.endpoints[config.endpoint]
126
- const sentinel = config.sentinel.trim().toLowerCase()
140
+ const endpoints = config.endpoints.get()
141
+ const selected = endpoints[config.endpoint.get()]
142
+ const sentinel = config.sentinel.get().trim().toLowerCase()
127
143
  if (sentinel.length > 0 && typeof selected === 'string' && selected.trim().length > 0) {
128
144
  table.set(sentinel, selected.trim())
129
145
  }
130
- for (const host of Object.values(config.endpoints)) {
146
+ for (const host of Object.values(endpoints)) {
131
147
  if (typeof host !== 'string') continue
132
148
  const trimmed = host.trim()
133
149
  if (trimmed.length > 0) table.set(trimmed.toLowerCase(), trimmed)
@@ -151,21 +167,22 @@ function routingTable(config) {
151
167
  * rebuilds it as `application/json`. Every other response is left untouched.
152
168
  *
153
169
  * @param {typeof fetch} native - the fetch this wrapper delegates to.
154
- * @param {() => ReturnType<typeof Config>} current - reads the live section.
170
+ * @param {ReturnType<typeof Config>} config - the entry configuration; every
171
+ * field read here is a `.volatile()` reference, so the value is the one the
172
+ * settings plane last wrote, not the one activation resolved.
155
173
  * @returns {typeof fetch} the wrapping fetch.
156
174
  */
157
- function fenceFetch(native, current) {
175
+ function fenceFetch(native, config) {
158
176
  return function agentRouterFetch(input, init) {
159
177
  const url = urlOf(input)
160
178
  if (url === undefined) return native(input, init)
161
179
 
162
- const config = current()
163
180
  const destination = routingTable(config).get(url.host.toLowerCase())
164
181
  if (destination === undefined) return native(input, init)
165
182
 
166
183
  const isRequest = typeof Request === 'function' && input instanceof Request
167
184
  const headers = new Headers(init?.headers ?? (isRequest ? input.headers : undefined))
168
- headers.set('user-agent', config.userAgent)
185
+ headers.set('user-agent', config.userAgent.get())
169
186
 
170
187
  // Same host means the sentinel was not involved: rewrite the header only,
171
188
  // and leave the caller's own URL object or Request identity alone.
@@ -185,7 +202,8 @@ function fenceFetch(native, current) {
185
202
  // as an event stream, which provider SDKs otherwise surface as an opaque
186
203
  // transport failure. Annotate those with the configured hint; every other
187
204
  // response passes through untouched.
188
- return config.quotaHint === '' ? pending : annotateQuotaError(pending, config.quotaHint)
205
+ const hint = config.quotaHint.get()
206
+ return hint === '' ? pending : annotateQuotaError(pending, hint)
189
207
  }
190
208
  }
191
209
 
@@ -257,22 +275,18 @@ function requestInitOf(request) {
257
275
  }
258
276
 
259
277
  /**
260
- * Install the relay fence and expose its endpoint choice as a settings section.
278
+ * Install the relay fence and declare how this plugin's configuration is edited.
261
279
  * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin's context.
262
280
  * @param {ReturnType<typeof Config>} config - resolved entry configuration.
263
281
  */
264
282
  function apply(ctx, config) {
265
- // The section is the authority while a settings service exists; the composed
266
- // entry is the fallback, so the fence works identically with no settings
267
- // plane at all (headless, or before the service mounts).
268
- let current = () => config
269
- ctx.inject(['settings'], (settingsCtx) => {
270
- settingsCtx.settings.installSection(ctx, AGENTROUTER_SETTINGS_NAMESPACE, Config, config, {
271
- setSource: (source) => {
272
- current = source
273
- },
274
- onChange: () => {},
275
- })
283
+ // The settings plane derives this plugin's form from the Config schema above
284
+ // and writes it back into the same `.volatile()` references the fence reads.
285
+ // `auto: false` because the endpoint card owns this plugin's UI (lib/client.js);
286
+ // the registration is optional, so a deployment with no settings service at all
287
+ // (headless, or before the service mounts) keeps the composed entry as-is.
288
+ ctx.inject(['settings'], (child) => {
289
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
276
290
  })
277
291
 
278
292
  ctx.effect(() => {
@@ -281,7 +295,7 @@ function apply(ctx, config) {
281
295
  ctx.logger.warn('llm-agentrouter: no global fetch to fence; relay requests will be unroutable and rejected')
282
296
  return () => {}
283
297
  }
284
- const fenced = fenceFetch(previous, () => current())
298
+ const fenced = fenceFetch(previous, config)
285
299
  globalThis.fetch = fenced
286
300
  return () => {
287
301
  // Restore only what this plugin installed: a later wrapper layered on top
@@ -293,10 +307,10 @@ function apply(ctx, config) {
293
307
  if (config.announce) {
294
308
  const table = routingTable(config)
295
309
  ctx.logger.info(
296
- 'llm-agentrouter: endpoint %c (%c), sending %c',
297
- config.endpoint,
298
- table.get(config.sentinel.trim().toLowerCase()) ?? 'unrouted',
299
- config.userAgent,
310
+ 'llm-agentrouter: endpoint %s (%s), sending %s',
311
+ config.endpoint.get(),
312
+ table.get(config.sentinel.get().trim().toLowerCase()) ?? 'unrouted',
313
+ config.userAgent.get(),
300
314
  )
301
315
  }
302
316
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lynn123411/dsh-llm-agentrouter",
3
- "version": "0.3.0",
4
- "description": "AgentRouter 中转聚合:一条 pi-ai 路由承载多模型(设置卡片内可从中转站拉取、逐个编辑模型与参数)+ 国内/国际端点一键切换 + 出站请求 User-Agent/402-配额改写围栏。aqiu817/dsh-llm-agentrouter 分叉,适配 0.1.2-alpha.5 起新 settings/slots API,已在 0.1.5-rc.1 逐项实证兼容。",
3
+ "version": "0.4.0",
4
+ "description": "AgentRouter 中转聚合:一条 pi-ai 路由承载多模型(设置卡片内可从中转站拉取、逐个编辑模型与参数)+ 国内/国际端点一键切换 + 出站请求 User-Agent/402-配额改写围栏。模型列表写入内核「设置 - 模型」页所编辑的同一份 llm-pi-ai 路由数据。",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh-plugin",
@@ -33,6 +33,7 @@
33
33
  "client": {
34
34
  "inject": [
35
35
  "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-primitives",
36
37
  "@deepseek-ai/dsh-client-ui-settings"
37
38
  ],
38
39
  "platform": "web"
@@ -41,7 +42,6 @@
41
42
  "files": [
42
43
  "lib/",
43
44
  "cordis.patch.yml",
44
- "LICENSE",
45
45
  "README.md"
46
46
  ],
47
47
  "publishConfig": {
@@ -51,13 +51,14 @@
51
51
  "test": "node --test test/*.test.mjs"
52
52
  },
53
53
  "engines": {
54
- "node": ">=22"
54
+ "node": ">=22",
55
+ "dsh": "0.1.7-rc.2"
55
56
  },
56
57
  "dependencies": {
57
- "@deepseek-ai/schemastery": "^3.18.2"
58
+ "@deepseek-ai/schemastery": "^3.18.4"
58
59
  },
59
60
  "peerDependencies": {
60
- "@deepseek-ai/cordis": "^4.0.2"
61
+ "@deepseek-ai/cordis": "^4.0.4"
61
62
  },
62
63
  "peerDependenciesMeta": {
63
64
  "@deepseek-ai/cordis": {
package/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 aqiu817
4
- Copyright (c) 2026 lynn123411 (alpha.5 compatibility fork: settings/slots API migration, package rename)
5
-
6
- Permission is hereby granted, free of charge, to any person obtaining a copy
7
- of this software and associated documentation files (the "Software"), to deal
8
- in the Software without restriction, including without limitation the rights
9
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
- copies of the Software, and to permit persons to whom the Software is
11
- furnished to do so, subject to the following conditions:
12
-
13
- The above copyright notice and this permission notice shall be included in all
14
- copies or substantial portions of the Software.
15
-
16
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
- SOFTWARE.