dsh-router-core 0.4.6 → 0.4.8

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.
Files changed (94) hide show
  1. package/README.md +156 -76
  2. package/cordis.patch.yml +2 -7
  3. package/docs/ext.md +4 -18
  4. package/docs/protocols.md +198 -0
  5. package/lib/client-registry.js +193 -668
  6. package/lib/client.js +193 -668
  7. package/lib/ext-prompt/index.js +202 -32
  8. package/lib/index.js +2805 -547
  9. package/lib/types/ext/contract.d.ts +20 -1
  10. package/lib/types/ext-prompt/content.d.ts +18 -1
  11. package/lib/types/ext-prompt/index.d.ts +7 -9
  12. package/lib/types/ext-prompt/mount.d.ts +14 -3
  13. package/lib/types/ext-prompt/plugin.d.ts +13 -5
  14. package/lib/types/ext-prompt/render.d.ts +14 -3
  15. package/lib/types/ext-prompt/sections.d.ts +95 -0
  16. package/lib/types/protocol/anthropic.d.ts +35 -0
  17. package/lib/types/protocol/chat.d.ts +25 -0
  18. package/lib/types/protocol/entry.d.ts +137 -0
  19. package/lib/types/protocol/gemini.d.ts +57 -0
  20. package/lib/types/protocol/index.d.ts +13 -0
  21. package/lib/types/protocol/responses.d.ts +41 -0
  22. package/lib/types/protocol/sse.d.ts +41 -0
  23. package/lib/types/protocol/stream.d.ts +92 -0
  24. package/lib/types/protocol/types.d.ts +224 -0
  25. package/lib/types/router/index.d.ts +42 -1
  26. package/lib/types/router/types.d.ts +19 -0
  27. package/lib/types/shared.d.ts +11 -0
  28. package/lib/types/suppliers/contract.d.ts +8 -0
  29. package/lib/types/suppliers/registry.d.ts +0 -1
  30. package/package.json +6 -15
  31. package/src/client/ExtDetail.tsx +26 -18
  32. package/src/client/group-controls.test.ts +53 -0
  33. package/src/client/group-controls.ts +43 -0
  34. package/src/client/index.tsx +0 -17
  35. package/src/client/router-components.test.ts +1 -1
  36. package/src/client/router.css +23 -355
  37. package/src/ext/contract.ts +20 -1
  38. package/src/ext-prompt/assembly.test.ts +63 -0
  39. package/src/ext-prompt/content.ts +25 -22
  40. package/src/ext-prompt/ext-prompt.test.ts +41 -31
  41. package/src/ext-prompt/index.ts +57 -11
  42. package/src/ext-prompt/mount.test.ts +18 -1
  43. package/src/ext-prompt/mount.ts +24 -9
  44. package/src/ext-prompt/plugin.ts +67 -6
  45. package/src/ext-prompt/render.test.ts +1 -1
  46. package/src/ext-prompt/render.ts +17 -6
  47. package/src/ext-prompt/section-controls.test.ts +148 -0
  48. package/src/ext-prompt/section-filter.test.ts +140 -0
  49. package/src/ext-prompt/sections.test.ts +123 -0
  50. package/src/ext-prompt/sections.ts +193 -0
  51. package/src/ext-route.test.ts +16 -0
  52. package/src/index.ts +109 -1
  53. package/src/protocol/anthropic.ts +662 -0
  54. package/src/protocol/chat.ts +272 -0
  55. package/src/protocol/entry.ts +440 -0
  56. package/src/protocol/gemini.ts +548 -0
  57. package/src/protocol/index.ts +31 -0
  58. package/src/protocol/protocol.test.ts +728 -0
  59. package/src/protocol/responses.ts +551 -0
  60. package/src/protocol/sse.ts +94 -0
  61. package/src/protocol/stream.ts +195 -0
  62. package/src/protocol/types.ts +249 -0
  63. package/src/protocol/wire-e2e.test.ts +208 -0
  64. package/src/protocol/wiring.test.ts +225 -0
  65. package/src/router/index.ts +144 -26
  66. package/src/router/models-cache.test.ts +5 -6
  67. package/src/router/supplier-switch.test.ts +1 -1
  68. package/src/router/types.ts +19 -0
  69. package/src/shared.ts +11 -0
  70. package/src/suppliers/contract.ts +8 -0
  71. package/src/suppliers/registry.ts +0 -71
  72. package/lib/contract-D4BWZ7Xe.js +0 -13
  73. package/lib/ext-test/index.js +0 -36
  74. package/lib/ext-test/locale/en.json +0 -6
  75. package/lib/ext-test/locale/zh.json +0 -6
  76. package/lib/ext-test/package.json +0 -5
  77. package/lib/types/ext-test/index.d.ts +0 -21
  78. package/lib/types/ext-test/plugin.d.ts +0 -23
  79. package/lib/types/suppliers/probe.d.ts +0 -160
  80. package/src/client/ExtTestPanel.tsx +0 -392
  81. package/src/client/comp-button-scope.test.ts +0 -58
  82. package/src/client/comp-panel-layout.test.ts +0 -58
  83. package/src/client/ext-panels.test.ts +0 -44
  84. package/src/client/ext-panels.ts +0 -49
  85. package/src/client/model-choice.test.ts +0 -41
  86. package/src/client/model-choice.ts +0 -37
  87. package/src/ext-test/ext-test.test.ts +0 -60
  88. package/src/ext-test/index.ts +0 -35
  89. package/src/ext-test/locale/en.json +0 -6
  90. package/src/ext-test/locale/zh.json +0 -6
  91. package/src/ext-test/plugin.ts +0 -34
  92. package/src/ext-test/row.json +0 -5
  93. package/src/suppliers/probe.test.ts +0 -266
  94. package/src/suppliers/probe.ts +0 -475
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 系统提示词**段落级**总控的纯逻辑与清单缓存 —— 把整体系统提示词拆成
3
+ * 每一段(`harness:identity`、`tool:write`、`plan:policy` …),面板分组列出、
4
+ * 逐段开关,关掉的段在装配时被过滤掉(`index.ts` 的 waterfall)。
5
+ *
6
+ * ════════════════════════════════════════════════════════════════════
7
+ * 清单从哪来:`assemble()` 的真实产物,不写死宿主段名表
8
+ * ════════════════════════════════════════════════════════════════════
9
+ * 段名只有个别是宿主导出常量(`harness:identity`、`deployment:persona-*`),
10
+ * 其余(`tool:*`、`mcp:*`、第三方插件段)都埋在各自插件里 —— 写死清单必然漂移。
11
+ * 装配 waterfall(`system-prompt/assemble`)每轮都携带**全量已排序段落**,
12
+ * 顺路缓存一份就是面板的事实源;apply 时预热一次 assemble,没开始对话也有清单。
13
+ *
14
+ * ⚠️ 清单是**最近一次装配的快照**(近似):`assemble()` 不带 scope 时只含
15
+ * global 段,真实对话是 agent scope;且多会话并发时缓存归"最后一次装配"。
16
+ * 面板展示可接受,**开关判定不受影响** —— 过滤按段名,名字缺席时无害跳过。
17
+ *
18
+ * ════════════════════════════════════════════════════════════════════
19
+ * 开关的生效面:waterfall 删段,不碰别人的注册
20
+ * ════════════════════════════════════════════════════════════════════
21
+ * 宿主没有"禁用别人的段"的 API。两条路里选了 waterfall:`assemble()` 的
22
+ * 监听器返回值是权威结果(宿主源码 `return transformed`,complete 段除外),
23
+ * 删掉的段不会被恢复。同名覆盖(scoped section 返回 `''`)只适合"顶掉某一个
24
+ * 已知名字",对同层自己注册的段还会 throw —— 删段才是对全部段通用的开关。
25
+ */
26
+ import type { PromptExtData } from './render.ts'
27
+
28
+ /** 面板控制条 id 的段前缀(**传输层编码**;落盘用裸段名,不带前缀)。 */
29
+ export const SECTION_CONTROL_PREFIX = 'sec:'
30
+
31
+ /**
32
+ * 自己的准则段:**不进清单、不被过滤**。
33
+ *
34
+ * 它已有独立的开关体系(扩展总开关 + 14 个分类开关)——再列一条"段开关"
35
+ * 就是同一件事的第二个开关,两边状态会漂移;过滤它还会让分类开关看起来
36
+ * 失效(段没了,分类怎么切都看不到效果)。
37
+ */
38
+ export const OWN_SECTION_NAME = 'dsh-router-ext-prompt:guideline'
39
+
40
+ /**
41
+ * 段名前缀 → 面板分组名。**未知前缀原样当组名**(不硬编全部前缀):
42
+ * 宿主还会加新段(`web:`、`structured:`…),映射漏了顶多组名不雅,
43
+ * 条目照常列出照常开关 —— 映射表漂移不允许变成"段消失"。
44
+ */
45
+ /**
46
+ * 段名前缀 → 面板分组名。
47
+ *
48
+ * ⚠️ 键是**段名的第一段**(冒号前),不是"看起来像"的前缀。2026-10-21 实测
49
+ * 真宿主(3081,23 条 controls / 9 段)纠正过两处脑补:
50
+ * - `harness:source`(运行时上下文里 Harness 检出的位置)第一段是 `harness`,
51
+ * 按前缀映射会被并入「开场身份」—— 而它不是身份,是运行环境说明;
52
+ * - `app:web-surface`(Web GUI 说明)第一段是 `app`,与"界面与交付"无关。
53
+ * 所以 `harness` 刻意**不映射**:`harness:identity` 是身份、`harness:source` 不是,
54
+ * 靠第一段分不开 —— 让它们各自留下真实前缀(`harness` / `app`)比归错组诚实。
55
+ * `ui:` 才是界面与交付那一组(`ui:deliverable-file-references`)。
56
+ *
57
+ * 映射漏了或故意不映射的前缀,组名**原样显示前缀**、条目照常能列能开关 ——
58
+ * 映射表漂移不允许变成"段消失"。
59
+ */
60
+ const GROUP_LABELS: Record<string, string> = {
61
+ deployment: '部署人设',
62
+ plan: '计划模式',
63
+ team: '团队',
64
+ tool: '工具说明',
65
+ mcp: 'MCP 服务',
66
+ context: '运行上下文',
67
+ ui: '界面与交付',
68
+ }
69
+
70
+ /**
71
+ * 无冒号段名的映射(宿主有几个段名整名不带冒号)。
72
+ *
73
+ * `harness:identity` 才是开场身份,它带冒号走前缀表;这里只收无冒号的整名。
74
+ */
75
+ const WHOLE_NAME_LABELS: Record<string, string> = {
76
+ 'mcp-resource-servers': 'MCP 资源',
77
+ }
78
+
79
+ /** 一条已装配的段(面板清单用)。 */
80
+ export interface SectionEntry {
81
+ /** 段注册名(稳定;也是开关的落盘键)。 */
82
+ name: string
83
+ /** 面板副行显示的原文(装配时 provider 现算的结果,**未做 `{{变量}}` 插值**)。 */
84
+ text: string
85
+ }
86
+
87
+ /** `controlId`(`sec:…`)→ 段名;非段条目返回 null。 */
88
+ export function sectionNameOf(controlId: string): string | null {
89
+ return controlId.startsWith(SECTION_CONTROL_PREFIX) ? controlId.slice(SECTION_CONTROL_PREFIX.length) : null
90
+ }
91
+
92
+ /** 段名 → 面板控制条 id。 */
93
+ export function sectionControlId(name: string): string {
94
+ return SECTION_CONTROL_PREFIX + name
95
+ }
96
+
97
+ /**
98
+ * 段名的分组名。
99
+ *
100
+ * 查表顺序:**整名**(无冒号段名,如 `mcp-resource-servers`)→ **第一段前缀**
101
+ * (`tool:write` → `tool`)。两级都查不到 ⇒ 有冒号时用第一段原样当组名、
102
+ * 无冒号归「其他」。
103
+ */
104
+ export function groupLabelOf(name: string): string {
105
+ const whole = WHOLE_NAME_LABELS[name]
106
+ if (whole !== undefined) return whole
107
+ const i = name.indexOf(':')
108
+ if (i < 0) return '其他'
109
+ const head = name.slice(0, i)
110
+ return GROUP_LABELS[head] ?? head
111
+ }
112
+
113
+ /** 面板条目标题:去掉组前缀的部分(`tool:write` → `write`;无冒号 = 全名)。 */
114
+ export function sectionTitleOf(name: string): string {
115
+ const i = name.indexOf(':')
116
+ return i >= 0 ? name.slice(i + 1) : name
117
+ }
118
+
119
+ /**
120
+ * 当前被关掉的段名集合。**只认显式 `false`** —— 缺项是"没碰过"(默认开),
121
+ * 与 `resolveEnabledCategories` 对 `undefined` 的处置同一纪律。
122
+ */
123
+ export function disabledSectionNames(data: PromptExtData | undefined): Set<string> {
124
+ const out = new Set<string>()
125
+ const saved = data?.sections
126
+ if (!saved) return out
127
+ for (const [name, on] of Object.entries(saved)) if (on === false) out.add(name)
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * 过滤掉关闭的段(保序)。自己那段**恒放行**(理由见 `OWN_SECTION_NAME`)。
133
+ *
134
+ * ⚠️ 名字不在清单里的关闭项静默跳过:宿主段随版本增删,旧数据里的名字
135
+ * 失效是正常生命周期,报错只会把面板变成红字噪音。
136
+ */
137
+ export function filterSections<T extends { name: string }>(sections: readonly T[], off: ReadonlySet<string>): T[] {
138
+ // ⚠️ 条件是 `===` **或** 没被关 —— 我第一版写成 `!== OWN && !off.has(...)`,
139
+ // 把"是自己的段"当成了排除条件:结果是**准则段每次都被删掉**,而
140
+ // tool:write 这些反倒留着。方向写反不会抛错,只会让面板看起来全开、
141
+ // prompt 里准则凭空消失(本条判据就是靠两个方向同时断言抓出来的)。
142
+ return sections.filter((s) => s.name === OWN_SECTION_NAME || !off.has(s.name))
143
+ }
144
+
145
+ /**
146
+ * 段清单缓存:waterfall 监听器是**唯一写入者**(装配路径顺路喂),
147
+ * 面板 controls getter 只读。分组与排序都在读取端做 —— 缓存保持
148
+ * 装配原序(`assemble` 已按 order/name 排好),组的次序自然等于 prompt 次序。
149
+ */
150
+ export function createSectionCache(): {
151
+ /** 用一次装配结果刷新(整表替换;自己的段不进清单)。 */
152
+ update: (sections: readonly { name: string; text: string }[]) => void
153
+ /** 当前清单(拷贝,防调用方改缓存内部)。 */
154
+ list: () => SectionEntry[]
155
+ } {
156
+ let entries: SectionEntry[] = []
157
+ return {
158
+ update(sections) {
159
+ entries = sections
160
+ .filter((s) => s.name !== OWN_SECTION_NAME)
161
+ .map((s) => ({ name: s.name, text: s.text }))
162
+ },
163
+ list() {
164
+ return entries.map((e) => ({ ...e }))
165
+ },
166
+ }
167
+ }
168
+
169
+ /** 一个分组(组名 + 组内条目,组内保持清单序)。 */
170
+ export interface SectionGroup {
171
+ label: string
172
+ items: SectionEntry[]
173
+ }
174
+
175
+ /**
176
+ * 按段名前缀分组;**组的先后 = 首次出现序**(清单已按 prompt 序 ⇒ 组序即
177
+ * 段落在 prompt 里的先后)——不按字母重排,面板顺序与模型看到的顺序一致。
178
+ */
179
+ export function groupSections(list: readonly SectionEntry[]): SectionGroup[] {
180
+ const out: SectionGroup[] = []
181
+ const byLabel = new Map<string, SectionGroup>()
182
+ for (const item of list) {
183
+ const label = groupLabelOf(item.name)
184
+ let g = byLabel.get(label)
185
+ if (!g) {
186
+ g = { label, items: [] }
187
+ byLabel.set(label, g)
188
+ out.push(g)
189
+ }
190
+ g.items.push(item)
191
+ }
192
+ return out
193
+ }
@@ -76,3 +76,19 @@ test('★ src 下不许有备份/临时文件(它们会被 `git add -A` 顺手
76
76
  .filter((f) => /\.(bak|orig|rej|tmp|swp)$/.test(f) || /~$/.test(f))
77
77
  assert.deepEqual(junk, [], `源码目录里有临时/备份文件:${junk.join(', ')}`)
78
78
  })
79
+
80
+ // ── controls 序列化白名单(2026-10-21 段落总控)──────────────────
81
+ test('★ controls 透传 group / sortable(白名单漏字段 ⇒ 面板分组永远不生效)', () => {
82
+ // ⚠️ 这里存在一个**只在入口才暴露**的失效:`GET /ext` 用 `.map()` 逐字段
83
+ // 重建 controls(白名单),扩展在 `controls` 里给了 `group`,但白名单
84
+ // 不认它 ⇒ 字段被静默丢掉。而单测(直接读扩展对象的 `.controls`)**全绿**:
85
+ // 扩展那头确实给了。两端都"对",中间少一环 —— 只能在这里守。
86
+ //
87
+ // 判据用源码形状而非启服务器:与本文件既有做法一致(理由见文件头)。
88
+ const mapLine = src.split('\n').find((l) => l.includes('.map((c) => ({ id: c.id, title: c.title' ))
89
+ assert.ok(mapLine !== undefined, '找不到 controls 的序列化 map(形状变了 ⇒ 本判据要跟着改)')
90
+ assert.match(mapLine, /\.\.\.\(c\.group !== undefined \? \{ group: c\.group \}/,
91
+ 'controls 序列化没透传 group ⇒ 段落分组在面板上永远不出现')
92
+ assert.match(mapLine, /\.\.\.\(c\.sortable !== undefined \? \{ sortable: c\.sortable \}/,
93
+ 'controls 序列化没透传 sortable ⇒ 段落行仍可拖(假交互)')
94
+ })
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ import { loopbackBaseURL } from './host-base-url.ts'
47
47
  import { detectHostVersion, isHostSupported } from './host-version.ts'
48
48
  import { ExtStore } from './ext/store.ts'
49
49
  import type { ExtControl, ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
50
+ import { geminiModelFromPath, geminiStreamFromPath, lvOf, outboundSink, parseInbound } from './protocol/entry.ts'
50
51
 
51
52
  /**
52
53
  * Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
@@ -138,6 +139,24 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
138
139
  res.end(payload)
139
140
  }
140
141
 
142
+ /**
143
+ * 每种协议的 error body 形状。
144
+ *
145
+ * 为什么必须有:非 chat 入口的 400/413/501 若照 OpenAI 的
146
+ * `{error:{message,type,code}}` 写,Claude Code 解析不到 `error.message`
147
+ * 会显示「未知错误」——用户看到的是一个空错误,而真因(如 body 太大)
148
+ * 就在手边却送不出去。
149
+ */
150
+ function errBodyFor(protocol: 'anthropic' | 'responses' | 'gemini', message: string): unknown {
151
+ if (protocol === 'anthropic') {
152
+ return { type: 'error', error: { type: 'invalid_request_error', message } }
153
+ }
154
+ if (protocol === 'gemini') {
155
+ return { error: { code: 400, message, status: 'INVALID_ARGUMENT' } }
156
+ }
157
+ return { error: { message, type: 'api_error', code: 'invalid_request' } }
158
+ }
159
+
141
160
  function readBody(req: IncomingMessage, limit: number): Promise<string> {
142
161
  return new Promise((resolve, reject) => {
143
162
  const chunks: Buffer[] = []
@@ -334,6 +353,14 @@ export function apply(rawContext: unknown): void {
334
353
  })()
335
354
 
336
355
  // ---- /v1/* (OpenAI-compatible, Bearer auth via KeysStore) ----
356
+ //
357
+ // 四种协议共用同一套路由/账号池/用量统计,差别只在「怎么听懂请求、怎么回答」:
358
+ // /v1/chat/completions OpenAI Chat Completions
359
+ // /v1/messages, /v1/messages/count_tokens Anthropic Messages
360
+ // /v1/responses OpenAI Responses
361
+ // /v1beta/models/<m>:generateContent Google Gemini
362
+ // 入站一律先翻成规范请求(Chat Completions 形状)再进 Router,所以上面这些
363
+ // 端点**自动**获得组合回退、账号池选号、首字节预算与用量看板。
337
364
 
338
365
  const withV1Auth = (handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>) => {
339
366
  return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
@@ -386,9 +413,90 @@ export function apply(rawContext: unknown): void {
386
413
  model: typeof peek.model === 'string' ? peek.model : '',
387
414
  lv,
388
415
  ...(session === undefined ? {} : { session }),
416
+ protocol: 'chat',
417
+ },
418
+ res,
419
+ )
420
+ }))
421
+
422
+ /**
423
+ * Anthropic / Responses / Gemini 三个非 chat 入口的**公共前半段**:
424
+ * 鉴权 → 读 body → 翻成规范请求 → 交给 Router → 把响应翻回客户端协议。
425
+ *
426
+ * 抽出来是因为三者的差别只有「协议 id 是什么、模型名从哪取」,而鉴权/限流/
427
+ * 归因/错误形状必须完全一致 —— 各写一份就会出现「Anthropic 入口认了
428
+ * x-dsh-router-session、Gemini 入口不认」这种只能靠实测发现的漂移。
429
+ */
430
+ const enterProtocol = (protocol: 'anthropic' | 'responses' | 'gemini') => withV1Auth(async (req, res) => {
431
+ const parsed = await parseInbound(req, protocol, {
432
+ limitBytes: 8 << 20,
433
+ modelFromPath: protocol === 'gemini' ? geminiModelFromPath(req.url ?? '') : undefined,
434
+ // Gemini 的「流不流式」在 URL 尾部(:streamGenerateContent vs
435
+ // :generateContent),body 里没有 stream 字段 —— 不认它,流式请求会被
436
+ // 当成非流式返回一整个 JSON,客户端按 SSE 解析就是空响应。
437
+ streamFromPath: protocol === 'gemini' ? geminiStreamFromPath(req.url ?? '') : undefined,
438
+ lvFromBody: (body) => lvOf(body, protocol),
439
+ })
440
+ if ('error' in parsed) {
441
+ writeJson(res, parsed.status, errBodyFor(protocol, parsed.error))
442
+ return
443
+ }
444
+ if (parsed.model === '') {
445
+ writeJson(res, 400, errBodyFor(protocol, 'missing model'))
446
+ return
447
+ }
448
+ const sessionRaw = req.headers['x-dsh-router-session']
449
+ const session = (typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined)
450
+ ?? clientIdentityOf(parsed.body, req.headers['user-agent'])
451
+ // 出口:Router 只会写 OpenAI 形态,sink 负责翻成客户端协议再写给 res。
452
+ // sink 是**透传式中继**,Router 那套「客户端断开就取消上游流」的逻辑
453
+ // 照旧生效 —— 它监听的是真实 res 的 close,与本层无关。
454
+ const sink = outboundSink(protocol, parsed.model, res)
455
+ await router.chatCompletions(
456
+ {
457
+ rawBody: JSON.stringify(parsed.body),
458
+ stream: parsed.stream,
459
+ model: parsed.model,
460
+ lv: parsed.lv,
461
+ ...(session === undefined ? {} : { session }),
462
+ protocol,
389
463
  },
390
464
  res,
465
+ sink,
391
466
  )
467
+ })
468
+
469
+ // Anthropic Messages(Claude Code 只说这种协议)。
470
+ // count_tokens 是「问一句要多少 token」:没有本地 tokenizer 就不能诚实回答,
471
+ // 而跑一次上游只数 token 是要花钱的假精确 —— 直接 501,别编数字。
472
+ route('/v1/messages', enterProtocol('anthropic'))
473
+ route('/v1/messages/count_tokens', withV1Auth(async (_req, res) => {
474
+ writeJson(res, 501, {
475
+ type: 'error',
476
+ error: { type: 'not_supported', message: 'count_tokens is not supported; send the message instead' },
477
+ })
478
+ }))
479
+
480
+ // OpenAI Responses(Codex 用)。
481
+ route('/v1/responses', enterProtocol('responses'))
482
+
483
+ // Google Gemini。Gemini 的模型名在 **URL 路径**里
484
+ // (`/v1beta/models/<model>:generateContent`),所以走 prefix 路由自己解析
485
+ // `req.url`(webServer 的 exact 匹配不吃 `:param` 语法,而模型名本身可含
486
+ // 斜杠,逐段穷举不现实)。流式/非流式靠路径尾部的
487
+ // `:streamGenerateContent` / `:generateContent` 区分,已在
488
+ // `geminiModelFromPath` 与 `parseInbound` 里落到 `stream` 上。
489
+ //
490
+ // ⚠️ **prefix 不能带尾斜杠**:宿主 `Table.match()` 的判据是
491
+ // `pathname === prefix || pathname.startsWith(prefix + '/')`
492
+ // 注册 `'/v1beta/models/'` 会让实际请求去比 `'/v1beta/models//'`,
493
+ // **永不匹配** → 落到宿主兜底,表现为 GET 404 / POST 405(2026-10-21 实测
494
+ // 在 3080 上复现)。注册成不带斜杠的 `'/v1beta/models'`,
495
+ // `startsWith('/v1beta/models/')` 才成立。
496
+ disposers.push(ctx.webServer.register({
497
+ kind: 'prefix',
498
+ path: '/v1beta/models',
499
+ handler: enterProtocol('gemini'),
392
500
  }))
393
501
 
394
502
  // ---- /router/api/* (panel, same-origin) ----
@@ -621,7 +729,7 @@ return Object.values(table as RouterExtService)
621
729
  controls: e.controls
622
730
  .filter((c): c is ExtControl =>
623
731
  !!c && typeof c.id === 'string' && c.id !== '' && typeof c.title === 'string')
624
- .map((c) => ({ id: c.id, title: c.title, on: c.on === true, ...(c.detail !== undefined ? { detail: c.detail } : {}), ...(typeof c.body === 'string' ? { body: c.body } : {}), ...(c.editable === true ? { editable: true } : {}), custom: c.custom === true, overridden: c.overridden === true })),
732
+ .map((c) => ({ id: c.id, title: c.title, on: c.on === true, ...(c.detail !== undefined ? { detail: c.detail } : {}), ...(typeof c.body === 'string' ? { body: c.body } : {}), ...(c.editable === true ? { editable: true } : {}), ...(c.group !== undefined ? { group: c.group } : {}), ...(c.sortable !== undefined ? { sortable: c.sortable } : {}), custom: c.custom === true, overridden: c.overridden === true })),
625
733
  }
626
734
  : {}),
627
735
  ...(st?.detail !== undefined ? { detail: st.detail } : {}),