dsh-model-headers 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/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # dsh-model-headers
2
+
3
+ [dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) 插件:按模型名给 LLM 请求注入自定义请求头。
4
+
5
+ 典型场景 —— Grok 中转的会话级缓存路由:让同一会话的所有请求携带恒定的 `X-Grok-Conv-Id` 头,与 body 里的 `prompt_cache_key` 恒等,从而命中中转的会话亲和缓存。
6
+
7
+ ```json
8
+ {
9
+ "rules": [
10
+ {
11
+ "id": "r-grok",
12
+ "enabled": true,
13
+ "model": "grok-*",
14
+ "headers": { "X-Grok-Conv-Id": "${sessionId}" }
15
+ }
16
+ ]
17
+ }
18
+ ```
19
+
20
+ ## 工作原理
21
+
22
+ - **注入点**:包装 `globalThis.fetch`(幂等,插件卸载时恢复)。规则引擎在模块导入时即安装 —— 不依赖插件 `apply` 时机,headless 首批请求也不会漏拦截。
23
+ - **模型匹配**:通配符 `*`(大小写不敏感),匹配请求 body 的 `model` 字段。命中的启用规则按数组顺序应用,同名头后者覆盖。
24
+ - **值变量**:`${sessionId}` 运行时解析为当前 dsh 会话 ID(取 `x-client-request-id` → `session_id` 头 → body `prompt_cache_key`,兜底空串)。
25
+
26
+ ## 管理 UI
27
+
28
+ dsh 设置页 →「模型请求头」:
29
+
30
+ - 规则的添加 / 编辑 / 删除 / 启停,改动实时生效(`fs.watch` 热加载)
31
+ - 模型下拉按供应商分组展示 settings.yaml 里已配置的模型,并自动推导通配符项(`*`、`grok-*`、`gpt-5.6-*` …),支持手输任意通配符与输入过滤
32
+ - 头值一键插入 `${sessionId}`
33
+
34
+ 规则持久化在 `~/.dsh/model-headers.json`。
35
+
36
+ ## 附带能力:settings.yaml 自愈
37
+
38
+ 启动及 settings.yaml 变动时,自动给 `llm-pi-ai.providers` 下缺 `cacheRetention` 的 provider 补写 `cacheRetention: long`(已有值一律尊重)。缺省时 dsh 退回 short 保留策略、不发 `prompt_cache_retention`,中转缓存容易未命中 —— 本插件确保 24h 保留策略就位。
39
+
40
+ ## 核验
41
+
42
+ 每次实际注入追加一行到 `~/.dsh/model-headers.log`(超 5MB 截断;主目录不可写自动落临时目录)。设 `DSH_MH_DEBUG=1` 可额外记录每个请求的 cacheKey / retention。
43
+
44
+ ## License
45
+
46
+ BSD-3-Clause
@@ -0,0 +1,5 @@
1
+ # dsh-model-headers bundle layer.
2
+ # 双面包:宿主半装进 host loader;dsh.client 声明把浏览器模块加进 Web roster。
3
+ - insert:
4
+ - id: dsh-model-headers
5
+ name: 'dsh-model-headers'
package/lib/client.js ADDED
@@ -0,0 +1,577 @@
1
+ /**
2
+ * dsh-model-headers client half (v0.1.2).
3
+ *
4
+ * 设置页 section「模型请求头」:
5
+ * - 规则卡片列表(双列):启停开关 / 模型通配符 / 头摘要 / 编辑 / 删除(二次确认)
6
+ * - 添加·编辑弹框:模型下拉(combobox:点选 settings.yaml 现有模型 / 推导通配符 / 手输任意)+ 头键值行(值可插入 ${sessionId} 变量)
7
+ * - 通信:connection RPC 通道 '/model-headers',信封 {ok, value | error{message}}
8
+ * - 挂载点:settings.section 槽位(order 100,排在「技能与MCP」之后)。
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: "dsh-model-headers",
12
+ factory: (require) => {
13
+ const React = require("react");
14
+ // 不用 jsx-runtime:某些 shell 构建里 require("react/jsx-runtime") 的命名导出
15
+ // 不可靠(jsx 为 undefined → "jsx is not a function"),createElement 最稳。
16
+ const jsx = (type, props) => React.createElement(type, props);
17
+
18
+ const INJECT = ["slots", "connection"];
19
+
20
+ /** connection RPC 通道(与 host 半一致)。 */
21
+ const RPC_CHANNEL = "/model-headers";
22
+
23
+ let hostCtx = null;
24
+
25
+ async function callApi(endpoint, body = {}) {
26
+ const result = await hostCtx.connection.rpc.call(RPC_CHANNEL, endpoint, body);
27
+ if (result.ok) return result.value;
28
+ throw new Error(result.error?.message || `rpc ${endpoint} failed`);
29
+ }
30
+
31
+ // ------------------------------------------------------------------
32
+ // Styles(沿用 dsh-ccs/dpc 设计语言,类前缀 dsh-mh-)
33
+ // ------------------------------------------------------------------
34
+
35
+ const css = `
36
+ .dsh-mh-page { display: flex; flex-direction: column; gap: 12px; width: 100%;
37
+ max-width: 760px; color: var(--dsw-alias-label-primary, #222);
38
+ flex: 1 0 auto; min-height: calc(100vh - 180px); }
39
+ .dsh-mh-title { margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; }
40
+ .dsh-mh-desc { margin: 0; display: flex; align-items: flex-start; gap: 8px;
41
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); border-radius: 8px;
42
+ padding: 9px 12px; background: var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.06));
43
+ font-size: 12px; line-height: 18px; white-space: pre-wrap;
44
+ color: var(--dsw-alias-label-secondary, #555); }
45
+ .dsh-mh-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
46
+ .dsh-mh-listhead { display: flex; align-items: baseline; gap: 7px; padding: 0 2px; }
47
+ .dsh-mh-listhead-title { margin: 0; font-size: 13px; line-height: 20px; font-weight: 600; }
48
+ .dsh-mh-listhead-count { font-size: 12px; line-height: 18px;
49
+ color: var(--dsw-alias-label-tertiary, #8a8a8a); font-variant-numeric: tabular-nums; }
50
+ .dsh-mh-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
51
+ align-items: stretch; gap: 10px; }
52
+ @media (max-width: 680px) { .dsh-mh-cards { grid-template-columns: minmax(0, 1fr); } }
53
+ .dsh-mh-card { display: flex; flex-direction: column; gap: 6px; min-width: 0;
54
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); border-radius: 10px;
55
+ background: var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.06)); padding: 10px 12px;
56
+ transition: background 0.15s; }
57
+ .dsh-mh-card:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.1)); }
58
+ .dsh-mh-card-off { opacity: 0.6; }
59
+ .dsh-mh-card-top { display: flex; align-items: center; gap: 8px; }
60
+ .dsh-mh-card-name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
61
+ white-space: nowrap; font-size: 14px; line-height: 20px; font-weight: 600;
62
+ font-family: var(--ds-font-family-code, ui-monospace, Consolas, monospace); }
63
+ .dsh-mh-card-spacer { flex: 1; }
64
+ .dsh-mh-card-headers { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
65
+ .dsh-mh-card-header-line { font-size: 12px; line-height: 18px;
66
+ color: var(--dsw-alias-label-secondary, #555);
67
+ font-family: var(--ds-font-family-code, ui-monospace, Consolas, monospace);
68
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
69
+ .dsh-mh-card-header-line .dsh-mh-var { color: var(--dsw-alias-state-business-primary, #4b7bec); }
70
+ .dsh-mh-tag { display: inline-flex; align-items: center; min-height: 18px; flex: none;
71
+ border-radius: 5px; padding: 1px 6px; background: var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.14));
72
+ color: var(--dsw-alias-label-secondary, #555); font-size: 11px; line-height: 16px; white-space: nowrap; }
73
+ .dsh-mh-tag[data-kind='off'] { background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b7791f) 12%, transparent);
74
+ color: var(--dsw-alias-label-secondary, #555); }
75
+ .dsh-mh-switch { appearance: none; width: 34px; height: 18px; border-radius: 9px; cursor: pointer;
76
+ background: var(--dsw-alias-state-inactive-label, #9e9e9e); position: relative;
77
+ transition: background 0.15s; flex-shrink: 0; outline: none; }
78
+ .dsh-mh-switch:checked { background: var(--dsw-alias-state-success-label, #2e7d32); }
79
+ .dsh-mh-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
80
+ border-radius: 50%; background: #fff; transition: transform 0.15s; }
81
+ .dsh-mh-switch:checked::after { transform: translateX(16px); }
82
+ .dsh-mh-btn { appearance: none; display: inline-flex; align-items: center; justify-content: center;
83
+ padding: 6px 14px; border-radius: 8px; cursor: pointer;
84
+ font-size: 12px; line-height: 18px;
85
+ background: var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.12));
86
+ color: var(--dsw-alias-label-primary, #222);
87
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); }
88
+ .dsh-mh-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.16)); }
89
+ .dsh-mh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
90
+ .dsh-mh-btn-primary { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4b7bec) 14%, transparent);
91
+ border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4b7bec) 40%, transparent);
92
+ color: var(--dsw-alias-state-business-primary, #4b7bec); font-weight: 600; }
93
+ .dsh-mh-btn-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4b7bec) 22%, transparent); }
94
+ .dsh-mh-icon-btn { appearance: none; background: none; border: none; cursor: pointer;
95
+ font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, #8a8a8a);
96
+ padding: 3px 8px; border-radius: 6px; }
97
+ .dsh-mh-icon-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12));
98
+ color: var(--dsw-alias-label-primary, #222); }
99
+ .dsh-mh-icon-btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 12%, transparent);
100
+ color: var(--dsw-alias-state-error-primary, #e5484d); }
101
+ .dsh-mh-input { border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35)); border-radius: 8px;
102
+ background: var(--dsw-alias-bg-layer-1, rgba(127,127,127,0.08));
103
+ color: var(--dsw-alias-label-primary, #222); outline: none;
104
+ padding: 6px 11px; font-size: 13px; line-height: 18px; min-width: 0; }
105
+ .dsh-mh-input:focus-visible { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
106
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary, #4b7bec) 18%, transparent); }
107
+ .dsh-mh-mono { font-family: var(--ds-font-family-code, ui-monospace, Consolas, monospace); }
108
+ .dsh-mh-msg { margin: 0; display: flex; align-items: flex-start; gap: 8px;
109
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); border-radius: 8px;
110
+ padding: 9px 12px; background: var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.06));
111
+ font-size: 12px; line-height: 18px; white-space: pre-wrap; word-break: break-all;
112
+ color: var(--dsw-alias-label-primary, #222); }
113
+ .dsh-mh-msg-ok { border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 35%, transparent);
114
+ background: color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 8%, transparent); }
115
+ .dsh-mh-msg-err { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 35%, transparent);
116
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 8%, transparent);
117
+ color: var(--dsw-alias-state-error-primary, #e5484d); }
118
+ .dsh-mh-empty { margin: 0; padding: 14px 2px; font-size: 13px; line-height: 20px;
119
+ color: var(--dsw-alias-label-tertiary, #8a8a8a); white-space: pre-wrap; }
120
+ /* 弹框 */
121
+ .dsh-mh-modal-mask { position: fixed; inset: 0; z-index: 1100; display: flex;
122
+ align-items: center; justify-content: center; padding: 24px;
123
+ background: var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.45)); backdrop-filter: blur(2px); }
124
+ .dsh-mh-modal { display: flex; flex-direction: column; min-width: 0; box-sizing: border-box;
125
+ width: min(520px, 100%); max-height: min(80vh, 680px); overflow: auto; padding: 20px;
126
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); border-radius: 14px;
127
+ background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 16px 48px rgba(0,0,0,0.24); }
128
+ .dsh-mh-modal-title { margin: 0; font-size: 15px; line-height: 22px; font-weight: 600;
129
+ color: var(--dsw-alias-label-primary, #222); }
130
+ .dsh-mh-modal-desc { margin: 4px 0 0; font-size: 12px; line-height: 18px;
131
+ color: var(--dsw-alias-label-tertiary, #8a8a8a); }
132
+ .dsh-mh-field { margin-top: 16px; }
133
+ .dsh-mh-field-label { display: block; margin: 0 0 6px; font-size: 11px; line-height: 16px;
134
+ font-weight: 500; color: var(--dsw-alias-label-secondary, #555); }
135
+ .dsh-mh-hv-row { display: flex; gap: 8px; align-items: center; }
136
+ .dsh-mh-hv-row + .dsh-mh-hv-row { margin-top: 8px; }
137
+ .dsh-mh-hv-name { flex: 2 1 0; min-width: 0; }
138
+ .dsh-mh-hv-value { flex: 3 1 0; min-width: 0; }
139
+ .dsh-mh-hv-var { flex: none; height: 32px; padding: 0 10px; font-size: 11px; }
140
+ .dsh-mh-hv-del { flex: none; width: 32px; height: 32px; padding: 0; display: inline-flex;
141
+ align-items: center; justify-content: center; border-radius: 8px; }
142
+ .dsh-mh-btn-dashed { width: 100%; justify-content: center; margin-top: 8px; height: 30px;
143
+ border-style: dashed; background: transparent; color: var(--dsw-alias-label-secondary, #555); }
144
+ .dsh-mh-btn-dashed:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #4b7bec);
145
+ border-color: var(--dsw-alias-state-business-primary, #4b7bec); background: transparent; }
146
+ .dsh-mh-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px;
147
+ padding-top: 14px; border-top: 1px solid var(--dsw-alias-border-l3, rgba(127,127,127,0.18)); }
148
+ /* 模型下拉(combobox:可手输通配符,也可点选;fixed 定位避免撑出弹框滚动条) */
149
+ @keyframes dsh-mh-pop { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
150
+ .dsh-mh-combo { position: relative; flex: 1; min-width: 0; }
151
+ .dsh-mh-combo-input { width: 100%; box-sizing: border-box; }
152
+ .dsh-mh-combo-menu { position: fixed; z-index: 1200; box-sizing: border-box;
153
+ overflow: auto; padding: 5px;
154
+ border: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.3)); border-radius: 10px;
155
+ background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 10px 32px rgba(0,0,0,0.16);
156
+ animation: dsh-mh-pop 0.14s ease; }
157
+ .dsh-mh-combo-group { margin: 4px 8px 2px; font-size: 10px; line-height: 14px; font-weight: 600;
158
+ letter-spacing: 0.03em; color: var(--dsw-alias-label-tertiary, #8a8a8a); user-select: none; }
159
+ .dsh-mh-combo-opt { display: flex; align-items: center; gap: 4px; width: 100%; box-sizing: border-box;
160
+ appearance: none; border: none; background: none; cursor: pointer; text-align: left;
161
+ border-radius: 7px; padding: 4px 10px; transition: background 0.15s; }
162
+ .dsh-mh-combo-opt:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12)); }
163
+ .dsh-mh-combo-opt.is-active { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4b7bec) 10%, transparent); }
164
+ .dsh-mh-combo-opt-check { flex: none; width: 14px; text-align: center; font-size: 11px; line-height: 18px;
165
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
166
+ .dsh-mh-combo-opt-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
167
+ white-space: nowrap; font-size: 12px; line-height: 18px;
168
+ font-family: var(--ds-font-family-code, ui-monospace, Consolas, monospace);
169
+ color: var(--dsw-alias-label-primary, #222); }
170
+ .dsh-mh-combo-opt.is-wild .dsh-mh-combo-opt-name {
171
+ color: var(--dsw-alias-state-business-primary, #4b7bec); font-weight: 600; }
172
+ .dsh-mh-combo-empty { padding: 8px 10px; font-size: 12px; line-height: 18px;
173
+ color: var(--dsw-alias-label-tertiary, #8a8a8a); white-space: pre-wrap; }
174
+ `;
175
+
176
+ function ensureStyles() {
177
+ if (document.getElementById('dsh-mh-style')) return;
178
+ const el = document.createElement('style');
179
+ el.id = 'dsh-mh-style';
180
+ el.textContent = css;
181
+ document.head.appendChild(el);
182
+ }
183
+
184
+ // ------------------------------------------------------------------
185
+ // UI 组件
186
+ // ------------------------------------------------------------------
187
+
188
+ const Switch = ({ checked, onChange, disabled, title }) =>
189
+ jsx('input', {
190
+ type: 'checkbox', className: 'dsh-mh-switch', checked,
191
+ onChange: (e) => onChange(e.target.checked), disabled, title,
192
+ });
193
+
194
+ /** 头值里的 ${sessionId} 变量着色展示。 */
195
+ function renderHeaderValue(value) {
196
+ const parts = String(value).split('${sessionId}');
197
+ if (parts.length === 1) return value;
198
+ const children = [];
199
+ for (let i = 0; i < parts.length; i++) {
200
+ if (parts[i] !== '') children.push(parts[i]);
201
+ if (i < parts.length - 1) {
202
+ children.push(jsx('span', { key: `v${i}`, className: 'dsh-mh-var', children: '${sessionId}' }));
203
+ }
204
+ }
205
+ return children;
206
+ }
207
+
208
+ /**
209
+ * 模型下拉(combobox):可手输任意通配符,也可点选。
210
+ * 选项按 provider(供应商)分组展示,末组为推导通配符(*、grok-*、gpt-5.6-* …)。
211
+ * fixed 定位依输入框实时测量(打开帧 + 渲染帧 + models 就绪时重测),避免首开尺寸错位。
212
+ */
213
+ function ModelCombo({ value, models, onChange }) {
214
+ const [open, setOpen] = React.useState(false);
215
+ const [query, setQuery] = React.useState(''); // 本次打开后手动输入的过滤词(打开时重置,避免只剩已选值)
216
+ const [menuStyle, setMenuStyle] = React.useState(null);
217
+ const ref = React.useRef(null);
218
+ const inputRef = React.useRef(null);
219
+
220
+ // 供应商 → 模型 id 分组(保持配置出现顺序)
221
+ const groups = React.useMemo(() => {
222
+ const map = new Map();
223
+ for (const m of models || []) {
224
+ let ids = map.get(m.provider);
225
+ if (!ids) map.set(m.provider, (ids = []));
226
+ if (!ids.includes(m.id)) ids.push(m.id);
227
+ }
228
+ return [...map.entries()].map(([provider, ids]) => ({ provider, ids }));
229
+ }, [models]);
230
+
231
+ // 通配符推导:* + 去掉最后一个 - 段的前缀
232
+ const wildcards = React.useMemo(() => {
233
+ const set = new Set(['*']);
234
+ for (const m of models || []) {
235
+ const parts = m.id.split('-');
236
+ if (parts.length >= 2) set.add(`${parts.slice(0, -1).join('-')}-*`);
237
+ }
238
+ return [...set];
239
+ }, [models]);
240
+
241
+ /** 依输入框位置计算下拉的 fixed 定位与高度;下方空间不足时上翻。 */
242
+ const placeMenu = React.useCallback(() => {
243
+ const r = inputRef.current?.getBoundingClientRect();
244
+ if (!r) return;
245
+ const ITEM = 26, HEADER = 18, PAD = 12, GAP = 4;
246
+ const rowCount = groups.reduce((n, g) => n + g.ids.length, 0) + wildcards.length;
247
+ const headerCount = groups.length + (wildcards.length > 0 ? 1 : 0);
248
+ const natural = Math.min(rowCount * ITEM + headerCount * HEADER + PAD, 440);
249
+ const below = window.innerHeight - r.bottom - 12;
250
+ const above = r.top - 12;
251
+ const openUp = below < Math.min(natural, 160) && above > below;
252
+ const maxH = Math.max(120, Math.min(natural, openUp ? above : below));
253
+ setMenuStyle({
254
+ left: r.left,
255
+ width: r.width,
256
+ maxHeight: maxH,
257
+ ...(openUp ? { bottom: window.innerHeight - r.top + GAP } : { top: r.bottom + GAP }),
258
+ });
259
+ }, [groups, wildcards]);
260
+
261
+ const openMenu = React.useCallback(() => {
262
+ setQuery('');
263
+ placeMenu();
264
+ setOpen(true);
265
+ // autoFocus 可能早于最终布局,下一帧再测一次保证首开位置/宽度正确
266
+ requestAnimationFrame(placeMenu);
267
+ }, [placeMenu]);
268
+
269
+ React.useEffect(() => {
270
+ if (!open) return;
271
+ const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
272
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
273
+ const onReposition = () => { placeMenu(); };
274
+ document.addEventListener('mousedown', onDoc);
275
+ document.addEventListener('keydown', onKey);
276
+ // 弹框/页面滚动与窗口缩放时跟随重定位
277
+ window.addEventListener('resize', onReposition);
278
+ window.addEventListener('scroll', onReposition, true);
279
+ return () => {
280
+ document.removeEventListener('mousedown', onDoc);
281
+ document.removeEventListener('keydown', onKey);
282
+ window.removeEventListener('resize', onReposition);
283
+ window.removeEventListener('scroll', onReposition, true);
284
+ };
285
+ }, [open, placeMenu]);
286
+
287
+ // models 异步就绪后重测一次,修正首开时的加载态尺寸
288
+ React.useEffect(() => {
289
+ if (open) placeMenu();
290
+ }, [open, groups, wildcards, placeMenu]);
291
+
292
+ // 打开/过滤后把已选中项滚动进可视区
293
+ React.useEffect(() => {
294
+ if (!open) return;
295
+ const el = ref.current?.querySelector('.dsh-mh-combo-opt.is-active');
296
+ el?.scrollIntoView?.({ block: 'nearest' });
297
+ }, [open, query]);
298
+
299
+ const q = query.trim().toLowerCase();
300
+ const match = (s) => q === '' || q === '*' || s.toLowerCase().includes(q);
301
+ const visibleGroups = groups
302
+ .map((g) => ({ ...g, ids: g.ids.filter(match) }))
303
+ .filter((g) => g.ids.length > 0);
304
+ const visibleWildcards = wildcards.filter(match);
305
+ const current = String(value);
306
+
307
+ const optButton = (val, wild, key) => jsx('button', {
308
+ key, type: 'button',
309
+ className: `dsh-mh-combo-opt${wild ? ' is-wild' : ''}${val === current ? ' is-active' : ''}`,
310
+ onClick: () => { onChange(val); setOpen(false); },
311
+ children: [
312
+ jsx('span', { key: 'c', className: 'dsh-mh-combo-opt-check',
313
+ children: val === current ? '✓' : '' }),
314
+ jsx('span', { key: 'n', className: 'dsh-mh-combo-opt-name', children: val }),
315
+ ],
316
+ });
317
+
318
+ const items = [];
319
+ for (const g of visibleGroups) {
320
+ items.push(jsx('div', { key: `g:${g.provider}`, className: 'dsh-mh-combo-group',
321
+ children: g.provider }));
322
+ for (const id of g.ids) items.push(optButton(id, false, `m:${g.provider}:${id}`));
323
+ }
324
+ if (visibleWildcards.length > 0) {
325
+ items.push(jsx('div', { key: 'g:wild', className: 'dsh-mh-combo-group', children: '通配符' }));
326
+ for (const w of visibleWildcards) items.push(optButton(w, true, `w:${w}`));
327
+ }
328
+
329
+ return jsx('div', {
330
+ ref, className: 'dsh-mh-combo',
331
+ children: [
332
+ jsx('input', {
333
+ key: 'i', ref: inputRef, className: 'dsh-mh-input dsh-mh-mono dsh-mh-combo-input',
334
+ placeholder: 'grok-*(可手输通配符,或点选下拉项)', value,
335
+ onChange: (e) => { onChange(e.target.value); setQuery(e.target.value); showMenu(); },
336
+ onFocus: openMenu,
337
+ }),
338
+ open && jsx('div', { key: 'menu', className: 'dsh-mh-combo-menu', style: menuStyle, children:
339
+ models === null
340
+ ? jsx('div', { className: 'dsh-mh-combo-empty', children: '读取模型列表…' })
341
+ : items.length === 0
342
+ ? jsx('div', { className: 'dsh-mh-combo-empty', children: '无匹配项,可直接使用通配符(如 grok-*)' })
343
+ : items,
344
+ }),
345
+ ],
346
+ });
347
+ }
348
+
349
+ /** 编辑弹框:模型通配符(下拉可点选 / 可手输)+ 头键值行。 */
350
+ function RuleEditor({ initial, onClose, onSaved }) {
351
+ const [model, setModel] = React.useState(initial?.model || '');
352
+ const [models, setModels] = React.useState(null); // [{ provider, id }] | null 加载中
353
+ const [pairs, setPairs] = React.useState(() => {
354
+ const entries = Object.entries(initial?.headers || {});
355
+ return entries.length > 0 ? entries.map(([name, value]) => ({ name, value })) : [{ name: '', value: '' }];
356
+ });
357
+ const [busy, setBusy] = React.useState(false);
358
+ const [error, setError] = React.useState(null);
359
+
360
+ React.useEffect(() => {
361
+ let alive = true;
362
+ callApi('models/list')
363
+ .then((d) => { if (alive) setModels(d.models || []); })
364
+ .catch(() => { if (alive) setModels([]); });
365
+ return () => { alive = false; };
366
+ }, []);
367
+
368
+ const setPair = (i, patch) => {
369
+ setPairs((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
370
+ };
371
+ const removePair = (i) => {
372
+ setPairs((prev) => (prev.length > 1 ? prev.filter((_, j) => j !== i) : prev));
373
+ };
374
+
375
+ const save = async () => {
376
+ setError(null);
377
+ const headers = {};
378
+ for (const p of pairs) {
379
+ if (p.name.trim() !== '') headers[p.name.trim()] = p.value;
380
+ }
381
+ if (model.trim() === '') { setError('模型通配符不能为空'); return; }
382
+ if (Object.keys(headers).length === 0) {
383
+ setError('至少需要一条请求头(头名称不能为空)');
384
+ return;
385
+ }
386
+ setBusy(true);
387
+ try {
388
+ const saved = await callApi('headers/save', {
389
+ rule: { id: initial?.id, enabled: initial?.enabled !== false, model, headers },
390
+ });
391
+ onSaved(saved.rule);
392
+ } catch (e) {
393
+ setError(e.message);
394
+ setBusy(false);
395
+ }
396
+ };
397
+
398
+ return jsx('div', {
399
+ className: 'dsh-mh-modal-mask',
400
+ onClick: (e) => { if (e.target === e.currentTarget && !busy) onClose(); },
401
+ children: jsx('div', {
402
+ className: 'dsh-mh-modal',
403
+ children: [
404
+ jsx('h3', { key: 't', className: 'dsh-mh-modal-title',
405
+ children: initial?.id ? '编辑规则' : '添加规则' }),
406
+ jsx('p', { key: 'd', className: 'dsh-mh-modal-desc',
407
+ children: '匹配模型通配符的 LLM 请求将在发出前注入下列自定义请求头' }),
408
+ jsx('div', { key: 'f1', className: 'dsh-mh-field', children: [
409
+ jsx('label', { key: 'l', className: 'dsh-mh-field-label',
410
+ children: '模型匹配(支持 * 通配符,不区分大小写)' }),
411
+ jsx(ModelCombo, { key: 'c', value: model, models, onChange: setModel }),
412
+ ] }),
413
+ jsx('div', { key: 'f2', className: 'dsh-mh-field', children: [
414
+ jsx('label', { key: 'l', className: 'dsh-mh-field-label',
415
+ children: '注入头(值支持 ${sessionId} 变量)' }),
416
+ ...pairs.map((p, i) => jsx('div', { key: i, className: 'dsh-mh-hv-row', children: [
417
+ jsx('input', {
418
+ key: 'n', className: 'dsh-mh-input dsh-mh-mono dsh-mh-hv-name',
419
+ placeholder: '头名称,如 X-Grok-Conv-Id', value: p.name,
420
+ onChange: (e) => setPair(i, { name: e.target.value }),
421
+ }),
422
+ jsx('input', {
423
+ key: 'v', className: 'dsh-mh-input dsh-mh-mono dsh-mh-hv-value',
424
+ placeholder: '固定值 或 ${sessionId}', value: p.value,
425
+ onChange: (e) => setPair(i, { value: e.target.value }),
426
+ }),
427
+ jsx('button', {
428
+ key: 'sv', type: 'button', className: 'dsh-mh-btn dsh-mh-hv-var',
429
+ title: '插入 ${sessionId} 变量',
430
+ onClick: () => setPair(i, { value: `${p.value}\${sessionId}` }),
431
+ children: '+会话ID',
432
+ }),
433
+ jsx('button', {
434
+ key: 'x', type: 'button', className: 'dsh-mh-icon-btn dsh-mh-icon-btn-danger dsh-mh-hv-del',
435
+ title: '移除该头', disabled: pairs.length === 1,
436
+ onClick: () => removePair(i), children: '✕',
437
+ }),
438
+ ] })),
439
+ jsx('button', {
440
+ key: 'add', type: 'button', className: 'dsh-mh-btn dsh-mh-btn-dashed',
441
+ onClick: () => setPairs((prev) => [...prev, { name: '', value: '' }]),
442
+ children: '+ 添加头',
443
+ }),
444
+ ] }),
445
+ error && jsx('div', { key: 'e', className: 'dsh-mh-msg dsh-mh-msg-err', style: { marginTop: '12px' }, children: error }),
446
+ jsx('div', { key: 'a', className: 'dsh-mh-modal-actions', children: [
447
+ jsx('button', { key: 'c', type: 'button', className: 'dsh-mh-btn', disabled: busy, onClick: onClose, children: '取消' }),
448
+ jsx('button', { key: 's', type: 'button', className: 'dsh-mh-btn dsh-mh-btn-primary', disabled: busy, onClick: () => void save(), children: busy ? '保存中…' : '保存' }),
449
+ ] }),
450
+ ],
451
+ }),
452
+ });
453
+ }
454
+
455
+ function HeadersPanel() {
456
+ const [rules, setRules] = React.useState(null);
457
+ const [configFile, setConfigFile] = React.useState('');
458
+ const [message, setMessage] = React.useState(null); // { kind: 'ok'|'err', text }
459
+ const [editing, setEditing] = React.useState(null); // rule 对象({} = 新增)
460
+ const [busyId, setBusyId] = React.useState(null);
461
+
462
+ const reload = React.useCallback(async () => {
463
+ try {
464
+ const data = await callApi('headers/list');
465
+ setRules(data.rules || []);
466
+ setConfigFile(data.configFile || '');
467
+ } catch (e) {
468
+ setMessage({ kind: 'err', text: `加载失败:${e.message}` });
469
+ }
470
+ }, []);
471
+
472
+ React.useEffect(() => { void reload(); }, [reload]);
473
+
474
+ const run = async (endpoint, body, okText) => {
475
+ const id = body?.id || 'new';
476
+ setBusyId(id);
477
+ try {
478
+ await callApi(endpoint, body);
479
+ setMessage(okText ? { kind: 'ok', text: okText } : null);
480
+ await reload();
481
+ } catch (e) {
482
+ setMessage({ kind: 'err', text: `${endpoint} 失败:${e.message}` });
483
+ } finally {
484
+ setBusyId(null);
485
+ }
486
+ };
487
+
488
+ const remove = (rule) => {
489
+ if (!confirm(`确定删除规则 '${rule.model}'?\n该规则的所有自定义请求头将停止注入。`)) return;
490
+ void run('headers/remove', { id: rule.id }, `已删除规则 '${rule.model}'。`);
491
+ };
492
+
493
+ const toggle = (rule, enabled) => {
494
+ void run('headers/toggle', { id: rule.id, enabled }, enabled ? `已启用 '${rule.model}'。` : `已停用 '${rule.model}'。`);
495
+ };
496
+
497
+ const enabledCount = (rules || []).filter((r) => r.enabled).length;
498
+
499
+ return jsx('div', { className: 'dsh-mh-page', children: [
500
+ jsx('h2', { key: 't', className: 'dsh-mh-title', children: '模型请求头' }),
501
+ jsx('div', { key: 'd', className: 'dsh-mh-desc',
502
+ children: '按模型名匹配 LLM 请求并注入自定义请求头。\n值支持固定字符串和 ${sessionId}(运行时替换为当前会话 ID,适合中转的会话粘性/缓存路由)。' }),
503
+ jsx('div', { key: 'tb', className: 'dsh-mh-toolbar', children: [
504
+ jsx('button', { key: 'add', type: 'button', className: 'dsh-mh-btn dsh-mh-btn-primary',
505
+ onClick: () => setEditing({}), children: '添加规则' }),
506
+ ] }),
507
+ jsx('div', { key: 'lh', className: 'dsh-mh-listhead', children: [
508
+ jsx('span', { key: 'lt', className: 'dsh-mh-listhead-title', children: '规则' }),
509
+ rules && jsx('span', { key: 'lc', className: 'dsh-mh-listhead-count',
510
+ children: `${rules.length} 条 · 启用 ${enabledCount}` }),
511
+ ] }),
512
+ message && jsx('div', { key: 'msg',
513
+ className: `dsh-mh-msg ${message.kind === 'ok' ? 'dsh-mh-msg-ok' : 'dsh-mh-msg-err'}`,
514
+ children: message.text }),
515
+ rules === null
516
+ ? jsx('div', { key: 'loading', className: 'dsh-mh-empty', children: '加载中…' })
517
+ : rules.length === 0
518
+ ? jsx('div', { key: 'empty', className: 'dsh-mh-empty',
519
+ children: '还没有规则。点击「添加规则」,例如:\n模型 grok-* → 头 X-Grok-Conv-Id: ${sessionId}' })
520
+ : jsx('div', { key: 'cards', className: 'dsh-mh-cards', children:
521
+ rules.map((rule) => jsx('div', {
522
+ key: rule.id,
523
+ className: `dsh-mh-card${rule.enabled ? '' : ' dsh-mh-card-off'}`,
524
+ children: [
525
+ jsx('div', { key: 'top', className: 'dsh-mh-card-top', children: [
526
+ jsx(Switch, { key: 'sw', checked: rule.enabled, disabled: busyId === rule.id,
527
+ title: rule.enabled ? '停用' : '启用', onChange: (v) => toggle(rule, v) }),
528
+ jsx('span', { key: 'n', className: 'dsh-mh-card-name', title: rule.model, children: rule.model }),
529
+ !rule.enabled && jsx('span', { key: 'off', className: 'dsh-mh-tag', 'data-kind': 'off', children: '已停用' }),
530
+ jsx('span', { key: 'sp', className: 'dsh-mh-card-spacer' }),
531
+ jsx('button', { key: 'e', type: 'button', className: 'dsh-mh-icon-btn', title: '编辑',
532
+ disabled: busyId === rule.id, onClick: () => setEditing(rule), children: '编辑' }),
533
+ jsx('button', { key: 'del', type: 'button', className: 'dsh-mh-icon-btn dsh-mh-icon-btn-danger', title: '删除',
534
+ disabled: busyId === rule.id, onClick: () => remove(rule), children: '删除' }),
535
+ ] }),
536
+ jsx('div', { key: 'hs', className: 'dsh-mh-card-headers', children:
537
+ Object.entries(rule.headers || {}).map(([name, value]) =>
538
+ jsx('div', { key: name, className: 'dsh-mh-card-header-line',
539
+ children: [`${name}: `, renderHeaderValue(value)] })) }),
540
+ ],
541
+ })) }),
542
+ configFile && jsx('div', { key: 'cfg', className: 'dsh-mh-empty', style: { fontSize: '11px' },
543
+ children: `配置文件:${configFile}(外部编辑自动热加载)` }),
544
+ editing && jsx(RuleEditor, {
545
+ key: editing.id || 'new',
546
+ initial: editing,
547
+ onClose: () => setEditing(null),
548
+ onSaved: (rule) => {
549
+ setEditing(null);
550
+ setMessage({ kind: 'ok', text: `已保存规则 '${rule.model}',即刻生效。` });
551
+ void reload();
552
+ },
553
+ }),
554
+ ] });
555
+ }
556
+
557
+ // ------------------------------------------------------------------
558
+ // apply
559
+ // ------------------------------------------------------------------
560
+
561
+ const apply = (ctx) => {
562
+ hostCtx = ctx;
563
+ ensureStyles();
564
+ ctx.effect(() => () => { hostCtx = null; });
565
+
566
+ // 设置页:模型请求头(settings.section 槽位,排在「技能与MCP」之后)
567
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
568
+ name: 'settings.section',
569
+ id: 'dsh-model-headers',
570
+ order: 100,
571
+ label: () => '模型请求头',
572
+ }, (props) => jsx(HeadersPanel, props)));
573
+ };
574
+
575
+ return { apply, inject: INJECT };
576
+ },
577
+ });
package/lib/index.js ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * dsh-model-headers host half (v0.1.2).
3
+ *
4
+ * 按模型名给 LLM 请求注入自定义请求头:
5
+ * - 规则存 ~/.dsh/model-headers.json:{ rules: [{ id, enabled, model, headers }] }
6
+ * - model 为通配符(仅 '*',大小写不敏感),匹配请求 body 里的 model 字段
7
+ * - 值支持固定字符串与 ${sessionId} 变量(运行时取 dsh 会话 ID)
8
+ * - 匹配的启用规则按数组顺序应用,同名头后者覆盖
9
+ *
10
+ * 附带自愈:settings.yaml 的 llm-pi-ai.providers 若缺 cacheRetention,启动和
11
+ * 文件变动时补写 `cacheRetention: long`(缺省时 pi-ai 退回 short,不发
12
+ * prompt_cache_retention,缓存保留退化易未命中)。
13
+ *
14
+ * 注入点:globalThis.fetch 包装(幂等,dispose 恢复)。钩子在**模块导入时**
15
+ * 立即安装(而非 apply 时),避免 headless 启动竞态导致首批请求漏拦截。
16
+ * dsh 原生请求头(x-client-request-id / session_id)在此之前已就位,作为
17
+ * sessionId 取值来源。
18
+ *
19
+ * 管理:connection RPC 通道 '/model-headers'(loopback),配置文件 fs.watch
20
+ * 热加载。每次实际注入追加一行到 ~/.dsh/model-headers.log 供核验(>5MB 截断)。
21
+ */
22
+
23
+ import { watch as fsWatch } from 'node:fs'
24
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
25
+ import { homedir, tmpdir } from 'node:os'
26
+ import path from 'node:path'
27
+ import { randomUUID } from 'node:crypto'
28
+
29
+ export const name = 'dsh-model-headers'
30
+ export const inject = []
31
+ export { ensureCacheRetention, listConfiguredModels }
32
+
33
+ const DSH_HOME = () => process.env.DSH_HOME || path.join(homedir(), '.dsh')
34
+ const CONFIG_FILE = () => path.join(DSH_HOME(), 'model-headers.json')
35
+ const LOG_FILE = () => path.join(DSH_HOME(), 'model-headers.log')
36
+ const SETTINGS_FILE = () => path.join(DSH_HOME(), 'settings.yaml')
37
+ const LOG_MAX_BYTES = 5 * 1024 * 1024
38
+ const RPC_CHANNEL = '/model-headers'
39
+ const GUARD = Symbol.for('dsh.model-headers.installed')
40
+
41
+ const e2str = (e) => String(e?.message || e).slice(0, 160)
42
+
43
+ // ==================== 模块级注入引擎(apply 之外也能工作) ====================
44
+
45
+ let rules = [] // apply/loadRules 填充;钩子实时读取
46
+
47
+ /** 规则通配符 → 正则(仅 '*' 语义,其余字符转义;大小写不敏感)。 */
48
+ function modelPatternToRegExp(pattern) {
49
+ const escaped = String(pattern)
50
+ .split('*')
51
+ .map((seg) => seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
52
+ .join('.*')
53
+ return new RegExp(`^${escaped}$`, 'i')
54
+ }
55
+
56
+ async function loadRules() {
57
+ try {
58
+ const raw = await readFile(CONFIG_FILE(), 'utf8')
59
+ const parsed = JSON.parse(raw)
60
+ rules = Array.isArray(parsed?.rules) ? parsed.rules : []
61
+ } catch (e) {
62
+ if (e?.code !== 'ENOENT') console.warn(`[dsh-model-headers] 配置读取失败:${e2str(e)}`)
63
+ rules = []
64
+ }
65
+ }
66
+
67
+ async function saveRules() {
68
+ const file = CONFIG_FILE()
69
+ await mkdir(path.dirname(file), { recursive: true })
70
+ const data = `${JSON.stringify({ rules }, null, 2)}\n`
71
+ const tmp = `${file}.${process.pid}.tmp`
72
+ try {
73
+ await writeFile(tmp, data, 'utf8')
74
+ // rename 覆盖已存在目标:先清掉旧的(Windows rename 不覆盖)
75
+ await rm(file, { force: true })
76
+ await rename(tmp, file)
77
+ } catch {
78
+ // 原子写不可用(受限环境/文件占用)时退化为直接覆盖
79
+ await rm(tmp, { force: true }).catch(() => {})
80
+ await writeFile(file, data, 'utf8')
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 自愈 settings.yaml:给 llm-pi-ai.providers 下缺 cacheRetention 的 provider
86
+ * 补写 `cacheRetention: long`。缺省时 pi-ai 退回 short → 不发
87
+ * prompt_cache_retention,缓存保留退化易未命中。纯行级插入:只补缺失键,
88
+ * 不动已有值(用户显式配的 none/short 一律尊重)与文件其它内容。
89
+ */
90
+ async function ensureCacheRetention() {
91
+ const file = SETTINGS_FILE()
92
+ let raw
93
+ try {
94
+ raw = await readFile(file, 'utf8')
95
+ } catch (e) {
96
+ if (e?.code !== 'ENOENT') console.warn(`[dsh-model-headers] settings.yaml 读取失败:${e2str(e)}`)
97
+ return
98
+ }
99
+ const lines = raw.split(/\r?\n/)
100
+ const start = lines.findIndex((l) => /^llm-pi-ai:\s*(?:#.*)?$/.test(l))
101
+ if (start < 0) return
102
+ let providersAt = -1
103
+ for (let k = start + 1; k < lines.length; k++) {
104
+ if (/^\S/.test(lines[k])) break // 顶层下一个键,llm-pi-ai 块结束
105
+ if (/^ {2}providers:\s*(?:#.*)?$/.test(lines[k])) { providersAt = k; break }
106
+ }
107
+ if (providersAt < 0) return
108
+ // 遍历 provider 块(4 空格缩进键),检查块内(6 空格缩进)是否已有 cacheRetention
109
+ const insertAt = []
110
+ for (let k = providersAt + 1; k < lines.length; k++) {
111
+ const line = lines[k]
112
+ if (/^\S/.test(line)) break
113
+ if (!/^ {4}[^ #\s][^:]*:\s*(?:#.*)?$/.test(line)) continue
114
+ let has = false
115
+ for (let q = k + 1; q < lines.length; q++) {
116
+ const inner = lines[q]
117
+ if (/^\S/.test(inner) || /^ {4}[^ ]/.test(inner)) break // 下一个 provider / 顶层
118
+ if (/^ {6}cacheRetention:/.test(inner)) { has = true; break }
119
+ }
120
+ if (!has) insertAt.push(k + 1)
121
+ }
122
+ if (insertAt.length === 0) return
123
+ for (const at of insertAt.reverse()) lines.splice(at, 0, ' cacheRetention: long')
124
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n'
125
+ const tmp = `${file}.${process.pid}.tmp`
126
+ try {
127
+ await writeFile(tmp, lines.join(eol), 'utf8')
128
+ await rm(file, { force: true })
129
+ await rename(tmp, file)
130
+ console.log(`[dsh-model-headers] settings.yaml 补写 cacheRetention: long × ${insertAt.length}`)
131
+ } catch (e) {
132
+ console.warn(`[dsh-model-headers] settings.yaml 补写失败(不影响请求头注入):${e2str(e)}`)
133
+ await rm(tmp, { force: true }).catch(() => {})
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 行级扫描 settings.yaml,取 llm-pi-ai.providers 下配置的模型清单
139
+ * ([{ provider, id }]),供管理 UI 选择指定模型。零 YAML 依赖。
140
+ */
141
+ async function listConfiguredModels() {
142
+ let raw = ''
143
+ try {
144
+ raw = await readFile(SETTINGS_FILE(), 'utf8')
145
+ } catch {
146
+ return []
147
+ }
148
+ const lines = raw.split(/\r?\n/)
149
+ const start = lines.findIndex((l) => /^llm-pi-ai:\s*(?:#.*)?$/.test(l))
150
+ if (start < 0) return []
151
+ const out = []
152
+ let provider = null
153
+ let inModels = false
154
+ for (let k = start + 1; k < lines.length; k++) {
155
+ const line = lines[k]
156
+ if (/^\S/.test(line)) break // 顶层下一键,llm-pi-ai 块结束
157
+ let m = line.match(/^ {4}([^ #\s][^:]*):\s*(?:#.*)?$/)
158
+ if (m) {
159
+ provider = m[1].trim()
160
+ inModels = false
161
+ continue
162
+ }
163
+ if (provider === null) continue
164
+ m = line.match(/^ {6}([^ #\s][^:]*):\s*(?:#.*)?$/)
165
+ if (m) {
166
+ inModels = m[1].trim() === 'models'
167
+ continue
168
+ }
169
+ if (!inModels) continue
170
+ m = line.match(/^ {8}- id:\s*(.+?)\s*(?:#.*)?$/)
171
+ if (m) {
172
+ const id = m[1].replace(/^['"]|['"]$/g, '')
173
+ if (id !== '') out.push({ provider, id })
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
179
+ // ---- 核验日志(追加一行;超限截断重建;主位置写失败自动落到临时目录) ----
180
+ let logFile = null
181
+ let logWarned = false
182
+ async function writeLogLine(file, line) {
183
+ let size = 0
184
+ try { size = (await readFile(file)).length } catch {}
185
+ if (size > LOG_MAX_BYTES) await rm(file, { force: true })
186
+ await writeFile(file, line, { flag: 'a' })
187
+ }
188
+ async function appendLog(entry) {
189
+ const line = `${JSON.stringify(entry)}\n`
190
+ try {
191
+ const file = logFile ?? LOG_FILE()
192
+ await writeLogLine(file, line)
193
+ logFile = file
194
+ } catch (e) {
195
+ if (logFile === null) {
196
+ // 主位置(~/.dsh)不可写(受限环境/权限),退回临时目录
197
+ try {
198
+ const fallback = path.join(tmpdir(), 'dsh-model-headers.log')
199
+ await writeLogLine(fallback, line)
200
+ logFile = fallback
201
+ console.warn(`[dsh-model-headers] 主日志位置不可写,改用 ${fallback}:${e2str(e)}`)
202
+ return
203
+ } catch {}
204
+ }
205
+ if (!logWarned) {
206
+ logWarned = true
207
+ console.warn(`[dsh-model-headers] 核验日志写入失败(注入本身不受影响):${e2str(e)}`)
208
+ }
209
+ }
210
+ }
211
+
212
+ // ---- 头注入核心 ----
213
+ /** sessionId 来源优先级:原生头 x-client-request-id → session_id → body prompt_cache_key。 */
214
+ function resolveSessionId(lowerHeaders, cacheKey) {
215
+ return (
216
+ lowerHeaders['x-client-request-id'] ??
217
+ lowerHeaders['session_id'] ??
218
+ cacheKey ??
219
+ null
220
+ )
221
+ }
222
+
223
+ function computeHeaders(lowerHeaders, bodyFields) {
224
+ if (rules.length === 0) return null
225
+ const model = bodyFields.model
226
+ if (!model) return null
227
+ const sessionId = resolveSessionId(lowerHeaders, bodyFields.prompt_cache_key)
228
+ const merged = {}
229
+ let touched = false
230
+ for (const rule of rules) {
231
+ if (!rule?.enabled) continue
232
+ const headers = rule.headers
233
+ if (!headers || typeof headers !== 'object') continue
234
+ let pattern
235
+ try { pattern = modelPatternToRegExp(rule.model) } catch { continue }
236
+ if (!pattern.test(model)) continue
237
+ for (const [hName, rawValue] of Object.entries(headers)) {
238
+ if (typeof hName !== 'string' || hName === '' || typeof rawValue !== 'string') continue
239
+ let value = rawValue
240
+ if (value.includes('${sessionId}')) {
241
+ if (sessionId === null) continue // 取不到会话 ID:丢弃该头,不发字面量
242
+ value = value.split('${sessionId}').join(sessionId)
243
+ }
244
+ merged[hName] = value
245
+ touched = true
246
+ }
247
+ }
248
+ return touched ? merged : null
249
+ }
250
+
251
+ function toPlainHeaders(headers) {
252
+ const out = {}
253
+ if (!headers) return out
254
+ try {
255
+ if (typeof headers.forEach === 'function') {
256
+ headers.forEach((v, k) => { out[String(k).toLowerCase()] = v })
257
+ } else if (Array.isArray(headers)) {
258
+ for (const [k, v] of headers) out[String(k).toLowerCase()] = v
259
+ } else {
260
+ for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = v
261
+ }
262
+ } catch {}
263
+ return out
264
+ }
265
+
266
+ function parseBody(body) {
267
+ try {
268
+ if (typeof body !== 'string') return null
269
+ const json = JSON.parse(body)
270
+ if (!json || typeof json !== 'object') return null
271
+ return {
272
+ model: typeof json.model === 'string' && json.model !== '' ? json.model : null,
273
+ prompt_cache_key: typeof json.prompt_cache_key === 'string' ? json.prompt_cache_key : null,
274
+ prompt_cache_retention: typeof json.prompt_cache_retention === 'string' ? json.prompt_cache_retention : null,
275
+ }
276
+ } catch { return null }
277
+ }
278
+
279
+ function setHeader(init, name, value) {
280
+ const headers = init.headers
281
+ if (headers && typeof headers.set === 'function') {
282
+ headers.set(name, value)
283
+ return
284
+ }
285
+ if (Array.isArray(headers)) {
286
+ const i = headers.findIndex(([k]) => String(k).toLowerCase() === name.toLowerCase())
287
+ if (i >= 0) headers[i][1] = value
288
+ else headers.push([name, value])
289
+ return
290
+ }
291
+ init.headers = headers || {}
292
+ init.headers[name] = value
293
+ }
294
+
295
+ function installFetchHook() {
296
+ if (globalThis[GUARD]) return
297
+ const debug = process.env.DSH_MH_DEBUG === '1'
298
+ const origFetch = globalThis.fetch
299
+ globalThis[GUARD] = origFetch
300
+ globalThis.fetch = async function modelHeadersFetch(input, init) {
301
+ try {
302
+ const url = typeof input === 'string' ? input : input?.url
303
+ const body = init?.body
304
+ const bodyFields = parseBody(body)
305
+ if (debug) {
306
+ void appendLog({ t: new Date().toISOString(), debug: 'fetch', url: String(url).slice(0, 120), bodyType: typeof body, model: bodyFields?.model ?? null, cacheKey: bodyFields?.prompt_cache_key ?? null, retention: bodyFields?.prompt_cache_retention ?? null, hasClientReqId: Boolean(toPlainHeaders(init?.headers)['x-client-request-id']) })
307
+ }
308
+ if (bodyFields) {
309
+ const lower = toPlainHeaders(init?.headers)
310
+ const extra = computeHeaders(lower, bodyFields)
311
+ if (extra) {
312
+ for (const [hName, value] of Object.entries(extra)) setHeader(init, hName, value)
313
+ void appendLog({
314
+ t: new Date().toISOString(),
315
+ model: bodyFields.model,
316
+ headers: extra,
317
+ })
318
+ }
319
+ }
320
+ } catch {}
321
+ return origFetch.call(this, input, init)
322
+ }
323
+ }
324
+
325
+ function uninstallFetchHook() {
326
+ const orig = globalThis[GUARD]
327
+ if (orig === undefined) return
328
+ if (globalThis.fetch.name === 'modelHeadersFetch') globalThis.fetch = orig
329
+ delete globalThis[GUARD]
330
+ }
331
+
332
+ // 钩子在模块导入时立即安装:任何 LLM 请求(含插件 apply 之前的)都能被拦截。
333
+ installFetchHook()
334
+
335
+ // ==================== 插件装配 ====================
336
+
337
+ export function apply(ctx) {
338
+ let watcher = null
339
+ let watchTimer = null
340
+
341
+ async function watchConfig() {
342
+ if (watcher) return
343
+ try {
344
+ watcher = fsWatch(path.dirname(CONFIG_FILE()), (event, filename) => {
345
+ const name = filename == null ? '' : String(filename)
346
+ const isRules = name === 'model-headers.json'
347
+ const isSettings = name === 'settings.yaml'
348
+ if (!isRules && !isSettings) return
349
+ clearTimeout(watchTimer)
350
+ watchTimer = setTimeout(() => {
351
+ if (isRules) void loadRules()
352
+ if (isSettings) void ensureCacheRetention()
353
+ }, 300)
354
+ })
355
+ } catch (e) {
356
+ console.warn(`[dsh-model-headers] 配置监听失败(外部编辑需手动刷新):${e2str(e)}`)
357
+ }
358
+ }
359
+
360
+ // ---- RPC ----
361
+ const rpcOk = (value) => ({ ok: true, value })
362
+ // 镜像 dsh-skill-mcp 的 RpcResult 形状;协议要求 error.code 走 discriminated union,
363
+ // 且 internal 分支 details 必须为 {}(bad-request 会要求 details.issues,不适用)
364
+ const rpcFail = (message) => ({ ok: false, error: { code: 'internal', message: String(message).slice(0, 300), details: {} } })
365
+
366
+ const endpoints = {
367
+ 'headers/list': { run: async () => ({ rules, configFile: CONFIG_FILE() }) },
368
+ 'models/list': { run: async () => ({ models: await listConfiguredModels() }) },
369
+ 'headers/save': {
370
+ run: async (p = {}) => {
371
+ const rule = p?.rule
372
+ if (!rule || typeof rule !== 'object') throw new Error('缺少 rule')
373
+ if (typeof rule.model !== 'string' || rule.model.trim() === '') throw new Error('模型通配符不能为空')
374
+ if (!rule.headers || typeof rule.headers !== 'object' || Object.keys(rule.headers).length === 0) {
375
+ throw new Error('至少需要一条请求头')
376
+ }
377
+ const clean = {
378
+ id: typeof rule.id === 'string' && rule.id !== '' ? rule.id : `r-${randomUUID()}`,
379
+ enabled: rule.enabled !== false,
380
+ model: rule.model.trim(),
381
+ headers: {},
382
+ }
383
+ for (const [k, v] of Object.entries(rule.headers)) {
384
+ if (typeof k === 'string' && k.trim() !== '' && typeof v === 'string') clean.headers[k.trim()] = v
385
+ }
386
+ const i = rules.findIndex((r) => r.id === clean.id)
387
+ if (i >= 0) rules[i] = clean
388
+ else rules.push(clean)
389
+ await saveRules()
390
+ return { rule: clean }
391
+ },
392
+ },
393
+ 'headers/remove': {
394
+ run: async (p = {}) => {
395
+ const before = rules.length
396
+ rules = rules.filter((r) => r.id !== p?.id)
397
+ if (rules.length === before) throw new Error(`未知规则 '${p?.id}'`)
398
+ await saveRules()
399
+ return { removed: p?.id }
400
+ },
401
+ },
402
+ 'headers/toggle': {
403
+ run: async (p = {}) => {
404
+ const rule = rules.find((r) => r.id === p?.id)
405
+ if (!rule) throw new Error(`未知规则 '${p?.id}'`)
406
+ rule.enabled = p?.enabled !== false
407
+ await saveRules()
408
+ return { rule }
409
+ },
410
+ },
411
+ }
412
+
413
+ // ---- 装配(apply 保持同步:加载/监听异步初始化,RPC 稍后即就绪) ----
414
+ void (async () => {
415
+ await loadRules()
416
+ await ensureCacheRetention()
417
+ await watchConfig()
418
+ console.log(`[dsh-model-headers] host ready: ${rules.length} rule(s), rpc '${RPC_CHANNEL}'`)
419
+ })()
420
+
421
+ // connection 是 web profile 的服务,headless 没有 → 可选获取,取不到就跳过 RPC
422
+ //(fetch 钩子与配置文件热加载不依赖它)。ctx.get 绕过 inject 要求直读服务店。
423
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
424
+ void (async () => {
425
+ let connection
426
+ for (let attempt = 0; attempt < 10; attempt++) {
427
+ connection = ctx.get?.('connection')
428
+ if (connection?.rpc?.handle) break
429
+ connection = undefined
430
+ await sleep(1000)
431
+ }
432
+ if (!connection?.rpc?.handle) {
433
+ console.log('[dsh-model-headers] 无 connection 服务(headless):RPC 关闭,注入钩子照常工作')
434
+ return
435
+ }
436
+ connection.rpc.handle(RPC_CHANNEL, async (endpoint, payload) => {
437
+ try {
438
+ const action = endpoints[endpoint]
439
+ if (action === undefined) return rpcFail(`unknown endpoint "${endpoint}"`)
440
+ return rpcOk(await action.run(payload ?? {}))
441
+ } catch (e) {
442
+ return rpcFail(e2str(e))
443
+ }
444
+ }, { authority: 'loopback' })
445
+ console.log(`[dsh-model-headers] rpc '${RPC_CHANNEL}' ready`)
446
+ })()
447
+
448
+ // 文件监听随插件卸载关闭;fetch 钩子恢复原状(配合热重载)。
449
+ ctx.effect(() => () => {
450
+ clearTimeout(watchTimer)
451
+ watcher?.close()
452
+ watcher = null
453
+ uninstallFetchHook()
454
+ })
455
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "dsh-model-headers",
3
+ "version": "0.2.0",
4
+ "description": "Per-model custom request headers: inject headers into LLM requests matched by model-name wildcard; values support fixed strings and the ${sessionId} variable. Managed from a settings-page UI.",
5
+ "license": "BSD-3-Clause",
6
+ "homepage": "https://github.com/LvanLa/deepseek-harness-ex/tree/master/plugins/dsh-model-headers",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/LvanLa/deepseek-harness-ex.git",
10
+ "directory": "plugins/dsh-model-headers"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/LvanLa/deepseek-harness-ex/issues"
14
+ },
15
+ "keywords": [
16
+ "dsh",
17
+ "dsh-plugin",
18
+ "deepseek-harness",
19
+ "headers",
20
+ "prompt-cache"
21
+ ],
22
+ "type": "module",
23
+ "main": "lib/index.js",
24
+ "exports": {
25
+ ".": "./lib/index.js",
26
+ "./client": "./lib/client.js",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib/index.js",
31
+ "lib/client.js",
32
+ "cordis.patch.yml",
33
+ "README.md"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web",
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-runtime",
43
+ "@deepseek-ai/dsh-client-connection",
44
+ "@deepseek-ai/dsh-client-locale",
45
+ "@deepseek-ai/dsh-client-ui-slots"
46
+ ]
47
+ }
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.1",
51
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2"
55
+ }
56
+ }