@lynn123411/dsh-llm-agentrouter 0.2.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/LICENSE ADDED
@@ -0,0 +1,22 @@
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.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @lynn123411/dsh-llm-agentrouter
2
+
3
+ AgentRouter 中转聚合插件:把同一家中转的多个模型收拢为一条 pi-ai 路由(模型选择器只出现一个 AgentRouter 分组),国内 / 国际端点在「设置 - 插件 - AgentRouter 中转站」一键切换、下一请求即生效,外加出站请求的 User-Agent 改写与 402 配额耗尽提示围栏。分叉自 `aqiu817/dsh-llm-agentrouter`,适配 DSH `0.1.2-alpha.5` 新 settings/slots API。
4
+
5
+ ## 特性
6
+
7
+ - **单路由多模型**:Claude Opus 5、GPT-5.6-sol 等走同一 `agentrouter` 路由,`reasoningEffort` 档位与兼容开关按实测探针手写声明,模型列表不翻倍。
8
+ - **端点一键切换**:设置卡片两个大选项(国内端点 / 国际端点,附带真实 host 展示),点击即写即生效,无需重启;API Key 只存 `$DSH_HOME/.credentials.yaml` 的 `AGENTROUTER_API_KEY` 引用。
9
+ - **请求围栏**:重写 relay 要求的 `User-Agent`(适配器强制署名下沉到 `fetch` 层替换),哨兵 host(`.internal` 不可解析)兜底防裸奔,402 配额耗尽的 JSON 错误体改写为可读提示。
10
+ - **alpha.5 兼容**:宿主改用 `ctx.settings.installSection` + `ctx.inject(['settings'])`,客户端卡片改挂 `settings.plugins.tab`;沿用 `llm-agentrouter` 设置命名空间,老配置无缝继承。
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ dsh plugin --profile web add @lynn123411/dsh-llm-agentrouter
16
+ ```
@@ -0,0 +1,147 @@
1
+ # dsh-llm-agentrouter bundle patch — the AgentRouter relay as ONE provider route
2
+ # whose endpoint is a runtime choice, plus the fence that makes both possible.
3
+ #
4
+ # Applied after the dsh-base / dsh-web-app bundle layers, so the `llm-pi-ai`
5
+ # row below overrides the dormant zero-route entry dsh-base mounts.
6
+ #
7
+ # WHY ONE ROUTE AND NOT TWO
8
+ # The relay serves the same three models at a domestic and an international
9
+ # endpoint; only the origin differs. Declaring a route per endpoint put every
10
+ # model in the picker twice and made "which endpoint" a choice the user had to
11
+ # re-make on every model switch. It is not a model property — it is one
12
+ # deployment-wide setting, so it lives in this plugin's own settings section and
13
+ # the picker shows one AgentRouter group.
14
+ #
15
+ # WHY THE BASE URL IS A SENTINEL
16
+ # `baseURL` is resolved once per request by the adapter, but it cannot read this
17
+ # plugin's endpoint choice — the two live in different namespaces. So the route
18
+ # addresses a host that deliberately does not exist, and the fence rewrites it
19
+ # to the chosen endpoint on the way out (`.internal` is reserved for exactly
20
+ # this, so an unfenced request fails to resolve instead of reaching anything
21
+ # real). The fence has to be in the request path regardless: the relay
22
+ # authenticates its client by User-Agent, and the adapter strips profile headers
23
+ # colliding with its mandatory attribution. See lib/index.js.
24
+ #
25
+ # CREDENTIALS
26
+ # `apiKeyEnv` is a credential *reference* resolved per request; the secret lives
27
+ # in $DSH_HOME/.credentials.yaml (or the environment), never in this file. Do
28
+ # NOT move a key into `headers`: that dict is returned verbatim by the adapter's
29
+ # `describe()` and rendered by the Models settings page. One reference serves
30
+ # both endpoints because it is one relay account.
31
+ #
32
+ # MODEL CATALOG
33
+ # pi-ai's installed catalog describes all three models, but under their vendor
34
+ # providers (`anthropic`, `openai`) and other protocols — Opus via
35
+ # anthropic-messages, gpt-5.6-sol via openai-responses. A hand-declared route
36
+ # inherits nothing, so capacities, reasoning levels, and compat switches are
37
+ # restated here from that catalog, adjusted for Chat Completions.
38
+ #
39
+ # `reasoningEfforts` keys are the levels the picker offers; each value is the
40
+ # wire spelling sent as `reasoning_effort`. All three models were probed against
41
+ # the live relay: every level below answered 200. `off:` (declared, no value)
42
+ # offers Off and sends nothing, which is what an OpenAI-shaped endpoint wants.
43
+ # WHY THE PICKER SAYS NOTHING ABOUT THE ENDPOINT
44
+ # The model picker renders no child slots, so a plugin cannot inject anything
45
+ # into that menu; per group it shows only `displayName`, and per model only the
46
+ # name and an adapter-supplied description a hand-declared pi-ai route has no
47
+ # field for. The group title is therefore the only reachable surface — and it is
48
+ # a name, not a notice, so it stays a name. The endpoint is explained where it is
49
+ # changed: 设置 → 插件 → AgentRouter 中转站.
50
+ - id: llm-pi-ai
51
+ name: '@deepseek-ai/dsh-llm-pi-ai'
52
+ config:
53
+ providers:
54
+ agentrouter:
55
+ displayName: AgentRouter
56
+ apiKeyEnv: AGENTROUTER_API_KEY
57
+ api: openai-completions
58
+ # Rewritten per request by llm-agentrouter to the selected endpoint.
59
+ baseURL: https://relay.agentrouter.internal/v1
60
+ defaultInput:
61
+ - text
62
+ # The relay is not an endpoint pi-ai can recognize, so its detection
63
+ # would address it as OpenAI itself: `developer` system role and
64
+ # `max_completion_tokens`. Probes show it accepts plain `system`,
65
+ # `max_tokens`, top-level `reasoning_effort`, and `strict` tools.
66
+ compat:
67
+ thinkingFormat: openai
68
+ supportsReasoningEffort: true
69
+ supportsDeveloperRole: false
70
+ maxTokensField: max_tokens
71
+ supportsStore: false
72
+ supportsStrictMode: true
73
+ supportsUsageInStreaming: true
74
+ models:
75
+ - id: claude-opus-5
76
+ name: Claude Opus 5
77
+ contextWindow: 1000000
78
+ maxTokens: 128000
79
+ reasoningEfforts: &opus-efforts
80
+ off:
81
+ low: low
82
+ medium: medium
83
+ high: high
84
+ xhigh: xhigh
85
+ max: max
86
+ - id: claude-opus-4-8
87
+ name: Claude Opus 4.8
88
+ contextWindow: 1000000
89
+ maxTokens: 128000
90
+ reasoningEfforts: *opus-efforts
91
+ - id: gpt-5.6-sol
92
+ name: GPT 5.6 Sol
93
+ contextWindow: 272000
94
+ maxTokens: 128000
95
+ reasoningEfforts:
96
+ off:
97
+ minimal: minimal
98
+ low: low
99
+ medium: medium
100
+ high: high
101
+ xhigh: xhigh
102
+ max: max
103
+ # Tiers mirror what the first-party `deepseek-official` route offers for
104
+ # DeepSeek-V4-Flash — off / low / high / max — rather than the relay's
105
+ # full enum. The relay does accept `minimal`, `medium`, and `xhigh` for
106
+ # this model, but the levels a model is *offered* under should match the
107
+ # vendor's own selection; add them here if that ever changes.
108
+ #
109
+ # `off` carries the wire value `none` instead of being left empty, and
110
+ # that is not cosmetic: omitting `reasoning_effort` entirely still
111
+ # returns `reasoning_content` with a nonzero `reasoning_tokens`, so an
112
+ # empty `off:` would offer a switch that does nothing. `none` is the
113
+ # only spelling that actually stops the thinking, and it is the relay's
114
+ # own word — `off` is rejected with a 400 naming the accepted enum.
115
+ - id: deepseek-v4-flash
116
+ name: DeepSeek V4 Flash
117
+ contextWindow: 1000000
118
+ maxTokens: 256000
119
+ reasoningEfforts:
120
+ off: none
121
+ low: low
122
+ high: high
123
+ max: max
124
+ # This model declares no `off` at all, and that is the honest shape
125
+ # rather than an omission: every level outside low / high / max — `none`,
126
+ # `minimal`, `medium`, `xhigh` — is refused with 400 «该模型始终思考,不
127
+ # 支持关闭思考;请使用 low、high 或 max。». It always thinks. Leaving `off`
128
+ # out withholds the level, so the picker never offers a switch the
129
+ # upstream would reject.
130
+ #
131
+ # `maxTokens` is the ceiling the relay states for this model: a larger
132
+ # value comes back as «max_tokens参数非法:限制数值范围[1,131072]».
133
+ - id: glm-5.3
134
+ name: GLM 5.3
135
+ contextWindow: 1000000
136
+ maxTokens: 131072
137
+ reasoningEfforts:
138
+ low: low
139
+ high: high
140
+ max: max
141
+
142
+ # The endpoint switch and the User-Agent fence. Both are one plugin because both
143
+ # are one rewrite of one outbound request, and splitting them would mean two
144
+ # wrappers over the same global for the same hostname.
145
+ - insert:
146
+ - id: llm-agentrouter
147
+ name: '@lynn123411/dsh-llm-agentrouter'
package/lib/client.js ADDED
@@ -0,0 +1,256 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@lynn123411/dsh-llm-agentrouter",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let jsx = require("react/jsx-runtime");
8
+ let react = require("react");
9
+
10
+ //#region styles
11
+ /*
12
+ * Written by hand rather than emitted from a CSS module: the `clientBundle`
13
+ * tsdown preset that produces those hashed class names is not published, so
14
+ * this bundle owns a prefixed class set and injects it once. Every colour is
15
+ * a shell design token, so the card follows the active theme.
16
+ */
17
+ const CSS = [
18
+ ".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_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:hover{background:var(--dsw-alias-interactive-bg-hover)}",
23
+ ".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
+ ".dshAr_choice[data-disabled=true]{cursor:default;opacity:.55}",
25
+ ".dshAr_choice input{margin:3px 0 0}",
26
+ ".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}",
31
+ ".dshAr_status[data-kind=error]{color:var(--dsw-alias-state-error-primary)}",
32
+ ].join("");
33
+ const CSS_TAG_ID = "dsh-llm-agentrouter/EndpointCard.css";
34
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(CSS_TAG_ID) + "]") === null) {
35
+ const tag = document.createElement("style");
36
+ tag.setAttribute("data-plugin-css", CSS_TAG_ID);
37
+ tag.textContent = CSS;
38
+ document.head.append(tag);
39
+ }
40
+ //#endregion
41
+
42
+ //#region locales
43
+ /** Simplified Chinese dictionary and key source of truth. */
44
+ const zh = {
45
+ title: "AgentRouter 中转站",
46
+ description: "选择请求发往的端点。切换后立即生效,无需重启;模型列表不受影响。",
47
+ cn: "国内端点",
48
+ cnHint: "如您区域处于中国大陆则更适用于该端点",
49
+ intl: "国际端点",
50
+ intlHint: "请确保您的网络环境支持访问该端点后切换",
51
+ loading: "正在读取设置…",
52
+ unavailable: "此浏览器无法读取该设置。",
53
+ readOnly: "当前部署不允许写入设置。",
54
+ saving: "正在保存…",
55
+ saved: "已切换到%s。",
56
+ failed: "保存失败,设置未更改。",
57
+ };
58
+ /** English dictionary checked against the Chinese key set. */
59
+ const en = {
60
+ title: "AgentRouter relay",
61
+ description: "Which endpoint requests are sent to. A switch applies to the next request; the model list is unaffected.",
62
+ cn: "Domestic endpoint",
63
+ cnHint: "Better suited if you are in mainland China",
64
+ intl: "International endpoint",
65
+ intlHint: "Make sure your network can reach this endpoint before switching",
66
+ loading: "Reading settings…",
67
+ unavailable: "This browser cannot read these settings.",
68
+ readOnly: "This deployment does not accept settings writes.",
69
+ saving: "Saving…",
70
+ saved: "Switched to %s.",
71
+ failed: "The save failed; the setting is unchanged.",
72
+ };
73
+ //#endregion
74
+
75
+ //#region endpoint card
76
+ /** The endpoint keys this card offers, in display order. */
77
+ const ENDPOINTS = ["cn", "intl"];
78
+ /** Field this card writes inside its namespace. */
79
+ const FIELD = "endpoint";
80
+
81
+ /**
82
+ * Read the endpoint from a resolved section, falling back to the default the
83
+ * schema documents when the section is not readable yet.
84
+ * @param {unknown} value - the scope snapshot's resolved value.
85
+ * @returns {string} an endpoint key.
86
+ */
87
+ function endpointOf(value) {
88
+ const endpoint = typeof value === "object" && value !== null ? value.endpoint : undefined;
89
+ return ENDPOINTS.includes(endpoint) ? endpoint : "cn";
90
+ }
91
+
92
+ /**
93
+ * Read the host table from a resolved section so each choice can show the
94
+ * origin it actually means.
95
+ * @param {unknown} value - the scope snapshot's resolved value.
96
+ * @returns {Record<string, string>} host per endpoint key.
97
+ */
98
+ function hostsOf(value) {
99
+ const hosts = typeof value === "object" && value !== null ? value.endpoints : undefined;
100
+ return typeof hosts === "object" && hosts !== null ? hosts : {};
101
+ }
102
+
103
+ /**
104
+ * The relay endpoint card: one radio group over this plugin's own settings
105
+ * namespace.
106
+ *
107
+ * A choice writes immediately rather than staging behind a Save button. The
108
+ * namespace has exactly one user-facing field and the write is reversible in
109
+ * one click, so a staged form would add a step without protecting anything —
110
+ * and `scope.set` already fences the write with the revision it read.
111
+ *
112
+ * @param {object} props - the injected scope face plus the bound translator.
113
+ * @returns {JSX.Element} the card.
114
+ */
115
+ function EndpointCard({ scope, t }) {
116
+ const snapshot = react.useSyncExternalStore(
117
+ react.useCallback((listener) => scope.subscribe(listener), [scope]),
118
+ () => scope.getSnapshot(),
119
+ );
120
+ const [pending, setPending] = react.useState(null);
121
+ const [failed, setFailed] = react.useState(false);
122
+ const groupName = react.useId();
123
+
124
+ const selected = endpointOf(snapshot.value);
125
+ const hosts = hostsOf(snapshot.value);
126
+ const disabled = snapshot.status !== "ready" || !snapshot.writable || pending !== null;
127
+
128
+ const choose = (endpoint) => {
129
+ if (endpoint === selected || disabled) return;
130
+ setPending(endpoint);
131
+ setFailed(false);
132
+ Promise.resolve()
133
+ .then(() => scope.set(FIELD, endpoint))
134
+ .then(
135
+ () => {
136
+ setPending(null);
137
+ },
138
+ () => {
139
+ setPending(null);
140
+ setFailed(true);
141
+ },
142
+ );
143
+ };
144
+
145
+ const status = () => {
146
+ if (snapshot.status === "loading") return { kind: "info", text: t("loading") };
147
+ if (snapshot.status === "unavailable") return { kind: "error", text: t("unavailable") };
148
+ if (failed) return { kind: "error", text: t("failed") };
149
+ if (pending !== null) return { kind: "info", text: t("saving") };
150
+ if (!snapshot.writable) return { kind: "info", text: t("readOnly") };
151
+ return { kind: "info", text: "" };
152
+ };
153
+ const shown = status();
154
+
155
+ return jsx.jsxs("section", {
156
+ className: "dshAr_card",
157
+ "data-plugin-card": "llm-agentrouter",
158
+ "aria-busy": snapshot.status === "loading" || pending !== null,
159
+ children: [
160
+ jsx.jsx("h3", { children: t("title") }),
161
+ jsx.jsx("p", { className: "dshAr_lead", children: t("description") }),
162
+ jsx.jsxs("fieldset", {
163
+ className: "dshAr_choices",
164
+ children: [
165
+ jsx.jsx("legend", { hidden: true, children: t("title") }),
166
+ ...ENDPOINTS.map((endpoint) => {
167
+ const active = (pending ?? selected) === endpoint;
168
+ return jsx.jsxs(
169
+ "label",
170
+ {
171
+ className: "dshAr_choice",
172
+ "data-endpoint": endpoint,
173
+ "data-selected": active ? "true" : undefined,
174
+ "data-disabled": disabled ? "true" : undefined,
175
+ children: [
176
+ jsx.jsx("input", {
177
+ type: "radio",
178
+ name: groupName,
179
+ value: endpoint,
180
+ checked: active,
181
+ disabled: disabled && !active,
182
+ onChange: () => choose(endpoint),
183
+ }),
184
+ jsx.jsxs("span", {
185
+ className: "dshAr_choiceText",
186
+ children: [
187
+ jsx.jsx("span", { className: "dshAr_choiceTitle", children: t(endpoint) }),
188
+ jsx.jsx("span", {
189
+ className: "dshAr_choiceHost",
190
+ children: hosts[endpoint] ?? "",
191
+ }),
192
+ jsx.jsx("span", { className: "dshAr_choiceHint", children: t(endpoint + "Hint") }),
193
+ ],
194
+ }),
195
+ ],
196
+ },
197
+ endpoint,
198
+ );
199
+ }),
200
+ ],
201
+ }),
202
+ jsx.jsx("p", {
203
+ className: "dshAr_status",
204
+ "data-kind": shown.kind,
205
+ role: shown.kind === "error" ? "alert" : "status",
206
+ children: shown.text,
207
+ }),
208
+ ],
209
+ });
210
+ }
211
+ //#endregion
212
+
213
+ //#region plugin
214
+ /** Dictionary namespace owned by this plugin. */
215
+ const NS = "settings.agentrouter";
216
+ /**
217
+ * Settings namespace the Host half registers. Spelled rather than imported:
218
+ * a browser bundle must not depend on a Host package, so both halves state
219
+ * the same literal (the Host's is `AGENTROUTER_SETTINGS_NAMESPACE`).
220
+ */
221
+ const SETTINGS_NS = "llm-agentrouter";
222
+ /** Services this plugin needs from the browser runtime. */
223
+ const inject = ["slots", "locale", "settingsScope"];
224
+
225
+ /**
226
+ * Register the endpoint card into the plugin configuration tab.
227
+ * @param {object} ctx - the browser plugin context.
228
+ */
229
+ function apply(ctx) {
230
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "llm-agentrouter: dictionaries");
231
+ const t = ctx.locale.bind(NS);
232
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
233
+ ctx.slots.inject("settings.plugins.tab", () =>
234
+ ctx.slots.register(
235
+ {
236
+ name: "settings.plugins.tab",
237
+ id: SETTINGS_NS,
238
+ order: 30,
239
+ label: () => t("title"),
240
+ locale: NS,
241
+ inject: () => ({ scope, t }),
242
+ },
243
+ EndpointCard,
244
+ ),
245
+ );
246
+ }
247
+ //#endregion
248
+
249
+ exports.NS = NS;
250
+ exports.SETTINGS_NS = SETTINGS_NS;
251
+ exports.EndpointCard = EndpointCard;
252
+ exports.apply = apply;
253
+ exports.inject = inject;
254
+ return module.exports;
255
+ }
256
+ });
package/lib/index.js ADDED
@@ -0,0 +1,304 @@
1
+ import z from '@deepseek-ai/schemastery'
2
+
3
+ /**
4
+ * dsh-llm-agentrouter — the runtime half of the bundle of the same name.
5
+ *
6
+ * The bundle's `cordis.patch.yml` declares ONE relay route on the pi-ai adapter
7
+ * (`llm-pi-ai`); everything a request needs — protocol, model catalog, reasoning
8
+ * levels — is configuration there, not code.
9
+ *
10
+ * This plugin owns the two things that configuration cannot express, both of
11
+ * which are rewrites of the same outbound request:
12
+ *
13
+ * 1. **The User-Agent.** The relay authenticates its *client* by `User-Agent`,
14
+ * accepting only the exact value it requires, and `dsh-llm-pi-ai`
15
+ * deliberately strips any profile header colliding with
16
+ * its own attribution before appending
17
+ * `user-agent: deepseek-harness/<version>`. Attribution is mandatory by
18
+ * design ("a white-label deployment may replace it, but not suppress it"), so
19
+ * the substitution has to happen below the adapter — at the `fetch` the
20
+ * provider SDK resolves from the global scope when it builds its client.
21
+ *
22
+ * 2. **The endpoint.** The relay serves the same models at a domestic and an
23
+ * international origin. That is one deployment-wide preference, not a model
24
+ * property, so it is a single setting here rather than a route per endpoint —
25
+ * which would list every model twice in the picker. The adapter cannot read
26
+ * this namespace, so the route's `baseURL` names a deliberately unresolvable
27
+ * sentinel host and the fence substitutes the chosen origin on the way out.
28
+ *
29
+ * The fence is therefore deliberately narrow: it rewrites one header and, for
30
+ * the sentinel alone, one origin; every other request goes to the previous
31
+ * `fetch` untouched. It is installed through `ctx.effect()`, so stopping or
32
+ * reloading the plugin restores the `fetch` it replaced.
33
+ *
34
+ * @module dsh-llm-agentrouter
35
+ */
36
+
37
+ /** Stable Cordis plugin name. */
38
+ const name = 'llm-agentrouter'
39
+
40
+ /**
41
+ * Settings namespace this plugin owns.
42
+ *
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.
45
+ */
46
+ const AGENTROUTER_SETTINGS_NAMESPACE = 'llm-agentrouter'
47
+
48
+ const Config = z.object({
49
+ /**
50
+ * Which relay origin outbound requests are sent to. The whole point of the
51
+ * plugin's settings card: one choice, applied to every model on the route.
52
+ */
53
+ endpoint: z
54
+ .union([z.const('cn'), z.const('intl')])
55
+ .default('cn')
56
+ .description('relay endpoint requests are sent to: cn (domestic) or intl (international)'),
57
+ /**
58
+ * Host per endpoint key. Configuration rather than a constant so a moved
59
+ * origin is a settings edit, not a release.
60
+ */
61
+ endpoints: z
62
+ .dict(z.string())
63
+ .default({ cn: 'ps.air-outer.com', intl: 'agentrouter.org' })
64
+ .description('host for each endpoint key'),
65
+ /**
66
+ * The host the route's `baseURL` names. Requests to it are rewritten to the
67
+ * selected endpoint; it must stay unresolvable so an unfenced request fails
68
+ * loudly instead of reaching some real server (`.internal` is reserved).
69
+ */
70
+ sentinel: z
71
+ .string()
72
+ .default('relay.agentrouter.internal')
73
+ .description('placeholder host in the route baseURL that the fence replaces with the selected endpoint'),
74
+ /**
75
+ * The exact User-Agent the relay accepts. It is the whole authentication of
76
+ * the client (the API key authenticates the account), so it is configuration
77
+ * rather than a constant: the relay may require a different value later.
78
+ */
79
+ userAgent: z
80
+ .string()
81
+ .default('claude-cli/2.1.161 (external, cli)')
82
+ .description('User-Agent value sent to the relay in place of the harness attribution'),
83
+ /** Report the installed fence once on activation. */
84
+ announce: z.boolean().default(true),
85
+ /**
86
+ * Hint appended to a 402 quota error from the relay. The relay answers
87
+ * Claude / GPT budget-pool exhaustion with HTTP 402 and a JSON error body
88
+ * mislabelled as an event stream; the fence keeps that original message and
89
+ * appends this plain-language explanation. Empty string disables it.
90
+ */
91
+ quotaHint: z
92
+ .string()
93
+ .default('Claude / GPT 本批额度已用完,请等待下一批投放。')
94
+ .description('text appended to relay 402 quota errors'),
95
+ })
96
+
97
+ /**
98
+ * Resolve a `fetch` argument to its URL without consuming a request body.
99
+ * @param {unknown} input - the first `fetch` argument.
100
+ * @returns {URL | undefined} the parsed URL, or undefined when it is not one.
101
+ */
102
+ function urlOf(input) {
103
+ try {
104
+ if (typeof input === 'string') return new URL(input)
105
+ if (input instanceof URL) return input
106
+ if (typeof input === 'object' && input !== null && typeof input.url === 'string') return new URL(input.url)
107
+ } catch {
108
+ return undefined
109
+ }
110
+ return undefined
111
+ }
112
+
113
+ /**
114
+ * The hosts one resolved section wants rewritten, and how.
115
+ *
116
+ * Derived per request from the live section so a settings change takes effect on
117
+ * the next call with no reload. Endpoint hosts are included as themselves: a
118
+ * request already addressed to a real relay origin still needs the User-Agent.
119
+ *
120
+ * @param {ReturnType<typeof Config>} config - the resolved section.
121
+ * @returns {Map<string, string>} lowercase source host to destination host.
122
+ */
123
+ function routingTable(config) {
124
+ const table = new Map()
125
+ const selected = config.endpoints[config.endpoint]
126
+ const sentinel = config.sentinel.trim().toLowerCase()
127
+ if (sentinel.length > 0 && typeof selected === 'string' && selected.trim().length > 0) {
128
+ table.set(sentinel, selected.trim())
129
+ }
130
+ for (const host of Object.values(config.endpoints)) {
131
+ if (typeof host !== 'string') continue
132
+ const trimmed = host.trim()
133
+ if (trimmed.length > 0) table.set(trimmed.toLowerCase(), trimmed)
134
+ }
135
+ return table
136
+ }
137
+
138
+ /**
139
+ * Wrap one `fetch` so relay requests carry the relay User-Agent, and sentinel
140
+ * requests additionally go to the selected endpoint.
141
+ *
142
+ * The wrapper never reads or rebuilds a body as a rule: for the shape every
143
+ * provider SDK in this harness uses (url plus an init object) it copies
144
+ * `init` and replaces only its headers. A bare `Request` is re-created with
145
+ * `duplex: 'half'` so a streamed body survives the clone.
146
+ *
147
+ * The one exception is a relay 402 quota-exhaustion response: the body is a
148
+ * small JSON error body mislabelled as `text/event-stream`, which provider
149
+ * SDKs otherwise surface as an opaque transport failure. The fence reads that
150
+ * body, keeps the original error message, appends the configured hint, and
151
+ * rebuilds it as `application/json`. Every other response is left untouched.
152
+ *
153
+ * @param {typeof fetch} native - the fetch this wrapper delegates to.
154
+ * @param {() => ReturnType<typeof Config>} current - reads the live section.
155
+ * @returns {typeof fetch} the wrapping fetch.
156
+ */
157
+ function fenceFetch(native, current) {
158
+ return function agentRouterFetch(input, init) {
159
+ const url = urlOf(input)
160
+ if (url === undefined) return native(input, init)
161
+
162
+ const config = current()
163
+ const destination = routingTable(config).get(url.host.toLowerCase())
164
+ if (destination === undefined) return native(input, init)
165
+
166
+ const isRequest = typeof Request === 'function' && input instanceof Request
167
+ const headers = new Headers(init?.headers ?? (isRequest ? input.headers : undefined))
168
+ headers.set('user-agent', config.userAgent)
169
+
170
+ // Same host means the sentinel was not involved: rewrite the header only,
171
+ // and leave the caller's own URL object or Request identity alone.
172
+ let pending
173
+ if (destination.toLowerCase() === url.host.toLowerCase()) {
174
+ if (isRequest && init === undefined) pending = native(new Request(input, { headers, duplex: 'half' }))
175
+ else pending = native(input, { ...init, headers })
176
+ } else {
177
+ const target = new URL(url)
178
+ target.host = destination
179
+ if (isRequest) pending = native(new Request(target, { ...(init ?? {}), ...requestInitOf(input), headers, duplex: 'half' }))
180
+ else pending = native(target, { ...init, headers })
181
+ }
182
+
183
+ // The one response this wrapper rewrites: the relay answers Claude / GPT
184
+ // budget-pool exhaustion with HTTP 402 and a JSON error body mislabelled
185
+ // as an event stream, which provider SDKs otherwise surface as an opaque
186
+ // transport failure. Annotate those with the configured hint; every other
187
+ // response passes through untouched.
188
+ return config.quotaHint === '' ? pending : annotateQuotaError(pending, config.quotaHint)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The relay answers Claude / GPT budget-pool exhaustion with HTTP 402 and a
194
+ * JSON error body mislabelled as 'text/event-stream'; left alone, provider
195
+ * SDKs surface it as an opaque transport failure rather than the API error it
196
+ * is. This rebuilds such a response as 'application/json', keeping the
197
+ * original error message and appending the hint. Every other response passes
198
+ * through untouched.
199
+ *
200
+ * @param {Promise<Response>} pending - the relay fetch promise.
201
+ * @param {string} hint - text appended to the original error message; empty
202
+ * disables the rewrite (the caller should not call this then).
203
+ * @returns {Promise<Response>} the (possibly rebuilt) response.
204
+ */
205
+ function annotateQuotaError(pending, hint) {
206
+ return pending.then((response) => {
207
+ if (response.status !== 402) return response
208
+ const type = (response.headers.get('content-type') ?? '').toLowerCase()
209
+ if (!type.includes('event-stream') && !type.includes('json')) return response
210
+ return response.text().then((text) => {
211
+ let parsed
212
+ let message
213
+ try {
214
+ parsed = JSON.parse(text)
215
+ message = parsed?.error?.message
216
+ } catch {
217
+ message = undefined
218
+ }
219
+ const headers = new Headers(response.headers)
220
+ if (typeof message !== 'string' || message.length === 0) {
221
+ // Not a JSON error body; hand the bytes back unchanged.
222
+ return new Response(text, { status: response.status, statusText: response.statusText, headers })
223
+ }
224
+ headers.set('content-type', 'application/json')
225
+ // Only annotate quota-exhaustion errors; other 402s pass through with
226
+ // the original body so the provider SDK builds its own error message.
227
+ if (/\b(?:quota|budget)\b/i.test(message)) {
228
+ parsed.error.message = message + '\n' + hint
229
+ }
230
+ return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers })
231
+ })
232
+ })
233
+ }
234
+
235
+ /**
236
+ * The fields of a `Request` that must survive re-addressing it.
237
+ *
238
+ * `new Request(url, request)` is not available — the second argument must be an
239
+ * init — so the parts a relay call depends on are copied explicitly. The body is
240
+ * passed by reference, never read.
241
+ *
242
+ * @param {Request} request - the request being re-addressed.
243
+ * @returns {RequestInit} an init carrying its method, body, and transfer flags.
244
+ */
245
+ function requestInitOf(request) {
246
+ return {
247
+ method: request.method,
248
+ ...(request.body === null || request.method === 'GET' || request.method === 'HEAD' ? {} : { body: request.body }),
249
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
250
+ credentials: request.credentials,
251
+ redirect: request.redirect,
252
+ referrer: request.referrer,
253
+ integrity: request.integrity,
254
+ keepalive: request.keepalive,
255
+ mode: request.mode,
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Install the relay fence and expose its endpoint choice as a settings section.
261
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin's context.
262
+ * @param {ReturnType<typeof Config>} config - resolved entry configuration.
263
+ */
264
+ 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
+ })
276
+ })
277
+
278
+ ctx.effect(() => {
279
+ const previous = globalThis.fetch
280
+ if (typeof previous !== 'function') {
281
+ ctx.logger.warn('llm-agentrouter: no global fetch to fence; relay requests will be unroutable and rejected')
282
+ return () => {}
283
+ }
284
+ const fenced = fenceFetch(previous, () => current())
285
+ globalThis.fetch = fenced
286
+ return () => {
287
+ // Restore only what this plugin installed: a later wrapper layered on top
288
+ // owns the global now, and clobbering it would drop that one's rewrite.
289
+ if (globalThis.fetch === fenced) globalThis.fetch = previous
290
+ }
291
+ })
292
+
293
+ if (config.announce) {
294
+ const table = routingTable(config)
295
+ 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,
300
+ )
301
+ }
302
+ }
303
+
304
+ export { AGENTROUTER_SETTINGS_NAMESPACE, Config, apply, name }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@lynn123411/dsh-llm-agentrouter",
3
+ "version": "0.2.0",
4
+ "description": "AgentRouter 中转聚合:一条 pi-ai 路由承载多模型 + 国内/国际端点一键切换(设置卡片)+ 出站请求 User-Agent/403-配额改写围栏。aqiu817/dsh-llm-agentrouter 的 alpha.5 兼容分叉。",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh-plugin",
8
+ "cordis",
9
+ "llm",
10
+ "agentrouter"
11
+ ],
12
+ "homepage": "https://github.com/tttnny/my-dsh#readme",
13
+ "bugs": "https://github.com/tttnny/my-dsh/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/tttnny/my-dsh.git",
17
+ "directory": "plugins/dsh-llm-agentrouter"
18
+ },
19
+ "license": "MIT",
20
+ "author": "lynn123411",
21
+ "type": "module",
22
+ "main": "lib/index.js",
23
+ "exports": {
24
+ ".": "./lib/index.js",
25
+ "./client": "./lib/client.js",
26
+ "./package.json": "./package.json",
27
+ "./cordis.patch.yml": "./cordis.patch.yml"
28
+ },
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-settings"
37
+ ],
38
+ "platform": "web"
39
+ }
40
+ },
41
+ "files": [
42
+ "lib/",
43
+ "cordis.patch.yml",
44
+ "LICENSE",
45
+ "README.md"
46
+ ],
47
+ "scripts": {
48
+ "test": "node --test test/*.test.mjs"
49
+ },
50
+ "engines": {
51
+ "node": ">=22"
52
+ },
53
+ "dependencies": {
54
+ "@deepseek-ai/schemastery": "^3.18.2"
55
+ },
56
+ "peerDependencies": {
57
+ "@deepseek-ai/cordis": "^4.0.2"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@deepseek-ai/cordis": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "js-yaml": "^5.4.1",
66
+ "react": "18.3.1",
67
+ "react-test-renderer": "18.3.1"
68
+ }
69
+ }