@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.2

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 (238) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/LICENSE +21 -0
  3. package/README.md +181 -2
  4. package/config/edge.example.json +179 -0
  5. package/config/privacy-policy.json +202 -0
  6. package/cordis.desktop-safe.patch.yml +129 -0
  7. package/cordis.patch.yml +382 -0
  8. package/cordis.yml +374 -0
  9. package/lib/attestation.d.ts +2 -0
  10. package/lib/attestation.d.ts.map +1 -0
  11. package/lib/attestation.js +2 -0
  12. package/lib/audit-log.d.ts +2 -0
  13. package/lib/audit-log.d.ts.map +1 -0
  14. package/lib/audit-log.js +2 -0
  15. package/lib/client.js +87 -0
  16. package/lib/cloud-llm-adapter.d.ts +2 -0
  17. package/lib/cloud-llm-adapter.d.ts.map +1 -0
  18. package/lib/cloud-llm-adapter.js +2 -0
  19. package/lib/dsh-adapter/attestation.d.ts +42 -0
  20. package/lib/dsh-adapter/attestation.d.ts.map +1 -0
  21. package/lib/dsh-adapter/attestation.js +40 -0
  22. package/lib/dsh-adapter/audit-log.d.ts +139 -0
  23. package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
  24. package/lib/dsh-adapter/audit-log.js +376 -0
  25. package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
  26. package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
  27. package/lib/dsh-adapter/cloud-egress.js +257 -0
  28. package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
  29. package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
  30. package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
  31. package/lib/dsh-adapter/contract.d.ts +80 -0
  32. package/lib/dsh-adapter/contract.d.ts.map +1 -0
  33. package/lib/dsh-adapter/contract.js +194 -0
  34. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
  35. package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
  36. package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
  37. package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
  38. package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
  39. package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
  40. package/lib/dsh-adapter/edge-config.d.ts +484 -0
  41. package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
  42. package/lib/dsh-adapter/edge-config.js +898 -0
  43. package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
  44. package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
  45. package/lib/dsh-adapter/edge-context-view.js +483 -0
  46. package/lib/dsh-adapter/effective-model.d.ts +85 -0
  47. package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
  48. package/lib/dsh-adapter/effective-model.js +171 -0
  49. package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
  50. package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
  51. package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
  52. package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
  53. package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
  54. package/lib/dsh-adapter/etscli-tools.js +393 -0
  55. package/lib/dsh-adapter/file-logger.d.ts +294 -0
  56. package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
  57. package/lib/dsh-adapter/file-logger.js +564 -0
  58. package/lib/dsh-adapter/function-call-external.d.ts +357 -0
  59. package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
  60. package/lib/dsh-adapter/function-call-external.js +1005 -0
  61. package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
  62. package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
  63. package/lib/dsh-adapter/host-egress-guard.js +213 -0
  64. package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
  65. package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
  66. package/lib/dsh-adapter/host-llm-trace.js +344 -0
  67. package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
  68. package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
  69. package/lib/dsh-adapter/http-api-routes.js +79 -0
  70. package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
  71. package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
  72. package/lib/dsh-adapter/http-api-sessions.js +173 -0
  73. package/lib/dsh-adapter/http-api.d.ts +221 -0
  74. package/lib/dsh-adapter/http-api.d.ts.map +1 -0
  75. package/lib/dsh-adapter/http-api.js +1811 -0
  76. package/lib/dsh-adapter/index.d.ts +62 -0
  77. package/lib/dsh-adapter/index.d.ts.map +1 -0
  78. package/lib/dsh-adapter/index.js +97 -0
  79. package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
  80. package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
  81. package/lib/dsh-adapter/input-guard-messages.js +136 -0
  82. package/lib/dsh-adapter/input-guard.d.ts +508 -0
  83. package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
  84. package/lib/dsh-adapter/input-guard.js +2174 -0
  85. package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
  86. package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
  87. package/lib/dsh-adapter/intent-prompts.js +97 -0
  88. package/lib/dsh-adapter/intent-router.d.ts +710 -0
  89. package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
  90. package/lib/dsh-adapter/intent-router.js +2802 -0
  91. package/lib/dsh-adapter/intent-signals.d.ts +208 -0
  92. package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
  93. package/lib/dsh-adapter/intent-signals.js +391 -0
  94. package/lib/dsh-adapter/invariant-install.d.ts +16 -0
  95. package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
  96. package/lib/dsh-adapter/invariant-install.js +15 -0
  97. package/lib/dsh-adapter/invariant.d.ts +16 -0
  98. package/lib/dsh-adapter/invariant.d.ts.map +1 -0
  99. package/lib/dsh-adapter/invariant.js +22 -0
  100. package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
  101. package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
  102. package/lib/dsh-adapter/llm-adapters.js +16 -0
  103. package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
  104. package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
  105. package/lib/dsh-adapter/llm-internal-caller.js +100 -0
  106. package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
  107. package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
  108. package/lib/dsh-adapter/llm-max-turns.js +127 -0
  109. package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
  110. package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
  111. package/lib/dsh-adapter/llm-trace-store.js +205 -0
  112. package/lib/dsh-adapter/llm-trace.d.ts +450 -0
  113. package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
  114. package/lib/dsh-adapter/llm-trace.js +463 -0
  115. package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
  116. package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
  117. package/lib/dsh-adapter/local-llm-adapter.js +787 -0
  118. package/lib/dsh-adapter/local-storage.d.ts +96 -0
  119. package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
  120. package/lib/dsh-adapter/local-storage.js +198 -0
  121. package/lib/dsh-adapter/model-router.d.ts +79 -0
  122. package/lib/dsh-adapter/model-router.d.ts.map +1 -0
  123. package/lib/dsh-adapter/model-router.js +107 -0
  124. package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
  125. package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
  126. package/lib/dsh-adapter/ohttp-gateway.js +32 -0
  127. package/lib/dsh-adapter/openai-sse.d.ts +85 -0
  128. package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
  129. package/lib/dsh-adapter/openai-sse.js +462 -0
  130. package/lib/dsh-adapter/pii-utils.d.ts +96 -0
  131. package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
  132. package/lib/dsh-adapter/pii-utils.js +324 -0
  133. package/lib/dsh-adapter/plugin-host.d.ts +38 -0
  134. package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
  135. package/lib/dsh-adapter/plugin-host.js +38 -0
  136. package/lib/dsh-adapter/plugin.d.ts +46 -0
  137. package/lib/dsh-adapter/plugin.d.ts.map +1 -0
  138. package/lib/dsh-adapter/plugin.js +100 -0
  139. package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
  140. package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
  141. package/lib/dsh-adapter/privacy-policy.js +391 -0
  142. package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
  143. package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
  144. package/lib/dsh-adapter/prompt-guard.js +872 -0
  145. package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
  146. package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
  147. package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
  148. package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
  149. package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
  150. package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
  151. package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
  152. package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
  153. package/lib/dsh-adapter/secure-bridge.js +35 -0
  154. package/lib/dsh-adapter/session-attribution.d.ts +70 -0
  155. package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
  156. package/lib/dsh-adapter/session-attribution.js +76 -0
  157. package/lib/dsh-adapter/sessions.d.ts +86 -0
  158. package/lib/dsh-adapter/sessions.d.ts.map +1 -0
  159. package/lib/dsh-adapter/sessions.js +179 -0
  160. package/lib/dsh-adapter/tcm.d.ts +55 -0
  161. package/lib/dsh-adapter/tcm.d.ts.map +1 -0
  162. package/lib/dsh-adapter/tcm.js +94 -0
  163. package/lib/dsh-adapter/td-bus.d.ts +87 -0
  164. package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
  165. package/lib/dsh-adapter/td-bus.js +196 -0
  166. package/lib/dsh-adapter/tool-runner.d.ts +185 -0
  167. package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
  168. package/lib/dsh-adapter/tool-runner.js +271 -0
  169. package/lib/dsh-adapter/tools.d.ts +118 -0
  170. package/lib/dsh-adapter/tools.d.ts.map +1 -0
  171. package/lib/dsh-adapter/tools.js +292 -0
  172. package/lib/dsh-adapter/types.d.ts +23 -0
  173. package/lib/dsh-adapter/types.d.ts.map +1 -0
  174. package/lib/dsh-adapter/types.js +16 -0
  175. package/lib/dsh-adapter/voice-input.d.ts +30 -0
  176. package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
  177. package/lib/dsh-adapter/voice-input.js +34 -0
  178. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
  179. package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
  180. package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
  181. package/lib/http-api.d.ts +2 -0
  182. package/lib/http-api.d.ts.map +1 -0
  183. package/lib/http-api.js +2 -0
  184. package/lib/index.d.ts +48 -0
  185. package/lib/index.d.ts.map +1 -0
  186. package/lib/index.js +49 -0
  187. package/lib/intent-router.d.ts +2 -0
  188. package/lib/intent-router.d.ts.map +1 -0
  189. package/lib/intent-router.js +2 -0
  190. package/lib/llm-adapters.d.ts +2 -0
  191. package/lib/llm-adapters.d.ts.map +1 -0
  192. package/lib/llm-adapters.js +2 -0
  193. package/lib/local-llm-adapter.d.ts +2 -0
  194. package/lib/local-llm-adapter.d.ts.map +1 -0
  195. package/lib/local-llm-adapter.js +2 -0
  196. package/lib/local-storage.d.ts +2 -0
  197. package/lib/local-storage.d.ts.map +1 -0
  198. package/lib/local-storage.js +2 -0
  199. package/lib/model-router.d.ts +2 -0
  200. package/lib/model-router.d.ts.map +1 -0
  201. package/lib/model-router.js +2 -0
  202. package/lib/ohttp-gateway.d.ts +2 -0
  203. package/lib/ohttp-gateway.d.ts.map +1 -0
  204. package/lib/ohttp-gateway.js +2 -0
  205. package/lib/plugin-host.d.ts +2 -0
  206. package/lib/plugin-host.d.ts.map +1 -0
  207. package/lib/plugin-host.js +2 -0
  208. package/lib/privacy-policy.d.ts +2 -0
  209. package/lib/privacy-policy.d.ts.map +1 -0
  210. package/lib/privacy-policy.js +2 -0
  211. package/lib/prompt-guard.d.ts +2 -0
  212. package/lib/prompt-guard.d.ts.map +1 -0
  213. package/lib/prompt-guard.js +2 -0
  214. package/lib/sanitizer-pipeline.d.ts +2 -0
  215. package/lib/sanitizer-pipeline.d.ts.map +1 -0
  216. package/lib/sanitizer-pipeline.js +2 -0
  217. package/lib/secure-bridge.d.ts +2 -0
  218. package/lib/secure-bridge.d.ts.map +1 -0
  219. package/lib/secure-bridge.js +2 -0
  220. package/lib/sessions.d.ts +2 -0
  221. package/lib/sessions.d.ts.map +1 -0
  222. package/lib/sessions.js +2 -0
  223. package/lib/tcm.d.ts +2 -0
  224. package/lib/tcm.d.ts.map +1 -0
  225. package/lib/tcm.js +2 -0
  226. package/lib/td-bus.d.ts +2 -0
  227. package/lib/td-bus.d.ts.map +1 -0
  228. package/lib/td-bus.js +2 -0
  229. package/lib/tools.d.ts +2 -0
  230. package/lib/tools.d.ts.map +1 -0
  231. package/lib/tools.js +2 -0
  232. package/lib/voice-input.d.ts +2 -0
  233. package/lib/voice-input.d.ts.map +1 -0
  234. package/lib/voice-input.js +2 -0
  235. package/package.json +253 -3
  236. package/presets/agent-preset-edge.json +50 -0
  237. package/presets/edge.desktop-safe.patch.yml +503 -0
  238. package/presets/edge.patch.yml +557 -0
@@ -0,0 +1,1472 @@
1
+ /**
2
+ * cloud-llm-adapter — registers a remote LLM provider (DeepSeek, etc).
3
+ * M1 ships a minimal self-contained DeepSeek Chat-Completions client:
4
+ * one HTTPS POST, one SSE parse, no retries, no idle watchdog, no
5
+ * secure-bridge routing (the latter comes in A4). The credentials
6
+ * are read from a process-env slot the operator names in
7
+ * `Config.apiKeyEnv` (default `DEEPSEEK_API_KEY`); the adapter never
8
+ * persists the key. Each request carries `attributionHeaders()` so
9
+ * upstream can correlate the call to the harness.
10
+ *
11
+ * Wire protocol: OpenAI-compatible chat-completions streaming endpoint
12
+ * (`{baseUrl}/chat/completions`). Adapter emits `text-delta` events
13
+ * for `content` deltas, `tool-call-delta` for function-call deltas,
14
+ * and a terminal `finish({kind})` derived from the stream's
15
+ * `finish_reason`.
16
+ *
17
+ * The body builder + SSE parser live in `./openai-sse.js` so the
18
+ * `local-llm-adapter` `mode: 'real'` path shares them.
19
+ *
20
+ * @module dsh-edge-agent/cloud-llm-adapter
21
+ */
22
+ import Schema from '@deepseek-ai/schemastery';
23
+ import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
24
+ import { buildChatCompletionsBody, extractText, iterateSseFrames, sseEventToChunk, sseFinishReason, terminalChunks, } from './openai-sse.js';
25
+ import { cloudPool, danglingSecretRefs, formatDanglingSecretRefs, loadEdgeConfig, } from './edge-config.js';
26
+ import { buildRequestTrace, buildResponseTrace, emitTrace, resolveLlmTraceConfig, } from './llm-trace.js';
27
+ import { bumpSessionToolCallCount, MAX_CONSECUTIVE_TOOL_CALLS, peekSessionSseRetryCount, resetSessionToolCallCount, resetSessionSseRetryCount, tryConsumeSseRetryBudget, } from './llm-max-turns.js';
28
+ import { planCloudEgress, } from './cloud-egress.js';
29
+ import { PII_ORDER, PII_PATTERNS } from './pii-utils.js';
30
+ import { egressAuditFields, internalCallerOf } from './llm-internal-caller.js';
31
+ import { sessionIdField } from './session-attribution.js';
32
+ import { getTraceStore, callIdFor } from './llm-trace-store.js';
33
+ // §7.171: 自有池名单的单一事实源(cloud-llm-adapter ↔ local-llm-adapter 互斥用),
34
+ // 以及「本地池转发」标记(打标方 = 本模块,读标方 = host-egress-guard)。
35
+ import { LOCAL_POOL_FORWARD_MARKER, OWN_POOL_PROVIDERS } from './host-llm-trace.js';
36
+ export const name = 'cloud-llm-adapter';
37
+ /**
38
+ * §7.152 (2026-10-29): 'sanitizer' + 'modelRouter' 加入 inject。
39
+ *
40
+ * 原来的类注释(§7.131)认为「apply() 时取不到的服务可以在运行时用
41
+ * `ctx as unknown as {...}` 取到」——**这是错的**:cordis 的 Context 是
42
+ * Proxy,`inject` 里没声明的服务在**运行时**访问一样会抛
43
+ * `cannot get property "X" without inject`。类型断言骗不过 Proxy。
44
+ *
45
+ * 实测(独立 HTTP profile,2026-10-29):CLOUD 直连池(baseUrl + keyRef)
46
+ * 的首次出网就死在这里:
47
+ * cloud-llm-adapter: egress preparation failed
48
+ * (cannot get property "modelRouter" without inject)
49
+ * —— refusing to send unscrubbed text
50
+ * 即:出网前准备器**取不到本地 provider 名单**(modelRouter)与**脱敏器**
51
+ * (sanitizer)⇒ 宁可不发也不发未脱敏的文本 ⇒ CLOUD 轮次全 502。
52
+ *
53
+ * 为什么之前没暴露:DELEGATE 模式(cloud 池留空、转交宿主 provider)走的是
54
+ * 另一条路径,不碰这两个服务;本仓库的默认部署一直是 DELEGATE。一旦把
55
+ * cloud 池配成**直连**(baseUrl + model + keyRef),这条路径才会走到。
56
+ *
57
+ * 无环性:sanitizer-pipeline 的 inject 是 [privacyPolicy, auditLog]、
58
+ * model-router 的 inject 是 [llm] —— 两个都不依赖本行,硬声明不会成环
59
+ * (inject 是「服务有没有」,不是「谁先谁后」)。
60
+ *
61
+ * ⚠️ 两种布局各取一半(与 §7.151 的关系):
62
+ * - 桌面 preset 布局:patch row 自带 `inject: [llm, auditLog]`,
63
+ * row 级声明**覆盖**这里的 TS export ⇒ TS 侧加了也没用,出网读取靠
64
+ * §7.151 的 `ctx.get()` 软取 + fallback 兜住(见 localProviders)。
65
+ * - 独立 HTTP profile(宿主树):insert row 不能带 inject(§7.101.x),
66
+ * 生效的就是**这份 TS 声明** ⇒ 本行是唯一可用的修法;已实机验证
67
+ * CLOUD 直连池 /v1/chat 200(sanitizer 直取在 row 覆盖不到的地方仍会炸,
68
+ * 所以这两项声明在宿主树布局是**必需**的,不是冗余)。
69
+ */
70
+ export const inject = ['llm', 'auditLog', 'inputGuard', 'sanitizer', 'modelRouter'];
71
+ /**
72
+ * §7.131 (2026-10-27): 出网准备器 —— 裁剪 + 脱敏。
73
+ *
74
+ * **为什么 ctx 在这里而不是 apply() 时取**:`cordis.patch.yml` 里
75
+ * cloud-llm-adapter(373) 排在 sanitizer-pipeline(506) **之前**,所以 apply()
76
+ * 执行的那一刻 `ctx.sanitizer` 还是 undefined。但 stream() 是 boot 完成后的
77
+ * 运行时调用 —— 那时所有插件都已 apply,ctx 完整。所以这里持有 ctx,在
78
+ * **运行时**再取 sanitizer。
79
+ *
80
+ * ⚠️ §7.152 修正:运行时取 ≠ 能取到。cordis 的 Context 是 Proxy,
81
+ * `inject` 未声明的服务在运行时访问照样抛
82
+ * `cannot get property "X" without inject`(类型断言绕不过 Proxy)。
83
+ * 宿主树布局里 `sanitizer` / `modelRouter` 已**声明进 inject**(见上方
84
+ * 注释);preset 布局里 row 级 inject 会覆盖 TS 声明,那边靠 §7.151 的
85
+ * `ctx.get()` 软取兜底。这里保留 ctx 是因为取到的必须是**运行时那一份实例**。
86
+ *
87
+ * 同理 `ctx.inputGuard` 也是运行时才拿得到 —— apply() 时取到的那个
88
+ * `inputGuard` 一直是 undefined(§7.17 的孤儿 tool_call 清洗在当前部署下
89
+ * 因此是**失效**的)。本轮不改那件事,只把脱敏需要的 sanitizer 运行时取到。
90
+ */
91
+ export class CloudEgressGuard {
92
+ ctx;
93
+ constructor(ctx) {
94
+ this.ctx = ctx;
95
+ }
96
+ /** 运行时取 sanitizer(apply 时拿不到,见类注释)。 */
97
+ sanitizer() {
98
+ return this.ctx.sanitizer;
99
+ }
100
+ /**
101
+ * 运行时取本地 provider 名单。
102
+ *
103
+ * 用 model-router 的 `listDetailed()`(带 realm 标注)—— 与路由判定同一份
104
+ * 事实源,避免「出网裁剪认为它是本地、路由认为它是云」这种口径漂移。
105
+ * model-router 在 patch 里排在 cloud-llm-adapter 之后,所以同样运行时取。
106
+ *
107
+ * §7.151 (2026-10-29): **必须走 `ctx.get()` 而不是 `ctx.modelRouter` 直取**。
108
+ * cordis 4.x 的 ctx 是 Proxy:读一个既没 provide、也不在生效 `inject` 里的
109
+ * 属性会**直接抛** `cannot get property "modelRouter" without inject`。
110
+ * preset 布局里 row 级 `inject: [llm, auditLog]` 覆盖 TS 声明(不含
111
+ * modelRouter),直取会让 §7.131 的裁剪在真机上抛错 ⇒ 出网准备失败 ⇒ 整轮
112
+ * 以 `SANITIZE_FAILED` 失败(裁剪从来就没跑起来过)。
113
+ * §7.152 注:宿主树 profile 的 TS inject 现已声明 modelRouter,直取合法;
114
+ * 这里的 `ctx.get()` + fallback 仍然保留 —— preset 布局只认它,且两种
115
+ * 布局共用这一份代码。
116
+ */
117
+ localProviders() {
118
+ let router;
119
+ try {
120
+ router = this.ctx.get?.('modelRouter');
121
+ }
122
+ catch {
123
+ router = undefined;
124
+ }
125
+ try {
126
+ const detailed = router?.listDetailed?.() ?? [];
127
+ const locals = detailed
128
+ .filter(d => d.realm === 'LOCAL' && typeof d.provider === 'string')
129
+ .map(d => d.provider);
130
+ if (locals.length > 0)
131
+ return locals;
132
+ }
133
+ catch { /* fall through to the default */ }
134
+ return ['local-llm'];
135
+ }
136
+ /**
137
+ * 裁剪到「只带云段历史 + 本轮」,并对保留块做可逆脱敏。
138
+ *
139
+ * 脱敏失败(占位符超限等)→ 抛错,**fail-closed**:宁可这一轮不出网,
140
+ * 也不放行半脱敏文本(与 sanitizer-pipeline 的既有语义一致)。
141
+ */
142
+ async prepare(messages, sessionId) {
143
+ const plan = planCloudEgress(messages, {
144
+ currentSegment: 'cloud',
145
+ localProviders: this.localProviders(),
146
+ });
147
+ const sanitizer = this.sanitizer();
148
+ if (!sanitizer) {
149
+ // 没有 sanitizer = 隐私栈没开(enableInputGuard=false 等)。裁剪仍然
150
+ // 执行(主人要求无条件成立),脱敏记审计 —— 不能假装脱过了。
151
+ return { messages: plan.kept, droppedCount: plan.droppedCount, replacements: 0, sanitized: false };
152
+ }
153
+ let replacements = 0;
154
+ const kept = await Promise.all(plan.kept.map(async (m) => {
155
+ // 先用同一批 PII 正则做**廉价预检**,只有疑似才真的调 sanitizer ——
156
+ // sanitizer.sanitize() 每次都要 sweepExpired(遍历全部 session mapping),
157
+ // 一条会话动辄几十上百条消息,逐条调会明显拖慢出网。
158
+ if (!mayContainPii(m.content))
159
+ return m;
160
+ const r = await this.sanitizeMessage(m, sanitizer, sessionId);
161
+ replacements += r.replacements;
162
+ return r.message;
163
+ }));
164
+ return { messages: kept, droppedCount: plan.droppedCount, replacements, sanitized: true };
165
+ }
166
+ async sanitizeMessage(message, sanitizer, sessionId) {
167
+ let count = 0;
168
+ const next = await mapMessageStringsAsync(message, async (text) => {
169
+ const result = await sanitizer.sanitize(text, sessionId ? { sessionId } : undefined);
170
+ count += result.replacements.length;
171
+ return result.sanitizedText;
172
+ });
173
+ return { message: next, replacements: count };
174
+ }
175
+ }
176
+ /** 内容里是否**可能**含 PII —— 预检,避免对每条消息都调 sanitizer。 */
177
+ function mayContainPii(content) {
178
+ const texts = [];
179
+ collectStrings(content, texts);
180
+ for (const t of texts) {
181
+ for (const name of PII_ORDER) {
182
+ // 正则带 g flag 时 lastIndex 有状态 —— 每次重建。
183
+ if (new RegExp(PII_PATTERNS[name], 'i').test(t))
184
+ return true;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+ function collectStrings(value, out) {
190
+ if (typeof value === 'string') {
191
+ out.push(value);
192
+ return;
193
+ }
194
+ if (Array.isArray(value)) {
195
+ for (const v of value)
196
+ collectStrings(v, out);
197
+ return;
198
+ }
199
+ if (value !== null && typeof value === 'object') {
200
+ for (const v of Object.values(value))
201
+ collectStrings(v, out);
202
+ }
203
+ }
204
+ /** mapMessageStrings 的 async 版本(脱敏是 async 的)。 */
205
+ async function mapMessageStringsAsync(message, fn) {
206
+ const next = { ...message };
207
+ next.content = await mapStringsAsync(message.content, fn);
208
+ return next;
209
+ }
210
+ async function mapStringsAsync(value, fn) {
211
+ if (typeof value === 'string')
212
+ return await fn(value);
213
+ if (Array.isArray(value)) {
214
+ const out = [];
215
+ for (const v of value)
216
+ out.push(await mapStringsAsync(v, fn));
217
+ return out;
218
+ }
219
+ if (value !== null && typeof value === 'object') {
220
+ const out = {};
221
+ for (const [k, v] of Object.entries(value)) {
222
+ out[k] = await mapStringsAsync(v, fn);
223
+ }
224
+ return out;
225
+ }
226
+ return value;
227
+ }
228
+ export const Config = Schema.object({
229
+ providers: Schema.array(Schema.string()).default(['cloud-llm']),
230
+ // §7.100: 默认空串 = delegate 模式哨兵。原默认 'https://api.deepseek.com'
231
+ // 会让"未配置云池"静默直连 DeepSeek(拿不到 key 才报错);现在未配置
232
+ // 即继承宿主 profile 的提供商,显式配置 baseUrl 才走自研直连。
233
+ baseUrl: Schema.string().default('').description('OpenAI-compatible endpoint base; empty = delegate to the host profile provider (§7.100).'),
234
+ apiKeyEnv: Schema.string().default('DEEPSEEK_API_KEY'),
235
+ defaultModel: Schema.string().default('deepseek-chat'),
236
+ temperature: Schema.number().description('Optional override for sample temperature.'),
237
+ delegateProvider: Schema.string().description('Delegate mode: explicit target provider route id.'),
238
+ delegateModel: Schema.string().description('Delegate mode: wire model override (beats host selection).'),
239
+ delegateEnabled: Schema.boolean().default(true).description('false = disable delegation; empty baseUrl then fails loudly.'),
240
+ });
241
+ /**
242
+ * Minimal DeepSeek-compatible adapter. Reads the bearer key lazily on
243
+ * each stream call so a config update takes effect without a re-register.
244
+ */
245
+ export class CloudLlmAdapterImpl extends LlmAdapter {
246
+ providersList;
247
+ baseUrl;
248
+ apiKeyEnv;
249
+ apiKey;
250
+ defaultModel;
251
+ temperature;
252
+ auditLog;
253
+ inputGuard;
254
+ logger;
255
+ /** §7.131: 出网准备器(裁剪云段 + 脱敏)。 */
256
+ egress;
257
+ constructor(config, auditLog, inputGuard, logger, egress) {
258
+ super();
259
+ this.providersList = [...config.providers];
260
+ this.baseUrl = config.baseUrl.replace(/\/+$/u, '');
261
+ this.apiKeyEnv = config.apiKeyEnv;
262
+ this.apiKey = config.apiKey;
263
+ this.defaultModel = config.defaultModel;
264
+ this.temperature = config.temperature;
265
+ this.auditLog = auditLog;
266
+ this.inputGuard = inputGuard;
267
+ this.logger = logger;
268
+ this.egress = egress;
269
+ }
270
+ audit(level, kind, payload) {
271
+ if (!this.auditLog)
272
+ return;
273
+ void this.auditLog.log({ level, kind, source: 'cloud-llm-adapter', payload }).catch(() => { });
274
+ }
275
+ /**
276
+ * §7.118: 当前生效的 LLM trace 配置。
277
+ *
278
+ * 每次调用现取,而不是在 apply() 里存一份 —— `loadEdgeConfig()` 自带
279
+ * mtime+size 指纹缓存,改了 config/edge.json 立刻生效,不用重启进程。
280
+ * 指纹命中时只付一次 statSync,相对一次 LLM 往返可以忽略。
281
+ */
282
+ traceConfig() {
283
+ return resolveLlmTraceConfig(loadEdgeConfig());
284
+ }
285
+ /**
286
+ * §7.118: 把详细的 request(messages + system + 参数)写进 ops log。
287
+ *
288
+ * 走 `info` 级别而不是 `debug` —— 运维出事之后 tail 的是 ops log,
289
+ * 不是 debug 日志。完整内容经过脱敏(凭据 + PII)与按字段截断,
290
+ * 详见 `./llm-trace.js`。
291
+ *
292
+ * 观测手段绝不能把主链路带崩:整段包在 try/catch 里。
293
+ */
294
+ traceRequest(sessionId, options, system, messages, rawBody) {
295
+ try {
296
+ const cfg = this.traceConfig();
297
+ if (!cfg.enabled || !cfg.includeRequest)
298
+ return;
299
+ const trace = buildRequestTrace({
300
+ sessionId,
301
+ provider: options.provider,
302
+ model: options.model ?? this.defaultModel,
303
+ baseUrl: this.baseUrl,
304
+ system,
305
+ messages,
306
+ tools: options.tools,
307
+ // §7.144: 真正发出去的 wire body(面板上要能核对「发了什么」)。
308
+ rawBody,
309
+ params: {
310
+ temperature: this.temperature,
311
+ maxTokens: options.maxTokens,
312
+ stream: true,
313
+ },
314
+ config: cfg,
315
+ });
316
+ this.emit('REQ', 'cloud-llm-adapter LLM_REQUEST', trace, options);
317
+ }
318
+ catch { /* 观测失败不拖垮主链路 */ }
319
+ }
320
+ /**
321
+ * §7.118: 把详细的 response(正文 + tool calls + finish reason)写进 ops log。
322
+ */
323
+ traceResponse(sessionId, provider, model, content, toolCalls, finishKind, durationMs, attempt, error, options, rawBody) {
324
+ try {
325
+ const cfg = this.traceConfig();
326
+ if (!cfg.enabled || !cfg.includeResponse)
327
+ return;
328
+ const trace = buildResponseTrace({
329
+ sessionId,
330
+ provider,
331
+ model,
332
+ attempt,
333
+ durationMs,
334
+ finishReason: finishKind,
335
+ content,
336
+ toolCalls,
337
+ // §7.144: 完整响应体快照。
338
+ rawBody,
339
+ error,
340
+ config: cfg,
341
+ });
342
+ this.emit('RESP', 'cloud-llm-adapter LLM_RESPONSE', trace, options);
343
+ }
344
+ catch { /* 观测失败不拖垮主链路 */ }
345
+ }
346
+ /**
347
+ * §7.133: trace 的统一出口 —— ops log + Edge 面板索引行 + 正文。
348
+ *
349
+ * 检测调用(安检 / 分类器)经 §7.132 的 `edgeInternalCaller` 标记带上
350
+ * caller —— 与主对话走的是同一段代码。
351
+ */
352
+ emit(kind, tag, trace, options) {
353
+ const sink = {
354
+ log: (entry) => this.auditLog?.log(entry) ?? Promise.resolve(),
355
+ logger: this.logger,
356
+ store: getTraceStore(),
357
+ };
358
+ // §7.132 的判据落到索引行:带工具 = 主对话,一件不带 = 内部调用。
359
+ const tools = options?.tools;
360
+ emitTrace(sink, kind, tag, trace, internalCallerOf(options), {
361
+ toolCount: Array.isArray(tools) ? tools.length : 0,
362
+ // §7.134: REQ 与 RESP 共用一个 callId —— 面板据此把两半配成一对。
363
+ // 重试时可能出现「失败 RESP + 成功 RESP」,它们共用同一个 callId,
364
+ // 面板取最后一个(那才是这次调用的最终结果)。
365
+ callId: callIdFor(options),
366
+ });
367
+ }
368
+ /** Mirror {@link CloudLlmAdapter.providers}; the upstream registry reads it. */
369
+ get providers() {
370
+ return [...this.providersList];
371
+ }
372
+ providerInfo(provider) {
373
+ return { id: provider, name: provider };
374
+ }
375
+ /**
376
+ * §7.106 Option A: catalog deliberately empty — the cloud-llm route is
377
+ * pipeline plumbing (http-api delegate egress, internal routing), not a
378
+ * picker choice; advertising it let users accidentally select
379
+ * `cloud-llm/deepseek-chat` and exposed INVALID_CATALOG failures.
380
+ * Routing is unaffected ("catalog membership does not constrain core
381
+ * routing", dsh-llm lib/index.js:2080) and compaction gets its 65536 from
382
+ * resolveModel's context (§7.43), which stays.
383
+ */
384
+ listModels(_provider) {
385
+ return Promise.resolve([]);
386
+ }
387
+ resolveModel(provider, model, _signal) {
388
+ // §7.43 (2026-09-07): see listModels comment — contextWindow must also
389
+ // surface through resolveModel so the runtime normalises it onto the
390
+ // active session's `info.context.contextWindow`. Without this the
391
+ // adapter's advertised contextWindow is ignored.
392
+ return Promise.resolve({
393
+ provider,
394
+ id: model,
395
+ name: model,
396
+ context: { contextWindow: 65536 },
397
+ });
398
+ }
399
+ async *stream(options) {
400
+ // 2026-09-02: per-call request/response body logging to ops log at
401
+ // debug level. CLOUD prompts are pre-sanitized by input-guard before
402
+ // egress so the captured prompt is already placeholder-text for
403
+ // sensitive spans; response text is NOT redacted. 2026-09-11 default
404
+ // flipped to `info` — these 4 body lines are filtered by Cordis and
405
+ // don't reach the ops log unless operator sets
406
+ // `DSH_EDGE_LOG_LEVEL=debug`. The lines below still gate on
407
+ // Cordis's per-exporter level filter — see file-logger.ts.
408
+ this.logger?.debug?.(`[cloud-llm-adapter] LLM_REQUEST provider=${options.provider} model=${options.model} messageCount=${options.messages.length} promptLen=${options.messages.reduce((n, m) => n + extractText(m.content).length, 0)}`);
409
+ // §7.102: prefer the literal key from config/edge.json; fall back to the
410
+ // deprecated env-slot lookup so an in-flight migration still authenticates.
411
+ const apiKey = this.apiKey ?? readApiKey(this.apiKeyEnv);
412
+ if (!apiKey) {
413
+ this.logger?.error?.(`[cloud-llm-adapter] missing API key (secrets.* / env slot ${this.apiKeyEnv})`);
414
+ this.audit('error', 'llm.cloud.missing_key', { provider: options.provider, model: options.model, envSlot: this.apiKeyEnv });
415
+ throw new LlmError(`cloud-llm-adapter: missing API key — set secrets.* in config/edge.json`
416
+ + `${this.apiKeyEnv === '' ? '' : ` (or the env slot "${this.apiKeyEnv}")`}`, 'MISSING_CREDENTIAL');
417
+ }
418
+ const url = `${this.baseUrl}/chat/completions`;
419
+ // 2026-08-31:input-guard 清洗孤儿 tool_call(避免 DeepSeek 400)。
420
+ // input-guard 未注入(plugin 顺序 / DSH_EDGE_INPUT_GUARD=false)则跳过。
421
+ // 这里只覆盖 messages,其他 option 字段(tools / temperature 等)
422
+ // 原样传给 buildChatCompletionsBody。
423
+ let sanitizedMessages = options.messages;
424
+ if (this.inputGuard) {
425
+ const result = this.inputGuard.sanitizeOrphanToolCalls(options.messages, options.sessionId);
426
+ sanitizedMessages = result.messages;
427
+ }
428
+ // §7.131 (2026-10-27): 出网准备 —— 裁掉本地段历史 + 对保留块脱敏。
429
+ //
430
+ // 主人裁决:「发给 cloudllm 的请求,只能携带之前所有的上 cloudllm 的历史」——
431
+ // 默认路由是本地池,本地轮次积累的会话历史属于本地资产,路由被改写/恢复
432
+ // 切回云端时不能跟着上云(实测 10-24 的 route.restored 就带着 28 条本地
433
+ // 历史上过云)。
434
+ //
435
+ // 脱敏失败(占位符超限等)→ fail-closed:宁可这一轮不出网,也不放行半脱敏
436
+ // 文本 —— 与 sanitizer-pipeline 的既有语义一致。
437
+ const egressSessionId = options.sessionId;
438
+ let egressDropped = 0;
439
+ let egressReplacements = 0;
440
+ let egressSanitized = false;
441
+ if (this.egress) {
442
+ try {
443
+ const prepared = await this.egress.prepare(sanitizedMessages, egressSessionId);
444
+ sanitizedMessages = prepared.messages;
445
+ egressDropped = prepared.droppedCount;
446
+ egressReplacements = prepared.replacements;
447
+ egressSanitized = prepared.sanitized;
448
+ }
449
+ catch (err) {
450
+ this.audit('security', 'llm.cloud.egress.blocked', {
451
+ provider: options.provider,
452
+ model: options.model,
453
+ // §7.147: 原来写 `egressSessionId ?? 'unknown'` —— 那是**假会话 id**
454
+ // (面板会造出一个叫 'unknown' 的桶,把这行从所有真实会话的视图里
455
+ // 藏掉)。没有会话就不写这个键 ⇒ 归全局桶。
456
+ ...sessionIdField(egressSessionId),
457
+ reason: err?.message ?? String(err),
458
+ });
459
+ throw new LlmError(`cloud-llm-adapter: egress preparation failed (${err?.message ?? String(err)}) — refusing to send unscrubbed text`, 'SANITIZE_FAILED');
460
+ }
461
+ }
462
+ // §7.48 (2026-09-13): system-prompt augmentation to nudge DeepSeek to
463
+ // synthesize answer after receiving tool results, instead of looping
464
+ // additional tool calls. 5x news verification (§7.47 followup):
465
+ // 3/5 runs aborted with kind=error because DeepSeek chose tool-calls
466
+ // over synthesis after gathering enough information. dsh-llm's
467
+ // buildChatCompletionsBody (openai-sse.ts:53) prepends options.system
468
+ // as a wire-level `{role:'system', content:string}` message — so we
469
+ // append (not replace) the existing system prompt when this stream
470
+ // is about to receive tool results.
471
+ // Inject only when:
472
+ // 1) messages already include a tool-result block — i.e. previous
473
+ // turn triggered a tool call. First-turn text-only chats don't
474
+ // need this guidance;
475
+ // 2) we don't disturb an already-set system prompt — we APPEND to
476
+ // it so user/runtime-set instructions are preserved.
477
+ // Scope: cloud-llm-adapter only. Local LLMs may have different
478
+ // tool-call patterns; we don't want to disturb them.
479
+ // Detection uses `source.kind === 'tool'` because dsh-llm's Message
480
+ // type's `role` is `'system' | 'user' | 'assistant'` only — tool
481
+ // results surface as user-role messages with source.kind='tool'.
482
+ const hasToolResult = sanitizedMessages.some((m) => m.source?.kind === 'tool');
483
+ let augmentedSystem = typeof options.system === 'string' ? options.system : '';
484
+ let systemInjected = false;
485
+ if (hasToolResult) {
486
+ const synthInstruction = 'You have received tool results above. Based on those results, synthesize a comprehensive answer in the user\'s language. Do not call additional tools unless the existing results are clearly insufficient. Avoid empty responses.';
487
+ augmentedSystem = augmentedSystem.length > 0
488
+ ? `${augmentedSystem}\n\n${synthInstruction}`
489
+ : synthInstruction;
490
+ systemInjected = true;
491
+ }
492
+ const body = buildChatCompletionsBody({ ...options, messages: sanitizedMessages, system: augmentedSystem.length > 0 ? augmentedSystem : options.system }, this.defaultModel, this.temperature);
493
+ const promptText = sanitizedMessages.map((m) => `${m.role}:${extractText(m.content)}`).join('\n');
494
+ const promptLen = promptText.length;
495
+ const messageCount = sanitizedMessages.length;
496
+ // §7.54.7 (2026-09-14): capture wire-system (after tool-result augmentation)
497
+ // for ops log so operator can see what system prompt actually went out.
498
+ // Truncate to first 200 chars to keep log lines readable; full text is in
499
+ // the request body which DSH_EDGE_LOG_LEVEL=debug exposes via LLM_MESSAGES.
500
+ const wireSystem = augmentedSystem.length > 0 ? augmentedSystem : (typeof options.system === 'string' ? options.system : '');
501
+ const systemLen = wireSystem.length;
502
+ const systemPreview = systemLen > 0 ? wireSystem.slice(0, 200).replace(/\s+/g, ' ') : '';
503
+ const start = Date.now();
504
+ // §7.46.1 (2026-09-11): ops log 让"TUI 路径调了哪个 LLM / 多少
505
+ // token / 多久"在 ops log 里可见。http-api POST 200 行有 provider/model
506
+ // + duration,但 TUI 路径之前只能靠 audit JSONL。start 行 + done
507
+ // 行成对,配合 realm-skill-bridge 的 pre-step / tool-allow 形成
508
+ // 完整 trace。
509
+ const sessionId = options.sessionId ?? 'unknown';
510
+ try {
511
+ this.logger?.info?.(`[cloud-llm-adapter] LLM call start sessionId=${sessionId} provider=${options.provider} model=${options.model} messageCount=${messageCount} promptLen=${promptLen} systemLen=${systemLen}${systemPreview ? ` systemPreview="${systemPreview}${systemLen > 200 ? '...' : ''}"` : ''} baseUrl=${this.baseUrl}`);
512
+ }
513
+ catch { /* debug only */ }
514
+ // 2026-09-02: dump the full sanitized messages array to the ops log
515
+ // at debug level. JSON.stringify keeps the entry single-line so
516
+ // file-logger's `formatLine` newline-collapse doesn't kick in.
517
+ this.logger?.debug?.(`[cloud-llm-adapter] LLM_MESSAGES ${JSON.stringify({ messages: sanitizedMessages })}`);
518
+ // §7.118: 详细 request trace —— 逐条 message 的角色 / 正文 / 工具回显,
519
+ // 经凭据 + PII 脱敏、按字段截断后写进 ops log(info 级别,默认可见)。
520
+ this.traceRequest(sessionId, options, wireSystem, sanitizedMessages, body);
521
+ // §7.147: 同 `llm.local.start` —— 这条**开始**行必须带 sessionId,
522
+ // 否则它落全局桶,Edge 面板的路由面板永远说不清「这一轮」。
523
+ this.audit('info', 'llm.cloud.start', { ...sessionIdField(options.sessionId), provider: options.provider, model: options.model, messageCount, promptLen, baseUrl: this.baseUrl, systemInjected, retryBudgetUsed: peekSessionSseRetryCount(options.sessionId), historyDropped: egressDropped, egressReplacements: egressReplacements, egressSanitized, ...egressAuditFields(options) });
524
+ // §7.131: 出网裁剪单独留一行 —— operator grep `llm.cloud.egress` 就能看到
525
+ // 「这一轮带了多少历史上去 / 丢了多少本地段 / 脱敏替换了几处」。
526
+ if (egressDropped > 0 || egressReplacements > 0 || !egressSanitized) {
527
+ this.audit('info', 'llm.cloud.egress', {
528
+ provider: options.provider,
529
+ model: options.model,
530
+ // §7.147: 同上 —— `'unknown'` 是假会话 id,改成「没有就不写」。
531
+ ...sessionIdField(egressSessionId),
532
+ keptMessages: messageCount,
533
+ droppedLocalHistory: egressDropped,
534
+ replacements: egressReplacements,
535
+ sanitized: egressSanitized,
536
+ });
537
+ }
538
+ const headers = {
539
+ 'content-type': 'application/json',
540
+ 'authorization': `Bearer ${apiKey}`,
541
+ 'accept': 'text/event-stream',
542
+ ...attributionHeaders(),
543
+ };
544
+ // §7.50 (2026-09-13): wrap fetch + SSE parsing in a per-session retry
545
+ // loop. Triggered when the upstream DeepSeek SSE stream disconnects
546
+ // before yielding any chunk (observed 137-494ms drop in §7.48 news
547
+ // verification, 2/5 runs aborted with kind=error after §7.48's
548
+ // system-prompt augmentation already cleared the synthesis-side issue).
549
+ //
550
+ // §7.49 had explored retrying at the dsh-agent-loop layer; reverted
551
+ // because (a) cordis ctx accessor requires extra inject, (b) Session.append
552
+ // reentrancy guard throws from listeners, (c) dsh-agent-loop's turn model
553
+ // doesn't rescue kind=error — new turns are fire-and-forget and don't
554
+ // flow back to http-api response. So retry must live at the LLM-adapter
555
+ // layer.
556
+ //
557
+ // Constraints:
558
+ // - retry only when no chunk has been yielded yet across the whole
559
+ // stream lifetime (avoids wasting tokens that upstream already
560
+ // produced; mid-stream drops in attempt N>1 → throw because the
561
+ // consumer has already consumed attempt 1's chunks)
562
+ // - per-session counter ≤ 1 (tryConsumeSseRetryBudget) so worst-case
563
+ // latency stays bounded and we don't amplify provider pressure
564
+ // - emit ops log + audit events for retry triggered / retry exhausted
565
+ // so the diagnostic matches the user principle "log should make
566
+ // the bug reproducible"
567
+ //
568
+ // yield semantics: we cannot `yield` from inside a retry helper
569
+ // (generators can't yield across function boundaries). So we loop
570
+ // inline and track `sawAnyChunkAcrossAttempts` — set true the first
571
+ // time we yield a chunk; subsequent retry attempts bail on error.
572
+ let sawAnyChunkAcrossAttempts = false;
573
+ let attemptIndex = 0;
574
+ let lastAttemptError;
575
+ // Hoisted so the post-loop runaway-tool-call guard + terminalChunks
576
+ // yield can read the FINAL attempt's state after `break outer`.
577
+ let assembledText = '';
578
+ let finishKind;
579
+ const toolCalls = new Map();
580
+ let sawText = false;
581
+ let sawToolCall = false;
582
+ outer: while (true) {
583
+ attemptIndex += 1;
584
+ // Reset per-attempt state (assembledText etc. are scoped INSIDE
585
+ // the loop so they naturally discard on retry).
586
+ let response;
587
+ try {
588
+ response = await fetch(url, {
589
+ method: 'POST',
590
+ headers,
591
+ body: JSON.stringify(body),
592
+ signal: options.signal,
593
+ });
594
+ }
595
+ catch (err) {
596
+ const errMsg = err?.message ?? String(err);
597
+ lastAttemptError = `fetch: ${errMsg}`;
598
+ const durationMs = Date.now() - start;
599
+ this.audit('error', 'llm.cloud.network_error', { sessionId: options.sessionId,
600
+ provider: options.provider,
601
+ model: options.model,
602
+ durationMs,
603
+ attempt: attemptIndex,
604
+ error: errMsg,
605
+ ...egressAuditFields(options),
606
+ });
607
+ // §7.50 followup: emit bookkeeping on fetch throw too. AbortError
608
+ // (signal aborted by dsh-agent-loop) is the common case — ops log
609
+ // previously showed only LLM call start, hiding the abort source.
610
+ try {
611
+ this.logger?.info?.(`[cloud-llm-adapter] LLM call done sessionId=${sessionId} provider=${options.provider} model=${options.model} attempt=${attemptIndex} retried=false durationMs=${durationMs} resultLen=0 toolCallCount=0 finishKind=unknown fetchError=${errMsg}`);
612
+ }
613
+ catch { /* debug only */ }
614
+ // §7.118: 失败也要有 response trace —— 否则 ops log 里只剩一个
615
+ // fetchError= 字样,看不出到底发了什么出去、为什么失败。
616
+ this.traceResponse(sessionId, options.provider, options.model ?? this.defaultModel, '', [], undefined, durationMs, attemptIndex, errMsg, options,
617
+ // §7.144: 传输层失败也要有完整响应体(错误原文)。
618
+ { error: errMsg, attempt: attemptIndex });
619
+ this.audit('info', 'llm.cloud.end', { sessionId: options.sessionId,
620
+ provider: options.provider,
621
+ model: options.model,
622
+ durationMs,
623
+ ...egressAuditFields(options),
624
+ promptLen,
625
+ resultLen: 0,
626
+ toolCallCount: 0,
627
+ finishKind: undefined,
628
+ sawText: false,
629
+ sawToolCall: false,
630
+ attempt: attemptIndex,
631
+ retried: attemptIndex > 1,
632
+ fetchError: errMsg,
633
+ });
634
+ if (!tryConsumeSseRetryBudget(options.sessionId)) {
635
+ this.logger?.error?.(`[cloud-llm-adapter] SSE retry exhausted sessionId=${sessionId} attempt=${attemptIndex} reason=fetch_throw error=${errMsg}`);
636
+ throw err;
637
+ }
638
+ this.logger?.warn?.(`[cloud-llm-adapter] SSE retry triggered sessionId=${sessionId} attempt=${attemptIndex} reason=fetch_throw error=${errMsg}`);
639
+ this.audit('warn', 'llm.cloud.sse_retry_triggered', {
640
+ provider: options.provider,
641
+ model: options.model,
642
+ sessionId,
643
+ attempt: attemptIndex,
644
+ reason: 'fetch_throw',
645
+ error: errMsg,
646
+ });
647
+ continue;
648
+ }
649
+ if (!response.ok) {
650
+ const durationMs = Date.now() - start;
651
+ let respBody = '';
652
+ try {
653
+ respBody = await response.text();
654
+ }
655
+ catch { /* ignore */ }
656
+ // HTTP 4xx/5xx — don't retry; the upstream rejected the request,
657
+ // and retrying identical body won't help (would only amplify 429s).
658
+ this.audit('error', 'llm.cloud.http_error', { sessionId: options.sessionId,
659
+ provider: options.provider,
660
+ model: options.model,
661
+ status: response.status,
662
+ durationMs,
663
+ respBody: respBody.slice(0, 500),
664
+ ...egressAuditFields(options),
665
+ });
666
+ // §7.50 followup (2026-09-13): also emit the LLM call done / end
667
+ // bookkeeping on HTTP error so the ops log + audit pipeline
668
+ // always has a matching end-pair for the start. Without this,
669
+ // 4xx errors leave ops log showing only LLM call start (which
670
+ // made the §7.50 verification look like a 0/5 regression — it
671
+ // was actually a 402 Insufficient Balance).
672
+ try {
673
+ this.logger?.info?.(`[cloud-llm-adapter] LLM call done sessionId=${sessionId} provider=${options.provider} model=${options.model} attempt=${attemptIndex} retried=false durationMs=${durationMs} resultLen=0 toolCallCount=0 finishKind=unknown httpError=${response.status}`);
674
+ }
675
+ catch { /* debug only */ }
676
+ // §7.118: HTTP 失败同样留 response trace(含上游返回体摘要)。
677
+ this.traceResponse(sessionId, options.provider, options.model ?? this.defaultModel, '', [], undefined, durationMs, attemptIndex, `HTTP ${response.status}: ${respBody.slice(0, 300)}`, options,
678
+ // §7.144: HTTP 失败的上游返回体(主人排查 400 时要看的就是它)。
679
+ { status: response.status, body: respBody, attempt: attemptIndex });
680
+ this.audit('info', 'llm.cloud.end', { sessionId: options.sessionId,
681
+ provider: options.provider,
682
+ model: options.model,
683
+ durationMs,
684
+ ...egressAuditFields(options),
685
+ promptLen,
686
+ resultLen: 0,
687
+ toolCallCount: 0,
688
+ finishKind: undefined,
689
+ sawText: false,
690
+ sawToolCall: false,
691
+ attempt: attemptIndex,
692
+ retried: attemptIndex > 1,
693
+ httpError: response.status,
694
+ });
695
+ throw new LlmError(`cloud-llm-adapter: HTTP ${response.status}: ${respBody.slice(0, 200)}`, 'PROVIDER_ERROR');
696
+ }
697
+ // 2026-09-03 §7.34.2 fix: defer `block-start` until the first text-delta.
698
+ // Same root cause as local-llm-adapter — empty stream (e.g. DeepSeek upstream
699
+ // returns finish=stop with 0 content) would leave BlockAssembler with an
700
+ // orphan text block + finish, agent-loop state machine hangs.
701
+ let blockStartYielded = false;
702
+ const yieldBlockStartOnce = () => {
703
+ if (blockStartYielded)
704
+ return false;
705
+ blockStartYielded = true;
706
+ return true;
707
+ };
708
+ // assembledText / finishKind / toolCalls / sawText / sawToolCall are
709
+ // hoisted to outer scope so post-loop logic can read the FINAL
710
+ // attempt's state. We reset them on each retry attempt so attempt 2
711
+ // starts fresh (discarding attempt 1's partial progress).
712
+ assembledText = '';
713
+ finishKind = undefined;
714
+ toolCalls.clear();
715
+ sawText = false;
716
+ sawToolCall = false;
717
+ const throwOnWire = (message) => {
718
+ throw new LlmError(`cloud-llm-adapter: SSE stream interrupted: ${message}`, 'PROVIDER_ERROR');
719
+ };
720
+ let wireError;
721
+ try {
722
+ for await (const event of iterateSseFrames(response, throwOnWire)) {
723
+ const reason = sseFinishReason(event);
724
+ if (reason !== undefined) {
725
+ finishKind = reason;
726
+ continue;
727
+ }
728
+ const result = sseEventToChunk(event, toolCalls);
729
+ if (result === null)
730
+ continue;
731
+ if (result.chunk.type === 'text-delta') {
732
+ // §7.34.2: yield block-start lazily before the first text-delta
733
+ if (yieldBlockStartOnce()) {
734
+ yield { type: 'block-start', index: 0, blockType: 'text' };
735
+ }
736
+ assembledText += result.chunk.text;
737
+ sawText = result.sawText;
738
+ }
739
+ else if (result.chunk.type === 'tool-call-delta') {
740
+ sawToolCall = result.sawToolCall;
741
+ }
742
+ if (!sawAnyChunkAcrossAttempts)
743
+ sawAnyChunkAcrossAttempts = true;
744
+ yield result.chunk;
745
+ }
746
+ // Stream completed normally — break the outer retry loop.
747
+ // Reset the retry counter so future calls in this session start fresh.
748
+ resetSessionSseRetryCount(options.sessionId);
749
+ break outer;
750
+ }
751
+ catch (err) {
752
+ wireError = err?.message ?? String(err);
753
+ lastAttemptError = `sse: ${wireError}`;
754
+ const durationMs = Date.now() - start;
755
+ this.audit('error', 'llm.cloud.sse_interrupted', { sessionId: options.sessionId,
756
+ provider: options.provider,
757
+ model: options.model,
758
+ durationMs,
759
+ attempt: attemptIndex,
760
+ wireError,
761
+ });
762
+ if (sawAnyChunkAcrossAttempts) {
763
+ // Mid-stream drop on a later try after attempt 1 yielded some
764
+ // chunks — don't retry, the consumer has already consumed them.
765
+ throw err;
766
+ }
767
+ if (!tryConsumeSseRetryBudget(options.sessionId)) {
768
+ this.logger?.error?.(`[cloud-llm-adapter] SSE retry exhausted sessionId=${sessionId} attempt=${attemptIndex} reason=sse_wire_error wireError=${wireError}`);
769
+ throw err;
770
+ }
771
+ this.logger?.warn?.(`[cloud-llm-adapter] SSE retry triggered sessionId=${sessionId} attempt=${attemptIndex} reason=sse_wire_error wireError=${wireError}`);
772
+ this.audit('warn', 'llm.cloud.sse_retry_triggered', {
773
+ provider: options.provider,
774
+ model: options.model,
775
+ sessionId,
776
+ attempt: attemptIndex,
777
+ reason: 'sse_wire_error',
778
+ wireError,
779
+ });
780
+ // Note: assembledText is intentionally discarded on retry — we
781
+ // are retrying from scratch because no chunk has been yielded yet.
782
+ continue;
783
+ }
784
+ finally {
785
+ const durationMs = Date.now() - start;
786
+ const retried = attemptIndex > 1;
787
+ this.audit('info', 'llm.cloud.end', { sessionId: options.sessionId,
788
+ provider: options.provider,
789
+ model: options.model,
790
+ durationMs,
791
+ ...egressAuditFields(options),
792
+ promptLen,
793
+ resultLen: assembledText.length,
794
+ toolCallCount: toolCalls.size,
795
+ finishKind,
796
+ sawText,
797
+ sawToolCall,
798
+ attempt: attemptIndex,
799
+ retried,
800
+ ...(wireError !== undefined ? { wireError } : {}),
801
+ ...(lastAttemptError !== undefined && wireError === undefined ? { lastAttemptError } : {}),
802
+ });
803
+ // §7.46.1: ops log done 行,跟 start 行配对
804
+ try {
805
+ this.logger?.info?.(`[cloud-llm-adapter] LLM call done sessionId=${sessionId} provider=${options.provider} model=${options.model} attempt=${attemptIndex} retried=${retried} durationMs=${durationMs} resultLen=${assembledText.length} toolCallCount=${toolCalls.size} finishKind=${finishKind ?? 'unknown'}${wireError !== undefined ? ` wireError=${wireError}` : ''}`);
806
+ }
807
+ catch { /* debug only */ }
808
+ // 2026-09-02: dump response summary + full text to the ops log at
809
+ // debug level. Splits into two entries (header + body) so grepping
810
+ // for `LLM_RESPONSE_TEXT` can isolate the body without parsing JSON.
811
+ this.logger?.debug?.(`[cloud-llm-adapter] LLM_RESPONSE provider=${options.provider} attempt=${attemptIndex} durationMs=${durationMs} resultLen=${assembledText.length} finishKind=${finishKind ?? 'unknown'}`);
812
+ this.logger?.debug?.(`[cloud-llm-adapter] LLM_RESPONSE_TEXT ${JSON.stringify({ text: assembledText })}`);
813
+ // §7.118: 详细 response trace(正文 + tool calls + finish reason)。
814
+ this.traceResponse(sessionId, options.provider, options.model ?? this.defaultModel, assembledText, [...toolCalls.values()].map((c) => ({ id: c.id, name: c.name, arguments: c.arguments })), finishKind, durationMs, attemptIndex, wireError, options,
815
+ // §7.144: 完整响应体(正文 + 工具调用 + 结束原因)。
816
+ {
817
+ content: assembledText,
818
+ tool_calls: [...toolCalls.values()].map((c) => ({ id: c.id, name: c.name, arguments: c.arguments })),
819
+ finish_reason: finishKind ?? 'unknown',
820
+ ...(wireError !== undefined ? { wire_error: wireError } : {}),
821
+ });
822
+ }
823
+ }
824
+ // §7.47.5 (2026-09-13): per-session runaway tool-call guard, mirrored
825
+ // from local-llm-adapter (§7.42) which only protected local models.
826
+ // cloud-llm-adapter previously had no guard, so DeepSeek looping
827
+ // web_search/web_fetch indefinitely (e.g. "查询最新的AI新闻" hits it
828
+ // deterministically — DeepSeek picks "call more tools" over
829
+ // "synthesize answer" until dsh-agent-loop upper layer aborts with
830
+ // reason=unknown → resultLen=0 → user sees empty reply). Now: emit
831
+ // audit + logger.error + throw MAX_TURNS_EXCEEDED so ops log records
832
+ // the runaway and agent loop terminates with a deterministic reason.
833
+ if (finishKind === 'tool-calls') {
834
+ if (!sawText) {
835
+ const consecutiveCount = bumpSessionToolCallCount(options.sessionId);
836
+ const sessionKeyForLog = typeof options.sessionId === 'string' && options.sessionId.length > 0
837
+ ? options.sessionId
838
+ : 'default';
839
+ if (consecutiveCount > MAX_CONSECUTIVE_TOOL_CALLS) {
840
+ this.audit('warn', 'llm.cloud.max_turns_exceeded', {
841
+ provider: options.provider,
842
+ model: options.model,
843
+ sessionId: sessionKeyForLog,
844
+ consecutiveCount,
845
+ });
846
+ this.logger?.error?.(`[cloud-llm-adapter] per-session max-turns exceeded (session=${sessionKeyForLog}, count=${consecutiveCount}/${MAX_CONSECUTIVE_TOOL_CALLS})`);
847
+ // Reset counter so next call starts fresh (agent loop may retry).
848
+ resetSessionToolCallCount(options.sessionId);
849
+ throw new LlmError(`cloud-llm-adapter: per-session max-turns exceeded (count=${consecutiveCount})`, 'MAX_TURNS_EXCEEDED');
850
+ }
851
+ }
852
+ else {
853
+ // Tool-call response with text content -> progress, reset counter.
854
+ resetSessionToolCallCount(options.sessionId);
855
+ }
856
+ }
857
+ else if (finishKind === 'stop' || finishKind === 'length') {
858
+ // Natural termination -> reset counter.
859
+ resetSessionToolCallCount(options.sessionId);
860
+ }
861
+ yield* terminalChunks(sawText, assembledText, toolCalls, finishKind, sawToolCall);
862
+ }
863
+ }
864
+ /** Stable dsh-base cloud routes win over user-defined pi-ai keys (registry
865
+ * order alone would make the pick depend on mount timing). */
866
+ const DELEGATE_PRIORITY = ['deepseek-account', 'deepseek-official'];
867
+ /**
868
+ * Pure resolution probe — exported for input-guard's `cloudAvailable()`
869
+ * (same chain, sync only: no async model fetch). Returns null when no
870
+ * usable cloud target exists.
871
+ */
872
+ export function probeCloudTarget(accessors, cfg) {
873
+ if (cfg.delegateEnabled === false)
874
+ return null;
875
+ // §7.171: 递归护栏 —— **两个自有池互相排除**。
876
+ // - `cloud-llm` 的 delegate 不得委托给 `local-llm`(realm 语义反转:LOCAL 池的
877
+ // 口径是带全部历史,让云池借道会把本地历史原样带上去)。
878
+ // - `local-llm` 的 delegate 不得委托给 `cloud-llm`(否则 local → cloud →
879
+ // delegate → 又指回宿主,构成一条多余的一跳 + 口径混流)。
880
+ //
881
+ // `OWN_POOL_PROVIDERS` 是这个名单的单一事实源(host-llm-trace.ts),与
882
+ // 出网守卫的 `isCloudBoundProvider` 同源。
883
+ const own = new Set([...cfg.providers, ...OWN_POOL_PROVIDERS]);
884
+ let live;
885
+ try {
886
+ live = accessors.llm.listProviders().map((p) => p.id);
887
+ }
888
+ catch {
889
+ return null;
890
+ }
891
+ const usable = live.filter((id) => !own.has(id));
892
+ const modelOverride = (cfg.delegateModel ?? '').trim();
893
+ // Step 1: explicit operator intent — honored or loud failure, never silently skipped.
894
+ const dp = (cfg.delegateProvider ?? '').trim();
895
+ if (dp.length > 0) {
896
+ if (own.has(dp) || !usable.includes(dp))
897
+ return null;
898
+ return { provider: dp, model: modelOverride.length > 0 ? modelOverride : undefined, source: 'env' };
899
+ }
900
+ // Step 2: the host profile's live selection (Desktop GUI model picker).
901
+ let selection;
902
+ try {
903
+ selection = accessors.getAgentDefaultModel()?.currentSelection();
904
+ }
905
+ catch {
906
+ selection = undefined;
907
+ }
908
+ if (selection
909
+ && typeof selection.provider === 'string'
910
+ && selection.provider.length > 0
911
+ && usable.includes(selection.provider)) {
912
+ const selModel = typeof selection.model === 'string' && selection.model.length > 0 ? selection.model : undefined;
913
+ return { provider: selection.provider, model: modelOverride.length > 0 ? modelOverride : selModel, source: 'selection' };
914
+ }
915
+ // Step 3: live-topology probe, stable ids first.
916
+ const ordered = [
917
+ ...DELEGATE_PRIORITY.filter((id) => usable.includes(id)),
918
+ ...usable.filter((id) => !DELEGATE_PRIORITY.includes(id)),
919
+ ];
920
+ if (ordered.length > 0) {
921
+ return { provider: ordered[0], model: modelOverride.length > 0 ? modelOverride : undefined, source: 'probe' };
922
+ }
923
+ return null;
924
+ }
925
+ /**
926
+ * §7.180 (2026-11-11): 委托目标的**共享缓存**。
927
+ *
928
+ * 背景(主人实测 2026-11-11 13:52,桌面 App):边缘模式会话在宿主的模型
929
+ * 选择器里显示「请选择模型」。宿主 `buildModelCatalog` 会把 `listModels()`
930
+ * 返回空数组的 provider **整组剔掉**(`groups.filter(g => g.models.length > 0)`),
931
+ * 而本地池此前恒返回空目录(§7.106 Option A)⇒ 本地池根本不在目录里,
932
+ * 选择器自然找不到与会话选择匹配的那一项(`currentChoice` 为 undefined)。
933
+ *
934
+ * 修法不是「目录里随便写个名字」,而是让三处**读同一份委托目标**:
935
+ *
936
+ * - wire(`stream()`):委托目标 → `target.model ?? firstModelId(target)`;
937
+ * - 目录(`listModels()`):同一个目标 → 同一条 wire model id;
938
+ * - 补钉(input-guard):同一个目标 → 同一个 id。
939
+ *
940
+ * ⇒ 宿主选择器上显示的名字 = 真正会跑的那个模型(§7.176 口径)。
941
+ *
942
+ * 条目按「委托旋钮」分键,并校验 `llm` 服务身份:换一个 llm 服务实例
943
+ * (测试里的假 llm、宿主重连后的新实例)各自解析,不会串味。
944
+ */
945
+ export class DelegateTargetCache {
946
+ entries = new Map();
947
+ /**
948
+ * 缓存键:只跟委托旋钮有关 —— 两个自有池互相排除(§7.171 递归护栏),
949
+ * 所以 `providers` 不影响解析结果,不必进键。
950
+ */
951
+ static key(cfg) {
952
+ return [
953
+ cfg.delegateEnabled === false ? 'off' : 'on',
954
+ (cfg.delegateProvider ?? '').trim(),
955
+ (cfg.delegateModel ?? '').trim(),
956
+ ].join('|');
957
+ }
958
+ /** 命中返回目标(含 null = 已探到「没有可用目标」);未命中返回 undefined。 */
959
+ get(llm, cfg) {
960
+ const entry = this.entries.get(DelegateTargetCache.key(cfg));
961
+ if (entry === undefined)
962
+ return undefined;
963
+ if (entry.llm !== llm)
964
+ return undefined;
965
+ return entry.target;
966
+ }
967
+ set(llm, cfg, target) {
968
+ this.entries.set(DelegateTargetCache.key(cfg), { llm, target });
969
+ }
970
+ /** 宿主路由拓扑变了(`llm/adapters-updated`)⇒ 丢掉全部条目。 */
971
+ clear() {
972
+ this.entries.clear();
973
+ }
974
+ }
975
+ /**
976
+ * §7.180: **本地池**共享的那一份委托目标缓存。
977
+ *
978
+ * wire(local-llm-adapter 的 DELEGATE 分支)、目录(`listModels()`)与会话
979
+ * 补钉(input-guard)三处都读它 ⇒ 三处永远说同一个模型。
980
+ * 云池不共用(§7.106:云池在目录里隐身,没有第二个消费方)。
981
+ */
982
+ export const localPoolTargetCache = new DelegateTargetCache();
983
+ export class CloudLlmDelegateAdapterImpl extends LlmAdapter {
984
+ providersList;
985
+ cfg;
986
+ accessors;
987
+ auditLog;
988
+ inputGuard;
989
+ logger;
990
+ /** §7.131: 出网准备器(裁剪云段 + 脱敏)。 */
991
+ egress;
992
+ /**
993
+ * §7.171: 这一池的隐私口径。
994
+ *
995
+ * - `'cloud'`(默认):云池 —— 出网裁剪(只带云段历史)+ 脱敏。
996
+ * - `'local'`:本地池 —— **不裁**(带全部历史)+ 不脱敏(§7.131 不对称规则)。
997
+ *
998
+ * delegate 只改变「谁提供模型」,不改变口径 —— 两个池共用同一套转发机制,
999
+ * 只有这一处口径开关不同。
1000
+ */
1001
+ poolKind;
1002
+ /**
1003
+ * §7.180: 委托目标缓存。
1004
+ *
1005
+ * 默认每实例一份(云池:目录里隐身,没有第二个消费方);本地池改用
1006
+ * `localPoolTargetCache` —— wire / 目录 / 会话补钉共用一份 ⇒ 三处永远
1007
+ * 说同一个模型。
1008
+ */
1009
+ targetCache;
1010
+ /** provider → first advertised model id, so stream() never re-fetches lists. */
1011
+ modelIdCache = new Map();
1012
+ constructor(config, accessors, auditLog, inputGuard, logger, egress,
1013
+ /** §7.171: 本地池 = 'local'(不裁剪);云池 = 'cloud'(默认,裁剪)。 */
1014
+ poolKind = 'cloud',
1015
+ /**
1016
+ * §7.180: 委托目标缓存。默认每实例一份;本地池传入共享的
1017
+ * `localPoolTargetCache`,让 wire / 目录 / 会话补钉共用同一份目标。
1018
+ */
1019
+ targetCache = new DelegateTargetCache()) {
1020
+ super();
1021
+ this.providersList = [...config.providers];
1022
+ this.cfg = config;
1023
+ this.accessors = accessors;
1024
+ this.auditLog = auditLog;
1025
+ this.inputGuard = inputGuard;
1026
+ this.logger = logger;
1027
+ this.egress = egress;
1028
+ this.poolKind = poolKind;
1029
+ this.targetCache = targetCache;
1030
+ }
1031
+ audit(level, kind, payload) {
1032
+ if (!this.auditLog)
1033
+ return;
1034
+ // §7.171: 本地池的 delegate 走同一个类,但审计 source/kind 必须能区分 ——
1035
+ // 否则审计链里 `llm.cloud.delegate.*` 会同时装着两个池的行。
1036
+ const source = this.poolKind === 'local' ? 'local-llm-adapter' : 'cloud-llm-adapter';
1037
+ void this.auditLog.log({ level, kind, source, payload }).catch(() => { });
1038
+ }
1039
+ /** §7.171: 审计 kind 的池前缀(`llm.local.*` / `llm.cloud.*`)。 */
1040
+ poolTag() {
1041
+ return this.poolKind === 'local' ? 'llm.local' : 'llm.cloud';
1042
+ }
1043
+ /** §7.171: 本池在宿主侧的 provider 名(`local-llm` / `cloud-llm`)。 */
1044
+ poolProvider() {
1045
+ return this.poolKind === 'local' ? 'local-llm' : 'cloud-llm';
1046
+ }
1047
+ /** §7.171: 日志前缀(`local-llm-adapter` / `cloud-llm-adapter`)。 */
1048
+ logTag() {
1049
+ return this.poolKind === 'local' ? 'local-llm-adapter' : 'cloud-llm-adapter';
1050
+ }
1051
+ /** `llm/adapters-updated` sink: drop cached target + model ids so the
1052
+ * next call re-probes the new topology (host route churn is normal —
1053
+ * llm-pi-ai itself re-registers via handle.replace on settings edits).
1054
+ * §7.180: 本地池传进来的是**共享**缓存 ⇒ 这里一清,目录与补钉也跟着
1055
+ * 重新解析,三处仍然同源。 */
1056
+ invalidateTopology() {
1057
+ this.targetCache.clear();
1058
+ this.modelIdCache.clear();
1059
+ }
1060
+ /**
1061
+ * §7.180: 委托目标(带缓存)。
1062
+ *
1063
+ * 命中共享缓存 ⇒ 与目录(`listModels()`)与会话补钉(input-guard)
1064
+ * 拿到的是**同一个对象** ⇒ wire 上跑的模型 id 与界面上写的 id 必然一致。
1065
+ */
1066
+ resolveTarget() {
1067
+ const llm = this.accessors.llm;
1068
+ const cached = this.targetCache.get(llm, this.cfg);
1069
+ if (cached !== undefined)
1070
+ return cached;
1071
+ const target = probeCloudTarget(this.accessors, this.cfg);
1072
+ this.targetCache.set(llm, this.cfg, target);
1073
+ return target;
1074
+ }
1075
+ get providers() {
1076
+ return [...this.providersList];
1077
+ }
1078
+ providerInfo(provider) {
1079
+ return { id: provider, name: provider };
1080
+ }
1081
+ /**
1082
+ * §7.180: **本地池**在模型目录里如实声明它真正会跑的那一个模型。
1083
+ *
1084
+ * 云池(`poolKind === 'cloud'`)保持 §7.106 Option A:目录恒空 —— 避免用户
1085
+ * 误选 `cloud-llm/deepseek-chat`,也避免 INVALID_CATALOG。
1086
+ *
1087
+ * 本地池相反:边缘模式会话就该指向本地池,目录里没有它 ⇒ 宿主选择器只能
1088
+ * 显示「请选择模型」(主人 13:52 实测)。这里声明的 id 与 wire 上真正跑的
1089
+ * 那个**同源**(同一个委托目标 + 同一个 firstModelId 回填)⇒ 选择器上
1090
+ * 写的名字 = 真正会跑的模型(§7.176)。
1091
+ */
1092
+ async listModels(provider) {
1093
+ if (this.poolKind !== 'local')
1094
+ return [];
1095
+ const target = this.resolveTarget();
1096
+ if (!target)
1097
+ return [];
1098
+ const id = await this.resolveWireModel(target);
1099
+ if (id === undefined)
1100
+ return [];
1101
+ // §7.180: 留审计行 —— 「宿主模型目录里 local-llm 声明了什么」是可查证的事实
1102
+ // (主人桌面复测时对照面板与审计链即可)。
1103
+ this.audit('info', 'llm.local.catalog', {
1104
+ provider,
1105
+ model: id,
1106
+ target: target.provider,
1107
+ targetSource: target.source,
1108
+ });
1109
+ // 宿主 dsh-llm 会校验 `model.provider === provider`(INVALID_CATALOG)。
1110
+ return [{ provider, id, name: id }];
1111
+ }
1112
+ async resolveModel(provider, model, signal) {
1113
+ const target = this.resolveTarget();
1114
+ if (target && this.accessors.llm.resolveModelInfo) {
1115
+ try {
1116
+ const info = await this.accessors.llm.resolveModelInfo(target.provider, target.model ?? model, signal);
1117
+ // Borrow the delegate target's CAPABILITY (contextWindow, modalities,
1118
+ // cost — §7.43 compaction reads them) but never its IDENTITY: the
1119
+ // dsh-llm contract (INVALID_MODEL_INFO, dsh-llm lib/index.js:2121)
1120
+ // rejects a resolve whose provider/id do not equal the caller's
1121
+ // request — and a caller selecting 'cloud-llm/deepseek-chat' must get
1122
+ // exactly that back, not the host route's real model ('deepseek-v4-
1123
+ // flash-0731'). The earlier `{ ...info, provider }` kept the target's
1124
+ // id and failed validation, killing every turn on this route.
1125
+ const sameIdentity = info.id === model;
1126
+ return {
1127
+ ...info,
1128
+ provider,
1129
+ id: model,
1130
+ name: sameIdentity ? info.name : model,
1131
+ ...(sameIdentity && typeof info.description === 'string' ? { description: info.description } : {}),
1132
+ };
1133
+ }
1134
+ catch {
1135
+ /* fall through to static shape */
1136
+ }
1137
+ }
1138
+ return { provider, id: model, name: model, context: { contextWindow: 65536 } };
1139
+ }
1140
+ async firstModelId(target) {
1141
+ const cached = this.modelIdCache.get(target.provider);
1142
+ if (cached)
1143
+ return cached;
1144
+ try {
1145
+ const models = await this.accessors.llm.listModels(target.provider);
1146
+ const first = models[0]?.id;
1147
+ if (first)
1148
+ this.modelIdCache.set(target.provider, first);
1149
+ return first;
1150
+ }
1151
+ catch {
1152
+ return undefined;
1153
+ }
1154
+ }
1155
+ /**
1156
+ * §7.180: wire 上真正会跑的 model id —— 与 `listModels()` 同一份口径。
1157
+ *
1158
+ * `target.model` 是委托解析链给定的 id;探针步没定 model 时取该 provider
1159
+ * 的第一个 model id(adapter 的既有口径)。解析出来的 id **回填进缓存
1160
+ * 目标** ⇒ 目录、补钉与 wire 三处共享同一个 id(`target` 是缓存里那个
1161
+ * 对象本身,回填对三方同时可见)。
1162
+ */
1163
+ async resolveWireModel(target) {
1164
+ const id = target.model ?? (await this.firstModelId(target));
1165
+ if (id !== undefined && target.model === undefined)
1166
+ target.model = id;
1167
+ return id;
1168
+ }
1169
+ async *stream(options) {
1170
+ const target = this.resolveTarget();
1171
+ if (!target) {
1172
+ const tag = this.poolTag();
1173
+ this.logger?.error?.(`[${this.logTag()}] NO_CLOUD_TARGET sessionId=${options.sessionId ?? 'unknown'} provider=${options.provider} delegateEnabled=${this.cfg.delegateEnabled !== false}`);
1174
+ this.audit('error', `${tag}.delegate.unavailable`, { sessionId: options.sessionId,
1175
+ provider: options.provider,
1176
+ model: options.model,
1177
+ delegateProvider: this.cfg.delegateProvider ?? '',
1178
+ hint: this.poolKind === 'local'
1179
+ ? 'configure llm.local.baseUrl (direct pool), select a model in the host profile, or set llm.local.delegate=false to restore the opt-in semantics (route stays unregistered)'
1180
+ : 'configure CLOUD_LLM_BASEURL (direct pool), select a model in the host profile, or set DSH_EDGE_CLOUD_DELEGATE=false',
1181
+ });
1182
+ throw new LlmError(this.poolKind === 'local'
1183
+ ? 'local-llm-adapter: no provider available (delegate mode). '
1184
+ + 'Configure llm.local.baseUrl for a direct pool, select a model in the host profile, '
1185
+ + 'or set llm.local.delegate=false to restore the opt-in semantics (route stays unregistered).'
1186
+ : 'cloud-llm-adapter: no cloud provider available (delegate mode). '
1187
+ + 'Configure CLOUD_LLM_BASEURL for a direct pool, select a model in the host profile, '
1188
+ + 'or set DSH_EDGE_CLOUD_DELEGATE=false to restore the §7.35 downgrade-to-LOCAL semantics.', 'NO_CLOUD_TARGET');
1189
+ }
1190
+ const wireModel = (await this.resolveWireModel(target)) ?? options.model;
1191
+ const sessionId = options.sessionId ?? 'unknown';
1192
+ // Orphan-tool-call wash (2026-08-31 fix, same rationale as direct mode):
1193
+ // run it before forwarding so the host adapter sees clean history too.
1194
+ let forwardMessages = options.messages;
1195
+ if (this.inputGuard) {
1196
+ const result = this.inputGuard.sanitizeOrphanToolCalls(options.messages, options.sessionId);
1197
+ forwardMessages = result.messages;
1198
+ }
1199
+ // §7.131 (2026-10-27): delegate 路径同样要裁剪 —— 它转发给宿主的云模型,
1200
+ // 那也是出网。当前部署走的就是这条(llm.cloud.baseUrl="" = DELEGATE),实测
1201
+ // 10-24 的 route.restored 就是带着 28 条本地历史走到这里的。
1202
+ //
1203
+ // §7.171: **本地池的 delegate 不裁** —— `local-llm` 的口径是「带全部历史」
1204
+ // (§7.131 不对称规则第一条)。它转发给宿主提供商这件事不改变这一域的口径:
1205
+ // delegate 只改变「谁提供模型」。对应的出网守卫放行靠
1206
+ // `LOCAL_POOL_FORWARD_MARKER`(见 host-egress-guard.ts)。
1207
+ let egressDropped = 0;
1208
+ let egressReplacements = 0;
1209
+ let egressSanitized = false;
1210
+ if (this.egress && this.poolKind === 'cloud') {
1211
+ try {
1212
+ const prepared = await this.egress.prepare(forwardMessages, sessionId);
1213
+ forwardMessages = prepared.messages;
1214
+ egressDropped = prepared.droppedCount;
1215
+ egressReplacements = prepared.replacements;
1216
+ egressSanitized = prepared.sanitized;
1217
+ }
1218
+ catch (err) {
1219
+ this.audit('security', `${this.poolTag()}.egress.blocked`, {
1220
+ provider: target.provider,
1221
+ model: wireModel,
1222
+ sessionId,
1223
+ reason: err?.message ?? String(err),
1224
+ });
1225
+ throw new LlmError(`cloud-llm-adapter: egress preparation failed (${err?.message ?? String(err)}) — refusing to send unscrubbed text`, 'SANITIZE_FAILED');
1226
+ }
1227
+ }
1228
+ this.audit('info', `${this.poolTag()}.delegate.resolved`, {
1229
+ from: this.poolProvider(),
1230
+ toProvider: target.provider,
1231
+ toModel: wireModel,
1232
+ source: target.source,
1233
+ sessionId,
1234
+ messageCount: forwardMessages.length,
1235
+ // §7.181: 判别字段(tools 件数 + 内部调用标记)—— 与直连池的
1236
+ // `llm.local.start` 同款。没有它,面板分不出「主对话出网」与「安检 /
1237
+ // 分类器的内部调用」(两者走的是同一条委托路径)。
1238
+ ...egressAuditFields(options),
1239
+ historyDropped: egressDropped,
1240
+ egressReplacements,
1241
+ egressSanitized,
1242
+ });
1243
+ if (egressDropped > 0 || egressReplacements > 0 || !egressSanitized) {
1244
+ this.audit('info', `${this.poolTag()}.egress`, {
1245
+ provider: target.provider,
1246
+ model: wireModel,
1247
+ sessionId,
1248
+ keptMessages: forwardMessages.length,
1249
+ droppedLocalHistory: egressDropped,
1250
+ replacements: egressReplacements,
1251
+ sanitized: egressSanitized,
1252
+ });
1253
+ }
1254
+ try {
1255
+ this.logger?.info?.(`[${this.logTag()}] DELEGATE start sessionId=${sessionId} from=${this.poolProvider()} to=${target.provider}/${wireModel} source=${target.source} messageCount=${forwardMessages.length} droppedLocalHistory=${egressDropped}`);
1256
+ }
1257
+ catch { /* debug only */ }
1258
+ const start = Date.now();
1259
+ let assembledLen = 0;
1260
+ let sawText = false;
1261
+ let sawToolCall = false;
1262
+ let finishKind;
1263
+ try {
1264
+ // §7.171: 本地池转发时打标记 —— 宿主侧的 `host-egress-guard` 看到它就
1265
+ // **不裁**(本地池口径 = 带全部历史,§7.131 不对称规则第一条)。
1266
+ // 云池不打这个标记 ⇒ 照旧按 §7.131 裁剪云段历史。
1267
+ //
1268
+ // cast 是必要的:`GenerateOptions` 没有索引签名(dsh-llm
1269
+ // `lib/types/types.d.ts:506`),多出来的键会触发 excess-property 检查。
1270
+ // 运行时安全 —— 宿主 `adapterStream` 用 `{...options, ...resolvedConfig}`
1271
+ // spread,未知字段原样透传。
1272
+ const forwardOptions = this.poolKind === 'local'
1273
+ ? { ...options, [LOCAL_POOL_FORWARD_MARKER]: true }
1274
+ : options;
1275
+ const streamRaw = this.accessors.llm.stream({
1276
+ ...forwardOptions,
1277
+ messages: forwardMessages,
1278
+ provider: target.provider,
1279
+ model: wireModel,
1280
+ });
1281
+ // §7.37 compat: llm.stream() may be a thenable returning the iterable.
1282
+ const stream = (streamRaw != null && typeof streamRaw.then === 'function')
1283
+ ? await streamRaw
1284
+ : streamRaw;
1285
+ // Forward verbatim — the host adapter synthesizes its own terminal
1286
+ // chunks; we must NOT append terminalChunks (would double the finish).
1287
+ for await (const chunk of stream) {
1288
+ if (chunk.type === 'text-delta') {
1289
+ sawText = true;
1290
+ assembledLen += chunk.text.length;
1291
+ }
1292
+ else if (chunk.type === 'tool-call-delta') {
1293
+ sawToolCall = true;
1294
+ }
1295
+ else if (chunk.type === 'finish') {
1296
+ finishKind = chunk.reason?.kind;
1297
+ }
1298
+ yield chunk;
1299
+ }
1300
+ }
1301
+ finally {
1302
+ const durationMs = Date.now() - start;
1303
+ this.audit('info', `${this.poolTag()}.delegate.end`, {
1304
+ from: this.poolProvider(),
1305
+ toProvider: target.provider,
1306
+ toModel: wireModel,
1307
+ sessionId,
1308
+ durationMs,
1309
+ resultLen: assembledLen,
1310
+ sawText,
1311
+ sawToolCall,
1312
+ finishKind,
1313
+ // §7.181: 同 `delegate.resolved` —— 判别字段(tools/internal)。
1314
+ ...egressAuditFields(options),
1315
+ });
1316
+ try {
1317
+ this.logger?.info?.(`[${this.logTag()}] DELEGATE done sessionId=${sessionId} from=${this.poolProvider()} to=${target.provider}/${wireModel} source=${target.source} durationMs=${durationMs} resultLen=${assembledLen} finishKind=${finishKind ?? 'unknown'}`);
1318
+ }
1319
+ catch { /* debug only */ }
1320
+ }
1321
+ // §7.47.5 runaway parity: delegated cloud models loop tool-calls the same
1322
+ // way our direct client saw with DeepSeek — same per-session counter.
1323
+ if (finishKind === 'tool-calls') {
1324
+ if (!sawText) {
1325
+ const consecutiveCount = bumpSessionToolCallCount(options.sessionId);
1326
+ const sessionKeyForLog = typeof options.sessionId === 'string' && options.sessionId.length > 0
1327
+ ? options.sessionId
1328
+ : 'default';
1329
+ if (consecutiveCount > MAX_CONSECUTIVE_TOOL_CALLS) {
1330
+ this.audit('warn', `${this.poolTag()}.max_turns_exceeded`, {
1331
+ provider: this.poolProvider(),
1332
+ toProvider: target.provider,
1333
+ model: wireModel,
1334
+ sessionId: sessionKeyForLog,
1335
+ consecutiveCount,
1336
+ });
1337
+ this.logger?.error?.(`[${this.logTag()}] per-session max-turns exceeded (session=${sessionKeyForLog}, count=${consecutiveCount}/${MAX_CONSECUTIVE_TOOL_CALLS})`);
1338
+ resetSessionToolCallCount(options.sessionId);
1339
+ throw new LlmError(`cloud-llm-adapter: per-session max-turns exceeded (count=${consecutiveCount})`, 'MAX_TURNS_EXCEEDED');
1340
+ }
1341
+ }
1342
+ else {
1343
+ resetSessionToolCallCount(options.sessionId);
1344
+ }
1345
+ }
1346
+ else if (finishKind === 'stop' || finishKind === 'length') {
1347
+ resetSessionToolCallCount(options.sessionId);
1348
+ }
1349
+ }
1350
+ }
1351
+ /** Read an env-slot key, throwing on absence is left to the caller. */
1352
+ function readApiKey(slot) {
1353
+ if (typeof process === 'undefined' || !process.env)
1354
+ return undefined;
1355
+ const value = process.env[slot];
1356
+ if (typeof value !== 'string' || value.length === 0)
1357
+ return undefined;
1358
+ return value;
1359
+ }
1360
+ export async function apply(ctx, config) {
1361
+ ctx.logger?.info?.(`[cloud-llm-adapter] apply() called with providers: ${config.providers?.join(', ')}`);
1362
+ // §7.102: the cloud pool and the delegate knobs come from
1363
+ // config/edge.json (llm.cloud.*). The cordis patch row no longer reads
1364
+ // process.env — the desktop GUI never had those variables.
1365
+ const cloud = cloudPool();
1366
+ if (cloud.baseUrl !== undefined && cloud.baseUrl !== config.baseUrl) {
1367
+ ctx.logger?.info?.(`[cloud-llm-adapter] baseUrl from config/edge.json: ${config.baseUrl} → ${cloud.baseUrl}`);
1368
+ config.baseUrl = cloud.baseUrl;
1369
+ }
1370
+ // §7.111 (2026-10-24): llm.cloud.model was silently dropped here — the
1371
+ // direct mode then wired the Schema default 'deepseek-chat' to whatever
1372
+ // endpoint the operator configured (observed: MiniMax 400 "unknown model
1373
+ // 'deepseek-chat'"). Map it onto defaultModel like the other pool fields.
1374
+ if (cloud.model !== undefined && cloud.model !== '') {
1375
+ ;
1376
+ config.defaultModel = cloud.model;
1377
+ }
1378
+ if (cloud.delegateProvider !== undefined) {
1379
+ ;
1380
+ config.delegateProvider = cloud.delegateProvider;
1381
+ }
1382
+ if (cloud.delegateModel !== undefined) {
1383
+ ;
1384
+ config.delegateModel = cloud.delegateModel;
1385
+ }
1386
+ ;
1387
+ config.delegateEnabled = cloud.delegate;
1388
+ if (cloud.apiKey !== undefined) {
1389
+ ;
1390
+ config.apiKey = cloud.apiKey;
1391
+ }
1392
+ const auditLog = ctx.auditLog;
1393
+ // input-guard 是可选注入 —— plugin 启动顺序里 cloud-llm-adapter 在
1394
+ // input-guard 之后才能拿到;否则降级为不洗(保持旧行为)。enableInputGuard=false
1395
+ // 时 inputGuard.sanitizeOrphanToolCalls 自身也 pass-through。
1396
+ const inputGuard = ctx.inputGuard;
1397
+ const logger = ctx.logger;
1398
+ // §7.100: baseUrl 空(Schema 默认)= delegate 模式;显式配置 = 直连不变。
1399
+ const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl.trim() : '';
1400
+ const delegateMode = baseUrl.length === 0;
1401
+ // §7.154: a keyRef that names a slot that is not there leaves the pool
1402
+ // with NO credential. In DELEGATE mode the cloud key is not used at all,
1403
+ // so only DIRECT mode is worth complaining about — and it must be said
1404
+ // AT BOOT: otherwise the operator finds out at the first CLOUD turn,
1405
+ // long after the edit that broke it.
1406
+ if (!delegateMode) {
1407
+ const dangling = danglingSecretRefs().filter((r) => r.field.startsWith('llm.cloud'));
1408
+ if (dangling.length > 0) {
1409
+ logger?.error?.(`[cloud-llm-adapter] 配置里的密钥引用指向不存在的槽位:${formatDanglingSecretRefs(dangling)}`
1410
+ + ` —— 直连模式(baseUrl=${baseUrl})没有凭据,CLOUD 轮次会以 MISSING_CREDENTIAL 失败`);
1411
+ }
1412
+ }
1413
+ const llm = ctx.llm;
1414
+ let delegate;
1415
+ // §7.131: 出网准备器。ctx 在这里持有 —— sanitizer / modelRouter 都是运行时
1416
+ // 才拿得到(apply 时 cloud-llm-adapter 排在 sanitizer-pipeline 与
1417
+ // model-router 之前,ctx 里还没有它们)。
1418
+ const egress = new CloudEgressGuard(ctx);
1419
+ const adapter = delegateMode
1420
+ ? (() => {
1421
+ const accessors = {
1422
+ llm: llm,
1423
+ // soft dep: dsh-base absent (minimal graphs) → undefined, probe skips to step 3.
1424
+ getAgentDefaultModel: () => {
1425
+ try {
1426
+ return ctx.get?.('agentDefaultModel');
1427
+ }
1428
+ catch {
1429
+ return undefined;
1430
+ }
1431
+ },
1432
+ };
1433
+ delegate = new CloudLlmDelegateAdapterImpl(config, accessors, auditLog, inputGuard, logger, egress);
1434
+ return delegate;
1435
+ })()
1436
+ : new CloudLlmAdapterImpl(config, auditLog, inputGuard, logger, egress);
1437
+ // §7.100 热更新:路由拓扑变更(宿主 pi-ai replace / adapter 装卸)→ 清缓存,
1438
+ // 下次 stream 重新探针。agentDefaultModel 是 volatile Config,GUI 换模型无需订阅。
1439
+ const offAdaptersUpdated = delegateMode && typeof ctx.on === 'function'
1440
+ ? ctx.on('llm/adapters-updated', () => { delegate?.invalidateTopology(); })
1441
+ : undefined;
1442
+ ctx.logger?.info?.(`[cloud-llm-adapter] Registering ${delegateMode ? 'DELEGATE' : 'DIRECT'} adapter for providers: ${config.providers?.join(', ')}`);
1443
+ let registration;
1444
+ try {
1445
+ registration = llm.registerAdapter(config.providers, adapter);
1446
+ }
1447
+ catch (err) {
1448
+ // A user-defined pi-ai route literally named 'cloud-llm' (or a stale
1449
+ // registration) wins the name; that live route legitimately IS the cloud
1450
+ // pool then. Degrade loudly instead of crashing the whole bundle.
1451
+ const isDuplicate = err?.code === 'DUPLICATE_ADAPTER'
1452
+ || String(err?.message ?? err).includes('DUPLICATE_ADAPTER')
1453
+ || String(err?.message ?? err).includes('already has an adapter');
1454
+ if (isDuplicate) {
1455
+ logger?.error?.(`[cloud-llm-adapter] DUPLICATE_ADAPTER on ${config.providers.join(',')}; host route owns it, edge adapter stands down`);
1456
+ auditLog?.log({
1457
+ level: 'security',
1458
+ kind: 'llm.cloud.registration.duplicate',
1459
+ source: 'cloud-llm-adapter',
1460
+ payload: { providers: config.providers, mode: delegateMode ? 'delegate' : 'direct', error: String(err?.message ?? err) },
1461
+ }).catch(() => { });
1462
+ offAdaptersUpdated?.();
1463
+ return async () => { };
1464
+ }
1465
+ throw err;
1466
+ }
1467
+ ctx.logger?.info?.(`[cloud-llm-adapter] Adapter registered successfully`);
1468
+ return async () => {
1469
+ registration();
1470
+ offAdaptersUpdated?.();
1471
+ };
1472
+ }