dsh-router-core 0.4.5 → 0.4.7

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 (75) hide show
  1. package/README.md +152 -12
  2. package/docs/ext.md +1 -1
  3. package/docs/protocols.md +198 -0
  4. package/docs/suppliers.md +2 -2
  5. package/lib/client-registry.js +146 -106
  6. package/lib/client.js +146 -106
  7. package/lib/ext-prompt/index.js +182 -17
  8. package/lib/index.js +2764 -63
  9. package/lib/suppliers/opencode/index.js +58 -30
  10. package/lib/types/ext/contract.d.ts +20 -1
  11. package/lib/types/ext-prompt/content.d.ts +7 -1
  12. package/lib/types/ext-prompt/mount.d.ts +12 -1
  13. package/lib/types/ext-prompt/plugin.d.ts +10 -0
  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/account-pool.d.ts +7 -2
  26. package/lib/types/router/index.d.ts +42 -1
  27. package/lib/types/router/types.d.ts +26 -3
  28. package/lib/types/router/usage-store.d.ts +4 -2
  29. package/lib/types/shared.d.ts +24 -0
  30. package/lib/types/suppliers/contract.d.ts +15 -1
  31. package/package.json +6 -6
  32. package/src/client/ExtDetail.tsx +25 -5
  33. package/src/client/group-controls.test.ts +53 -0
  34. package/src/client/group-controls.ts +43 -0
  35. package/src/client/router.css +20 -1
  36. package/src/ext/contract.ts +20 -1
  37. package/src/ext-prompt/assembly.test.ts +63 -0
  38. package/src/ext-prompt/content.ts +8 -10
  39. package/src/ext-prompt/ext-prompt.test.ts +38 -28
  40. package/src/ext-prompt/index.ts +50 -2
  41. package/src/ext-prompt/mount.test.ts +18 -1
  42. package/src/ext-prompt/mount.ts +22 -7
  43. package/src/ext-prompt/plugin.ts +64 -1
  44. package/src/ext-prompt/render.test.ts +1 -1
  45. package/src/ext-prompt/render.ts +17 -6
  46. package/src/ext-prompt/section-controls.test.ts +148 -0
  47. package/src/ext-prompt/section-filter.test.ts +140 -0
  48. package/src/ext-prompt/sections.test.ts +123 -0
  49. package/src/ext-prompt/sections.ts +193 -0
  50. package/src/ext-route.test.ts +16 -0
  51. package/src/index.ts +117 -6
  52. package/src/llm/adapter.test.ts +54 -0
  53. package/src/llm/adapter.ts +24 -2
  54. package/src/protocol/anthropic.ts +662 -0
  55. package/src/protocol/chat.ts +272 -0
  56. package/src/protocol/entry.ts +440 -0
  57. package/src/protocol/gemini.ts +548 -0
  58. package/src/protocol/index.ts +31 -0
  59. package/src/protocol/protocol.test.ts +728 -0
  60. package/src/protocol/responses.ts +551 -0
  61. package/src/protocol/sse.ts +94 -0
  62. package/src/protocol/stream.ts +195 -0
  63. package/src/protocol/types.ts +249 -0
  64. package/src/protocol/wire-e2e.test.ts +208 -0
  65. package/src/protocol/wiring.test.ts +225 -0
  66. package/src/router/account-pool.test.ts +35 -0
  67. package/src/router/account-pool.ts +15 -4
  68. package/src/router/index.ts +143 -24
  69. package/src/router/types.ts +26 -3
  70. package/src/router/usage-store.ts +4 -2
  71. package/src/shared.test.ts +35 -0
  72. package/src/shared.ts +33 -0
  73. package/src/suppliers/contract.ts +15 -2
  74. package/src/suppliers/opencode/plugin.test.ts +79 -25
  75. package/src/suppliers/opencode/plugin.ts +60 -29
package/src/index.ts CHANGED
@@ -33,7 +33,7 @@
33
33
  import type { IncomingMessage, ServerResponse } from 'node:http'
34
34
  import { join } from 'node:path'
35
35
  import Schema from '@deepseek-ai/schemastery'
36
- import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
36
+ import { ROUTER_API_BASE, type RouterPeriod, clientIdentityOf } from './shared.ts'
37
37
  import { Router } from './router/index.ts'
38
38
  import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
39
39
  import { KeysStore } from './keys.ts'
@@ -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> => {
@@ -363,9 +390,9 @@ export function apply(rawContext: unknown): void {
363
390
  writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
364
391
  return
365
392
  }
366
- let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown }
393
+ let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown; user?: unknown; prompt_cache_key?: unknown }
367
394
  try {
368
- peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown }
395
+ peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown; user?: unknown; prompt_cache_key?: unknown }
369
396
  } catch {
370
397
  writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
371
398
  return
@@ -373,9 +400,12 @@ export function apply(rawContext: unknown): void {
373
400
  const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
374
401
  ? peek.reasoning_effort
375
402
  : 'auto'
376
- // 宿主会话身份(adapter 从 GenerateOptions.sessionId 转来的内部头);外部客户端没有。
403
+ // 调用方身份:宿主会话内部头(adapter 从 GenerateOptions.sessionId 转来)
404
+ // 优先;外部客户端没有内部头,用 user/prompt_cache_key/UA 兜底归因——
405
+ // 否则 usage 记录全是匿名,反查不出是哪个客户端在打。
377
406
  const sessionRaw = req.headers['x-dsh-router-session']
378
- const session = typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined
407
+ const session = (typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined)
408
+ ?? clientIdentityOf(peek, req.headers['user-agent'])
379
409
  await router.chatCompletions(
380
410
  {
381
411
  rawBody: body,
@@ -383,11 +413,92 @@ export function apply(rawContext: unknown): void {
383
413
  model: typeof peek.model === 'string' ? peek.model : '',
384
414
  lv,
385
415
  ...(session === undefined ? {} : { session }),
416
+ protocol: 'chat',
386
417
  },
387
418
  res,
388
419
  )
389
420
  }))
390
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,
463
+ },
464
+ res,
465
+ sink,
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'),
500
+ }))
501
+
391
502
  // ---- /router/api/* (panel, same-origin) ----
392
503
 
393
504
  route(`${ROUTER_API_BASE}/health`, (_req, res) => {
@@ -618,7 +729,7 @@ return Object.values(table as RouterExtService)
618
729
  controls: e.controls
619
730
  .filter((c): c is ExtControl =>
620
731
  !!c && typeof c.id === 'string' && c.id !== '' && typeof c.title === 'string')
621
- .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 })),
622
733
  }
623
734
  : {}),
624
735
  ...(st?.detail !== undefined ? { detail: st.detail } : {}),
@@ -156,6 +156,60 @@ test('出口:空的 finish_reason 不许冲掉后面那个真终止原因', as
156
156
  assert.deepEqual(reason, { kind: 'tool-calls' })
157
157
  })
158
158
 
159
+ test('出口:上游报 stop 却发了完整工具调用时,必须以 tool-calls 收尾', async () => {
160
+ // 真实事故(2026-10-15,traework 上游 + Delta 客户端):带 tools 的请求上,
161
+ // 上游正常发出完整的 tool_calls(参数逐片拼全),收尾帧却写
162
+ // `finish_reason: "stop"` —— 与 OpenAI 契约不符(有工具调用应为 tool_calls)。
163
+ //
164
+ // 修复前 mapFinishReason('stop') 直接给 kind='stop',而「无内容」兜底又因
165
+ // 为有内容不触发,于是已攒好的工具块被静默丢弃;调用方据此认为「本轮结束、
166
+ // 没有工具要执行」,表现为「模型能对话但工具永远调不动」(Delta 侧即
167
+ // 「test 能访问但 tool 调用不了」)。
168
+ const reason = await finishOf(
169
+ frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
170
+ frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
171
+ '[DONE]',
172
+ )
173
+ assert.deepEqual(reason, { kind: 'tool-calls' })
174
+ })
175
+
176
+ test('出口:stop + 工具调用时,工具块本身也要照常交出(不能只改收尾)', async () => {
177
+ const calls = await toolCallsOf(
178
+ frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
179
+ frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
180
+ '[DONE]',
181
+ )
182
+ assert.deepEqual(calls, [{ type: 'tool-call', id: 'call_1', name: 'bash', arguments: '{"command":"ls"}' }])
183
+ })
184
+
185
+ test('出口:stop 且没有工具调用时,仍然收尾为 stop(不能误报 tool-calls)', async () => {
186
+ // 反向判据:修复不能把普通对话也标成 tool-calls,否则调用方会空等工具结果。
187
+ const reason = await finishOf(frame({ choices: [{ delta: { content: 'hello' }, finish_reason: 'stop' }] }), '[DONE]')
188
+ assert.deepEqual(reason, { kind: 'stop' })
189
+ })
190
+
191
+ test('出口:length 截断即使带了工具调用也不能改成 tool-calls', async () => {
192
+ // `length` 必须原样透传:dsh-llm 的 assembled() 靠 `finish.kind === 'max-tokens'`
193
+ // 丢弃被截断的工具调用,改成 tool-calls 等于把半截调用放出去执行。
194
+ const reason = await finishOf(
195
+ frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
196
+ frame({ choices: [{ delta: {}, finish_reason: 'length' }] }),
197
+ '[DONE]',
198
+ )
199
+ assert.deepEqual(reason, { kind: 'max-tokens' })
200
+ })
201
+
202
+ test('出口:stop + 参数残缺的工具调用时,仍判 error(半成品不许当有效调用)', async () => {
203
+ // 与上一条互补:`stop` 只在工具块**完整**时才提升为 tool-calls。
204
+ // 参数残缺时既有逻辑已把 pendingFinish 改成 error,不能被这条修复绕过去。
205
+ const reason = await finishOf(
206
+ frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command": "cd' } }] } }] }),
207
+ frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
208
+ '[DONE]',
209
+ )
210
+ assert.equal(reason?.kind, 'error')
211
+ })
212
+
159
213
  test('出口:未知终止原因必须带非空 code(agent-loop 会拿它重建 LlmError)', async () => {
160
214
  const reason = await finishOf(frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'content_filter' }] }), '[DONE]')
161
215
  assert.equal(reason?.kind, 'error')
@@ -444,13 +444,35 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
444
444
  const usage = toTokenUsage(accumulated)
445
445
  if (usage !== null) yield { type: 'usage', usage }
446
446
  }
447
+ // 上游报 `stop` **却发了工具调用**时,必须以 tool-calls 收尾。
448
+ //
449
+ // 为什么(2026-10-15 实测):traework 上游在带 tools 的请求上正常发出
450
+ // 完整的 tool_calls(参数逐片拼全),收尾帧却写 `finish_reason: "stop"`
451
+ // ——与 OpenAI 契约不符(有工具调用应为 `tool_calls`)。而 mapFinishReason
452
+ // 只看 reason 字面量,于是 kind='stop',下面那个 `order.length === 0` 的
453
+ // 兜底又因为「有内容」不触发,最终以 stop 收尾 → **已攒好的工具块被静默
454
+ // 丢弃**,调用方(Delta 等)据此认为「本轮结束、没有工具要执行」,表现为
455
+ // 「模型能对话但工具永远调不动」。
456
+ //
457
+ // 判据用 `complete` 而不是 `order`:只有**参数拼全**的工具块才配提升为
458
+ // tool-calls。注意在当前逻辑下两者等价——参数残缺时上面的 `pendingFinish`
459
+ // 已经把它改成 error,`reason.kind` 就不是 'stop',根本走不到这里;
460
+ // 仍写 `complete` 是因为语义直接(「完整的工具调用」),且不依赖那个
461
+ // 副作用:哪天 error 分支挪走或放宽,这里不会跟着把半成品放行。
462
+ //
463
+ // 只改 `stop`:`length`(截断)必须原样透传 —— dsh-llm 的 assembled()
464
+ // 正是靠 `finish.kind === 'max-tokens'` 去丢弃被截断的工具调用,
465
+ // 改掉它就等于把半截调用放出去执行。
466
+ const hasCompleteToolCall = complete.some((block) => block.kind === 'tool-call')
447
467
  const reason = pendingFinish ?? { kind: 'stop' }
468
+ const resolved =
469
+ reason.kind === 'stop' && hasCompleteToolCall ? { kind: 'tool-calls' } : reason
448
470
  yield {
449
471
  type: 'finish',
450
472
  reason:
451
- reason.kind === 'stop' && order.length === 0
473
+ resolved.kind === 'stop' && order.length === 0
452
474
  ? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
453
- : (reason as never),
475
+ : (resolved as never),
454
476
  }
455
477
  return
456
478
  }