@a1hvdy/cc-openclaw 0.8.0 → 0.9.1

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 (288) hide show
  1. package/dist/scripts/bench/ab-harness.d.ts +58 -0
  2. package/dist/scripts/bench/ab-harness.d.ts.map +1 -0
  3. package/dist/scripts/bench/ab-harness.js +78 -0
  4. package/dist/scripts/bench/ab-harness.js.map +1 -0
  5. package/dist/src/channels/adapter.d.ts.map +1 -0
  6. package/dist/src/channels/telegram/completion-summary.d.ts.map +1 -0
  7. package/dist/src/channels/telegram/error-renderer.d.ts.map +1 -0
  8. package/dist/src/channels/telegram/event-reducer.d.ts.map +1 -0
  9. package/dist/src/channels/telegram/index.d.ts.map +1 -0
  10. package/dist/src/channels/telegram/injector.d.ts.map +1 -0
  11. package/dist/src/channels/telegram/live-card.d.ts.map +1 -0
  12. package/dist/src/channels/telegram/state-machine.d.ts.map +1 -0
  13. package/dist/src/channels/telegram/tool-tracker.d.ts.map +1 -0
  14. package/dist/src/command-router/cc-handler.d.ts.map +1 -0
  15. package/dist/src/command-router/index.d.ts.map +1 -0
  16. package/dist/src/constants.d.ts.map +1 -0
  17. package/dist/src/council/consensus.d.ts.map +1 -0
  18. package/dist/src/council/council.d.ts.map +1 -0
  19. package/dist/src/council/index.d.ts.map +1 -0
  20. package/dist/src/engines/base-oneshot-session.d.ts.map +1 -0
  21. package/dist/src/engines/index.d.ts.map +1 -0
  22. package/dist/src/engines/persistent-codex-session.d.ts.map +1 -0
  23. package/dist/src/engines/persistent-cursor-session.d.ts.map +1 -0
  24. package/dist/src/engines/persistent-custom-session.d.ts.map +1 -0
  25. package/dist/src/engines/persistent-gemini-session.d.ts.map +1 -0
  26. package/dist/src/engines/persistent-session.d.ts.map +1 -0
  27. package/dist/src/health/handler.d.ts.map +1 -0
  28. package/dist/src/health/index.d.ts.map +1 -0
  29. package/dist/src/index.d.ts +10 -1
  30. package/dist/src/index.d.ts.map +1 -0
  31. package/dist/src/index.js +47 -7
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/lib/auto-recovery.d.ts.map +1 -0
  34. package/dist/src/lib/cache-parity.d.ts.map +1 -0
  35. package/dist/src/lib/circuit-breaker.d.ts.map +1 -0
  36. package/dist/src/lib/config-service.d.ts +106 -0
  37. package/dist/src/lib/config-service.js +217 -0
  38. package/dist/src/lib/config-service.js.map +1 -0
  39. package/dist/src/lib/config.d.ts +33 -14
  40. package/dist/src/lib/config.d.ts.map +1 -0
  41. package/dist/src/lib/config.js +147 -34
  42. package/dist/src/lib/config.js.map +1 -1
  43. package/dist/src/lib/debug-tap.d.ts.map +1 -0
  44. package/dist/src/lib/drift-detector.d.ts.map +1 -0
  45. package/dist/src/lib/error-formatter.d.ts.map +1 -0
  46. package/dist/src/lib/heartbeat-workaround.d.ts.map +1 -0
  47. package/dist/src/lib/index.d.ts +1 -1
  48. package/dist/src/lib/index.d.ts.map +1 -0
  49. package/dist/src/lib/index.js +4 -1
  50. package/dist/src/lib/index.js.map +1 -1
  51. package/dist/src/lib/register-guard.d.ts.map +1 -0
  52. package/dist/src/lib/req-shape-log.d.ts +31 -0
  53. package/dist/src/lib/req-shape-log.js +106 -0
  54. package/dist/src/lib/req-shape-log.js.map +1 -0
  55. package/dist/src/lib/route-flag.d.ts.map +1 -0
  56. package/dist/src/lib/sysprompt-strip.d.ts.map +1 -0
  57. package/dist/src/lib/telemetry.d.ts.map +1 -0
  58. package/dist/src/lib/test-mode.d.ts.map +1 -0
  59. package/dist/src/lib/vendor-paths.d.ts.map +1 -0
  60. package/dist/src/logger.d.ts.map +1 -0
  61. package/dist/src/mcp/bridge.d.ts.map +1 -0
  62. package/dist/src/mcp/index.d.ts.map +1 -0
  63. package/dist/src/models.d.ts.map +1 -0
  64. package/dist/src/openai-compat/cli-stream-parser.d.ts.map +1 -0
  65. package/dist/src/openai-compat/index.d.ts.map +1 -0
  66. package/dist/src/openai-compat/message-extractor.d.ts +79 -0
  67. package/dist/src/openai-compat/message-extractor.js +162 -0
  68. package/dist/src/openai-compat/message-extractor.js.map +1 -0
  69. package/dist/src/openai-compat/mode-flags.d.ts +34 -0
  70. package/dist/src/openai-compat/mode-flags.js +44 -0
  71. package/dist/src/openai-compat/mode-flags.js.map +1 -0
  72. package/dist/src/openai-compat/non-streaming-handler.d.ts +26 -0
  73. package/dist/src/openai-compat/non-streaming-handler.js +108 -0
  74. package/dist/src/openai-compat/non-streaming-handler.js.map +1 -0
  75. package/dist/src/openai-compat/openai-compat.d.ts +15 -166
  76. package/dist/src/openai-compat/openai-compat.d.ts.map +1 -0
  77. package/dist/src/openai-compat/openai-compat.js +65 -849
  78. package/dist/src/openai-compat/openai-compat.js.map +1 -1
  79. package/dist/src/openai-compat/prompts.d.ts +47 -0
  80. package/dist/src/openai-compat/prompts.js +119 -0
  81. package/dist/src/openai-compat/prompts.js.map +1 -0
  82. package/dist/src/openai-compat/response-formatter.d.ts +33 -0
  83. package/dist/src/openai-compat/response-formatter.js +74 -0
  84. package/dist/src/openai-compat/response-formatter.js.map +1 -0
  85. package/dist/src/openai-compat/session-key-resolver.d.ts +41 -0
  86. package/dist/src/openai-compat/session-key-resolver.js +78 -0
  87. package/dist/src/openai-compat/session-key-resolver.js.map +1 -0
  88. package/dist/src/openai-compat/skill-resolver.d.ts.map +1 -0
  89. package/dist/src/openai-compat/sse-translator.d.ts.map +1 -0
  90. package/dist/src/openai-compat/status-reporter.d.ts +30 -0
  91. package/dist/src/openai-compat/status-reporter.js +81 -0
  92. package/dist/src/openai-compat/status-reporter.js.map +1 -0
  93. package/dist/src/openai-compat/streaming-handler.d.ts +41 -0
  94. package/dist/src/openai-compat/streaming-handler.js +294 -0
  95. package/dist/src/openai-compat/streaming-handler.js.map +1 -0
  96. package/dist/src/openai-compat/tool-calls-parser.d.ts +34 -0
  97. package/dist/src/openai-compat/tool-calls-parser.js +93 -0
  98. package/dist/src/openai-compat/tool-calls-parser.js.map +1 -0
  99. package/dist/src/openai-compat/tool-results-serializer.d.ts +60 -0
  100. package/dist/src/openai-compat/tool-results-serializer.js +56 -0
  101. package/dist/src/openai-compat/tool-results-serializer.js.map +1 -0
  102. package/dist/src/proxy/anthropic-adapter.d.ts.map +1 -0
  103. package/dist/src/proxy/handler.d.ts.map +1 -0
  104. package/dist/src/proxy/index.d.ts.map +1 -0
  105. package/dist/src/proxy/schema-cleaner.d.ts.map +1 -0
  106. package/dist/src/proxy/thought-cache.d.ts.map +1 -0
  107. package/dist/src/session/embedded-server.d.ts.map +1 -0
  108. package/dist/src/session/inbox-manager.d.ts.map +1 -0
  109. package/dist/src/session/index.d.ts.map +1 -0
  110. package/dist/src/session/session-manager.d.ts.map +1 -0
  111. package/dist/src/session-bootstrap/cwd-patch.d.ts.map +1 -0
  112. package/dist/src/session-bootstrap/cwd-patch.js +20 -13
  113. package/dist/src/session-bootstrap/cwd-patch.js.map +1 -1
  114. package/dist/src/session-bootstrap/index.d.ts.map +1 -0
  115. package/dist/src/session-bootstrap/sysprompt-strip.d.ts.map +1 -0
  116. package/dist/src/session-bootstrap/think-conflict-resolver.d.ts.map +1 -0
  117. package/dist/src/types/index.d.ts +15 -0
  118. package/dist/src/types/index.js +16 -0
  119. package/dist/src/types/index.js.map +1 -0
  120. package/dist/src/types/route.d.ts +41 -0
  121. package/dist/src/types/route.js +12 -0
  122. package/dist/src/types/route.js.map +1 -0
  123. package/dist/src/types/runtime-config.d.ts +161 -0
  124. package/dist/src/types/runtime-config.js +118 -0
  125. package/dist/src/types/runtime-config.js.map +1 -0
  126. package/dist/src/types/session.d.ts +48 -0
  127. package/dist/src/types/session.js +20 -0
  128. package/dist/src/types/session.js.map +1 -0
  129. package/dist/src/types/sse.d.ts +38 -0
  130. package/dist/src/types/sse.js +12 -0
  131. package/dist/src/types/sse.js.map +1 -0
  132. package/dist/src/types/tool-bridge.d.ts +81 -0
  133. package/dist/src/types/tool-bridge.js +34 -0
  134. package/dist/src/types/tool-bridge.js.map +1 -0
  135. package/dist/src/types/upstream.d.ts +652 -0
  136. package/dist/src/types/upstream.js +145 -0
  137. package/dist/src/types/upstream.js.map +1 -0
  138. package/dist/src/types.d.ts.map +1 -0
  139. package/dist/src/validation.d.ts.map +1 -0
  140. package/dist/tests/_helpers/subprocess-mock.d.ts +35 -0
  141. package/dist/tests/_helpers/subprocess-mock.d.ts.map +1 -0
  142. package/dist/tests/_helpers/subprocess-mock.js +136 -0
  143. package/dist/tests/_helpers/subprocess-mock.js.map +1 -0
  144. package/dist/tests/auto-recovery.test.d.ts +2 -0
  145. package/dist/tests/auto-recovery.test.d.ts.map +1 -0
  146. package/dist/tests/auto-recovery.test.js +189 -0
  147. package/dist/tests/auto-recovery.test.js.map +1 -0
  148. package/dist/tests/bench-harness.test.d.ts +2 -0
  149. package/dist/tests/bench-harness.test.d.ts.map +1 -0
  150. package/dist/tests/bench-harness.test.js +21 -0
  151. package/dist/tests/bench-harness.test.js.map +1 -0
  152. package/dist/tests/cache-parity.test.d.ts +2 -0
  153. package/dist/tests/cache-parity.test.d.ts.map +1 -0
  154. package/dist/tests/cache-parity.test.js +401 -0
  155. package/dist/tests/cache-parity.test.js.map +1 -0
  156. package/dist/tests/command-router.test.d.ts +2 -0
  157. package/dist/tests/command-router.test.d.ts.map +1 -0
  158. package/dist/tests/command-router.test.js +60 -0
  159. package/dist/tests/command-router.test.js.map +1 -0
  160. package/dist/tests/council.test.d.ts +2 -0
  161. package/dist/tests/council.test.d.ts.map +1 -0
  162. package/dist/tests/council.test.js +20 -0
  163. package/dist/tests/council.test.js.map +1 -0
  164. package/dist/tests/drift-detector.test.d.ts +2 -0
  165. package/dist/tests/drift-detector.test.d.ts.map +1 -0
  166. package/dist/tests/drift-detector.test.js +268 -0
  167. package/dist/tests/drift-detector.test.js.map +1 -0
  168. package/dist/tests/eager-bootstrap-gating.test.d.ts +9 -0
  169. package/dist/tests/eager-bootstrap-gating.test.d.ts.map +1 -0
  170. package/dist/tests/eager-bootstrap-gating.test.js +97 -0
  171. package/dist/tests/eager-bootstrap-gating.test.js.map +1 -0
  172. package/dist/tests/engines.test.d.ts +2 -0
  173. package/dist/tests/engines.test.d.ts.map +1 -0
  174. package/dist/tests/engines.test.js +8 -0
  175. package/dist/tests/engines.test.js.map +1 -0
  176. package/dist/tests/error-formatter.test.d.ts +2 -0
  177. package/dist/tests/error-formatter.test.d.ts.map +1 -0
  178. package/dist/tests/error-formatter.test.js +220 -0
  179. package/dist/tests/error-formatter.test.js.map +1 -0
  180. package/dist/tests/health.test.d.ts +2 -0
  181. package/dist/tests/health.test.d.ts.map +1 -0
  182. package/dist/tests/health.test.js +110 -0
  183. package/dist/tests/health.test.js.map +1 -0
  184. package/dist/tests/heartbeat-workaround.test.d.ts +2 -0
  185. package/dist/tests/heartbeat-workaround.test.d.ts.map +1 -0
  186. package/dist/tests/heartbeat-workaround.test.js +90 -0
  187. package/dist/tests/heartbeat-workaround.test.js.map +1 -0
  188. package/dist/tests/index.test.d.ts +2 -0
  189. package/dist/tests/index.test.d.ts.map +1 -0
  190. package/dist/tests/index.test.js +7 -0
  191. package/dist/tests/index.test.js.map +1 -0
  192. package/dist/tests/lib-sysprompt-strip.test.d.ts +2 -0
  193. package/dist/tests/lib-sysprompt-strip.test.d.ts.map +1 -0
  194. package/dist/tests/lib-sysprompt-strip.test.js +145 -0
  195. package/dist/tests/lib-sysprompt-strip.test.js.map +1 -0
  196. package/dist/tests/listener-activation.test.d.ts +2 -0
  197. package/dist/tests/listener-activation.test.d.ts.map +1 -0
  198. package/dist/tests/listener-activation.test.js +87 -0
  199. package/dist/tests/listener-activation.test.js.map +1 -0
  200. package/dist/tests/mcp-bridge.test.d.ts +2 -0
  201. package/dist/tests/mcp-bridge.test.d.ts.map +1 -0
  202. package/dist/tests/mcp-bridge.test.js +137 -0
  203. package/dist/tests/mcp-bridge.test.js.map +1 -0
  204. package/dist/tests/openai-compat.test.d.ts +2 -0
  205. package/dist/tests/openai-compat.test.d.ts.map +1 -0
  206. package/dist/tests/openai-compat.test.js +8 -0
  207. package/dist/tests/openai-compat.test.js.map +1 -0
  208. package/dist/tests/proxy-heartbeat-integration.test.d.ts +15 -0
  209. package/dist/tests/proxy-heartbeat-integration.test.d.ts.map +1 -0
  210. package/dist/tests/proxy-heartbeat-integration.test.js +122 -0
  211. package/dist/tests/proxy-heartbeat-integration.test.js.map +1 -0
  212. package/dist/tests/proxy.test.d.ts +2 -0
  213. package/dist/tests/proxy.test.d.ts.map +1 -0
  214. package/dist/tests/proxy.test.js +8 -0
  215. package/dist/tests/proxy.test.js.map +1 -0
  216. package/dist/tests/register-guard-stacking.test.d.ts +2 -0
  217. package/dist/tests/register-guard-stacking.test.d.ts.map +1 -0
  218. package/dist/tests/register-guard-stacking.test.js +61 -0
  219. package/dist/tests/register-guard-stacking.test.js.map +1 -0
  220. package/dist/tests/register-guard.test.d.ts +2 -0
  221. package/dist/tests/register-guard.test.d.ts.map +1 -0
  222. package/dist/tests/register-guard.test.js +129 -0
  223. package/dist/tests/register-guard.test.js.map +1 -0
  224. package/dist/tests/route-flag-rollback.test.d.ts +2 -0
  225. package/dist/tests/route-flag-rollback.test.d.ts.map +1 -0
  226. package/dist/tests/route-flag-rollback.test.js +70 -0
  227. package/dist/tests/route-flag-rollback.test.js.map +1 -0
  228. package/dist/tests/route-flag.test.d.ts +2 -0
  229. package/dist/tests/route-flag.test.d.ts.map +1 -0
  230. package/dist/tests/route-flag.test.js +101 -0
  231. package/dist/tests/route-flag.test.js.map +1 -0
  232. package/dist/tests/session-bootstrap.test.d.ts +2 -0
  233. package/dist/tests/session-bootstrap.test.d.ts.map +1 -0
  234. package/dist/tests/session-bootstrap.test.js +183 -0
  235. package/dist/tests/session-bootstrap.test.js.map +1 -0
  236. package/dist/tests/session.test.d.ts +2 -0
  237. package/dist/tests/session.test.d.ts.map +1 -0
  238. package/dist/tests/session.test.js +17 -0
  239. package/dist/tests/session.test.js.map +1 -0
  240. package/dist/tests/state-machine.test.d.ts +2 -0
  241. package/dist/tests/state-machine.test.d.ts.map +1 -0
  242. package/dist/tests/state-machine.test.js +133 -0
  243. package/dist/tests/state-machine.test.js.map +1 -0
  244. package/dist/tests/streaming/cli-stream-parser.test.d.ts +2 -0
  245. package/dist/tests/streaming/cli-stream-parser.test.d.ts.map +1 -0
  246. package/dist/tests/streaming/cli-stream-parser.test.js +233 -0
  247. package/dist/tests/streaming/cli-stream-parser.test.js.map +1 -0
  248. package/dist/tests/streaming/feature-flag.test.d.ts +14 -0
  249. package/dist/tests/streaming/feature-flag.test.d.ts.map +1 -0
  250. package/dist/tests/streaming/feature-flag.test.js +163 -0
  251. package/dist/tests/streaming/feature-flag.test.js.map +1 -0
  252. package/dist/tests/streaming/no-tools-prompt.test.d.ts +17 -0
  253. package/dist/tests/streaming/no-tools-prompt.test.d.ts.map +1 -0
  254. package/dist/tests/streaming/no-tools-prompt.test.js +229 -0
  255. package/dist/tests/streaming/no-tools-prompt.test.js.map +1 -0
  256. package/dist/tests/streaming/skill-plus-tools.test.d.ts +14 -0
  257. package/dist/tests/streaming/skill-plus-tools.test.d.ts.map +1 -0
  258. package/dist/tests/streaming/skill-plus-tools.test.js +234 -0
  259. package/dist/tests/streaming/skill-plus-tools.test.js.map +1 -0
  260. package/dist/tests/streaming/sse-translator.test.d.ts +2 -0
  261. package/dist/tests/streaming/sse-translator.test.d.ts.map +1 -0
  262. package/dist/tests/streaming/sse-translator.test.js +227 -0
  263. package/dist/tests/streaming/sse-translator.test.js.map +1 -0
  264. package/dist/tests/streaming/tool-result-roundtrip.test.d.ts +11 -0
  265. package/dist/tests/streaming/tool-result-roundtrip.test.d.ts.map +1 -0
  266. package/dist/tests/streaming/tool-result-roundtrip.test.js +215 -0
  267. package/dist/tests/streaming/tool-result-roundtrip.test.js.map +1 -0
  268. package/dist/tests/streaming/tool-use-translation.test.d.ts +10 -0
  269. package/dist/tests/streaming/tool-use-translation.test.d.ts.map +1 -0
  270. package/dist/tests/streaming/tool-use-translation.test.js +251 -0
  271. package/dist/tests/streaming/tool-use-translation.test.js.map +1 -0
  272. package/dist/tests/telegram-bridge.test.d.ts +2 -0
  273. package/dist/tests/telegram-bridge.test.d.ts.map +1 -0
  274. package/dist/tests/telegram-bridge.test.js +17 -0
  275. package/dist/tests/telegram-bridge.test.js.map +1 -0
  276. package/dist/tests/telegram-injector.test.d.ts +2 -0
  277. package/dist/tests/telegram-injector.test.d.ts.map +1 -0
  278. package/dist/tests/telegram-injector.test.js +74 -0
  279. package/dist/tests/telegram-injector.test.js.map +1 -0
  280. package/dist/tests/telemetry.test.d.ts +2 -0
  281. package/dist/tests/telemetry.test.d.ts.map +1 -0
  282. package/dist/tests/telemetry.test.js +405 -0
  283. package/dist/tests/telemetry.test.js.map +1 -0
  284. package/dist/tests/test-mode.test.d.ts +2 -0
  285. package/dist/tests/test-mode.test.d.ts.map +1 -0
  286. package/dist/tests/test-mode.test.js +39 -0
  287. package/dist/tests/test-mode.test.js.map +1 -0
  288. package/package.json +3 -2
@@ -5,484 +5,86 @@
5
5
  * webchat frontends (ChatGPT-Next-Web, Open WebUI, etc.) to use the plugin
6
6
  * as a drop-in backend. Stateful sessions maximize Anthropic prompt caching.
7
7
  */
8
- import * as http from 'node:http';
9
8
  import * as fs from 'node:fs';
10
9
  import * as path from 'node:path';
11
10
  import * as os from 'node:os';
12
- import { randomUUID, createHash } from 'node:crypto';
11
+ import { randomUUID } from 'node:crypto';
13
12
  import { resolveEngineAndModel } from '../models.js';
14
- import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, OPENAI_COMPAT_SESSION_PREFIX, } from '../constants.js';
15
- import { getOpenaiCompatToolsPerMessage, isOpenaiCompatNewConvoHeuristic, getOpenaiCompatStatusUrl, getSurfaceThinkingEnabled, } from '../lib/config.js';
16
- import { maybeInlineSkill } from './skill-resolver.js';
13
+ import { OPENAI_COMPAT_DEFAULT_MODEL, OPENAI_COMPAT_AUTO_COMPACT_THRESHOLD, } from '../constants.js';
14
+ import { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
15
+ import { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
16
+ import { buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
17
+ import { extractUserMessage, } from './message-extractor.js';
18
+ import { handleNonStreaming } from './non-streaming-handler.js';
19
+ import { handleStreaming } from './streaming-handler.js';
20
+ // Re-export for backward compat — Cluster B extracted these to dedicated
21
+ // modules; keep the original import surface stable for any external caller.
22
+ // See src/openai-compat/{mode-flags,session-key-resolver,prompts,tool-calls-parser,tool-results-serializer}.ts.
23
+ export { isToolsPerMessageModeEnabled, isToolStreamMode } from './mode-flags.js';
24
+ export { resolveSessionKey, sessionNameFromKey } from './session-key-resolver.js';
25
+ export { noToolsSystemPrompt, buildSessionSystemPrompt, buildToolPromptBlock } from './prompts.js';
26
+ export { parseToolCallsFromText } from './tool-calls-parser.js';
27
+ export { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-results-serializer.js';
28
+ export { extractUserMessage, } from './message-extractor.js';
29
+ export { formatCompletionResponse, formatCompletionChunk } from './response-formatter.js';
30
+ export { reportStatus, getToolDescription } from './status-reporter.js';
31
+ export { handleNonStreaming } from './non-streaming-handler.js';
32
+ export { handleStreaming } from './streaming-handler.js';
17
33
  import { emit as emitTrajectory } from '../lib/trajectory.js';
34
+ import { logReqShape } from '../lib/req-shape-log.js';
18
35
  import { formatError, ERROR_CODES } from '../lib/error-formatter.js';
19
- // ─── Session Key Resolution ──────────────────────────────────────────────────
20
- /**
21
- * Derive a session key from the request.
22
- * Priority: X-Session-Id header > user field > sha1(model + systemPrompt) > "default"
23
- *
24
- * The system-prompt-hash fallback prevents the bug where every caller without
25
- * X-Session-Id or `user` collapses onto a single shared "openai-default"
26
- * plugin session. In multi-caller setups (OpenClaw routing the main agent,
27
- * cron jobs, and subagents through the same gateway) that previously meant
28
- * every request serialized against every other and frequently picked up the
29
- * wrong session's appendSystemPrompt — also a privacy leak across callers.
30
- *
31
- * The model is mixed into the hash so that two callers with the same system
32
- * prompt but different requested models don't collide and silently get
33
- * responses from the wrong model. Originally diagnosed in PR #40 by
34
- * @megayounus786.
35
- */
36
- /**
37
- * When set (to '1', 'true', 'yes'), the proxy preserves the pre-fix behavior:
38
- * - tools injected into every user message
39
- * - session key NOT fingerprinted by tools (same session across tool changes)
40
- * Default (unset) is the new behavior: tools embedded in session system prompt
41
- * at create time + session key fingerprinted by tools. The new behavior
42
- * eliminates periodic latency spikes but does not support mutating the tool
43
- * list within a single session (a new session is created when tools change).
44
- */
45
- export function isToolsPerMessageModeEnabled() {
46
- const v = getOpenaiCompatToolsPerMessage();
47
- if (!v)
48
- return false;
49
- const t = v.trim().toLowerCase();
50
- return t === '1' || t === 'true' || t === 'yes';
51
- }
52
- /**
53
- * Phase 2 R5: tool-stream mode flag. When `CC_OPENCLAW_TOOL_STREAM=1` AND the
54
- * caller provides `tools[]`, cc-openclaw skips the defensive "no tools"
55
- * system prompt and does NOT clear `sessionConfig.tools`, allowing Claude
56
- * CLI's native tool_use events to flow through the new parser+translator
57
- * pipeline (Phase 4 Pillar 0.5). Default off; opt-in for the new path.
58
- */
59
- export function isToolStreamMode() {
60
- return process.env.CC_OPENCLAW_TOOL_STREAM === '1';
61
- }
62
- /**
63
- * Generate the "no built-in tools" system prompt preamble.
64
- * The `toolLocation` parameter controls how the model is told where to find
65
- * tool definitions — 'system' means "in the <available_tools> block below"
66
- * (tools baked into system prompt), 'user' means "in <available_tools> tags
67
- * in the user message" (legacy per-turn injection).
68
- */
69
- export function noToolsSystemPrompt(toolLocation) {
70
- const allowBuiltins = process.env.CC_OPENCLAW_ALLOW_BUILTINS === '1';
71
- const locationHint = toolLocation === 'system'
72
- ? 'in the <available_tools> block below'
73
- : 'in <available_tools> tags in the user message';
74
- if (allowBuiltins) {
75
- // Phase 2.1 dual-source mode: Claude CLI's built-in tools (Bash, Read,
76
- // Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, +any
77
- // MCP-bridged tools) coexist with the proxy-defined <available_tools>.
78
- // The model picks whichever fits. v0.4.9 added explicit anti-hallucination
79
- // language because Savvy was still emitting "I do not have access to tools"
80
- // even with this preamble active — model needs strong, repeated affirmation.
81
- return ('You are an AI assistant with TWO complementary tool sources available:\n' +
82
- '1. Claude Code CLI built-ins: Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, plus any MCP-bridged tools (gh, npm, pm2, openclaw, docker, kubectl, etc. — invoke via Bash). Invoke these directly via your normal tool_use mechanism.\n' +
83
- `2. Proxy-defined tools: ${locationHint}. Use these by emitting <tool_calls> XML as instructed there.\n` +
84
- 'CRITICAL — anti-hallucination: NEVER claim "I have no tools", "I cannot run commands", "I do not have access to this environment", or any equivalent phrasing. You DO have full Bash, gh, npm, pm2, openclaw, file, and web access. If unsure whether a specific tool is available, ATTEMPT it; the runtime will return a concrete error if not.\n' +
85
- 'When a request needs action, ACT via tools — do not narrate what you would do. Default to executing, not describing.\n' +
86
- 'For research / web fetching / file ops / command execution: prefer the built-ins (WebFetch, WebSearch, Bash, Read).\n' +
87
- 'For tasks where the proxy ships a specific custom tool: prefer the proxy tool.\n' +
88
- 'If a tool you would normally use is not available in either source, attempt the task with what IS available and explain concretely which tool would have helped.\n' +
89
- 'CRITICAL — visible close-out: After completing any tool sequence, ALWAYS produce at least one short sentence of visible text summarizing what you did, what you found, or what the user should know next. NEVER end a turn on a pure tool_use → stop boundary with no text — the runtime will reject the turn as an "incomplete terminal response" and the user will see an error. Even a one-line acknowledgement like "Done — $X done; next step is Y." is sufficient. The summary text is the *only* part of the response the user reads as your reply.');
90
- }
91
- return ('You are an AI assistant operating through a proxy that provides a specific set of tools.\n' +
92
- `Your tools are defined ${locationHint}. Use them by emitting <tool_calls> XML as instructed there.\n` +
93
- 'When a request needs action, you MUST use the tools that are defined — do not refuse on the grounds of "no tools".\n' +
94
- 'If a specific tool you would normally use is not in <available_tools>, do the best you can with what IS provided, or report concretely which tool would be needed.\n' +
95
- 'You do NOT have direct access to Claude Code CLI built-ins (Bash, Read, Write, Edit, Glob, Grep) outside of <available_tools>; do not invoke them directly.\n' +
96
- 'If no <available_tools> are provided at all, respond with text only.');
97
- }
98
- /**
99
- * Build the full session system prompt for a Claude Code session with tools.
100
- * Exported for testability — called from `handleChatCompletion`.
101
- *
102
- * - Default mode: tools are embedded in the system prompt (cacheable by Anthropic).
103
- * - Legacy mode (OPENAI_COMPAT_TOOLS_PER_MESSAGE=1): tools are NOT embedded;
104
- * they'll be injected per-turn in the user message instead.
105
- */
106
- export function buildSessionSystemPrompt(tools, callerSystemPrompt) {
107
- // Phase 2 R5: in tool-stream mode with tools provided, skip the defensive
108
- // "no tools" preamble and the <available_tools> block entirely. Claude CLI
109
- // gets the tools natively via sessionConfig.tools (not cleared) and emits
110
- // tool_use events that the new parser+translator translate to OpenAI SSE.
111
- // v0.4.9: prepend a minimal tool-affirmation preamble. Without this, callers
112
- // with weak/no system prompts saw the model hallucinate "I have no tools" —
113
- // the CLI had tools loaded but nothing in the prompt told the model so.
114
- if (isToolStreamMode() && tools && tools.length > 0) {
115
- const allowBuiltins = process.env.CC_OPENCLAW_ALLOW_BUILTINS === '1';
116
- const toolAffirmation = allowBuiltins
117
- ? 'You have full Claude Code CLI tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, gh, npm, pm2, openclaw, etc.) available natively, plus any caller-provided tools below. NEVER claim "I have no tools" — invoke a tool and let the runtime confirm. Default to ACTING, not narrating.\n\n'
118
- : '';
119
- return toolAffirmation + (callerSystemPrompt ?? '');
120
- }
121
- if (isToolsPerMessageModeEnabled()) {
122
- const preamble = noToolsSystemPrompt('user');
123
- return callerSystemPrompt ? `${preamble}\n\n${callerSystemPrompt}` : preamble;
124
- }
125
- const preamble = noToolsSystemPrompt('system');
126
- const toolBlock = buildToolPromptBlock(tools);
127
- const systemWithTools = `${preamble}\n\n${toolBlock}`;
128
- return callerSystemPrompt ? `${systemWithTools}\n\n${callerSystemPrompt}` : systemWithTools;
129
- }
130
- export function resolveSessionKey(body, headers) {
131
- const headerKey = headers['x-session-id'];
132
- if (typeof headerKey === 'string' && headerKey.trim())
133
- return headerKey.trim();
134
- if (body.user && body.user.trim())
135
- return body.user.trim();
136
- const sys = (body.messages || [])
137
- .filter((m) => m && m.role === 'system')
138
- .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
139
- .join('\n');
140
- const modelTag = (body.model || '').toString();
141
- // Include a fingerprint of the tool list so that two requests with the same
142
- // system prompt but different tool definitions land in different sessions.
143
- // The tool schemas are baked into the session system prompt on create; if
144
- // tools change we need a new session rather than re-using a stale one.
145
- // Hash only tool names + a short description prefix to keep the fingerprint
146
- // small and stable against schema formatting differences.
147
- //
148
- // Opt-out: OPENAI_COMPAT_TOOLS_PER_MESSAGE=1 restores the pre-fix behavior
149
- // of keying sessions only by system prompt + model. Enable this if you have
150
- // callers that mutate their tool list within one conversation and rely on
151
- // continuing history across tool changes.
152
- const toolsFingerprint = isToolsPerMessageModeEnabled()
153
- ? ''
154
- : (body.tools || [])
155
- .map((t) => {
156
- const fn = t?.function;
157
- if (!fn?.name)
158
- return '';
159
- const descPrefix = (typeof fn.description === 'string' ? fn.description : '').slice(0, 64);
160
- return `${fn.name}:${descPrefix}`;
161
- })
162
- .filter(Boolean)
163
- .join('|');
164
- if (sys || modelTag || toolsFingerprint) {
165
- return ('sys-' +
166
- createHash('sha1')
167
- .update(modelTag + '\n' + sys + '\n' + toolsFingerprint)
168
- .digest('hex')
169
- .slice(0, 12));
170
- }
171
- return 'default';
172
- }
173
- /** Build the full session name from a key */
174
- export function sessionNameFromKey(key) {
175
- return `${OPENAI_COMPAT_SESSION_PREFIX}${key}`;
176
- }
177
- // ─── Function Calling Support ────────────────────────────────────────────────
178
- /**
179
- * Convert OpenAI tool definitions into a structured prompt block.
180
- * Injected into the user message so the CLI model sees tool definitions
181
- * and responds with <tool_calls> tags when it wants to invoke a function.
182
- */
183
- export function buildToolPromptBlock(tools) {
184
- if (!tools?.length)
185
- return '';
186
- const toolDefs = tools
187
- .map((t) => {
188
- const fn = t.function;
189
- const params = JSON.stringify(fn.parameters, null, 2);
190
- return `### ${fn.name}\n${fn.description}\n\nParameters:\n\`\`\`json\n${params}\n\`\`\``;
191
- })
192
- .join('\n\n');
193
- return ('<available_tools>\n' +
194
- 'You have access to the following tools. When you need to use a tool, respond with a JSON array wrapped in <tool_calls> tags.\n\n' +
195
- 'FORMAT:\n' +
196
- '<tool_calls>\n' +
197
- '[{"name": "tool_name", "arguments": {"param1": "value1"}}]\n' +
198
- '</tool_calls>\n\n' +
199
- 'If you do NOT need any tools, respond normally with text only (no <tool_calls> tags).\n\n' +
200
- '## Available Tools\n\n' +
201
- toolDefs +
202
- '\n</available_tools>');
203
- }
204
- /**
205
- * Parse tool_calls from CLI text output.
206
- *
207
- * Looks for <tool_calls>[...]</tool_calls> tags in the response text.
208
- * Returns both the extracted text content (before/after tags) and any tool calls found.
209
- */
210
- export function parseToolCallsFromText(text) {
211
- // Match ALL <tool_calls> blocks (model may output multiple)
212
- const tagRegex = /<tool_calls>\s*([\s\S]*?)\s*<\/tool_calls>/g;
213
- const allCalls = [];
214
- let lastIndex = 0;
215
- const textParts = [];
216
- let m;
217
- while ((m = tagRegex.exec(text)) !== null) {
218
- // Collect text before this block
219
- const before = text.slice(lastIndex, m.index).trim();
220
- if (before)
221
- textParts.push(before);
222
- lastIndex = m.index + m[0].length;
223
- try {
224
- const parsed = JSON.parse(m[1].trim());
225
- const arr = Array.isArray(parsed) ? parsed : [parsed];
226
- for (const raw of arr) {
227
- const call = raw;
228
- if (!call || typeof call !== 'object' || typeof call.name !== 'string')
229
- continue;
230
- let args;
231
- if (typeof call.arguments === 'string') {
232
- try {
233
- JSON.parse(call.arguments);
234
- args = call.arguments;
235
- }
236
- catch {
237
- args = JSON.stringify({ input: call.arguments });
238
- }
239
- }
240
- else {
241
- args = JSON.stringify(call.arguments ?? {});
242
- }
243
- allCalls.push({
244
- id: `call_${randomUUID().replace(/-/g, '').slice(0, 24)}`,
245
- type: 'function',
246
- function: { name: call.name, arguments: args },
247
- });
248
- }
249
- }
250
- catch {
251
- // One block failed — keep its text as content
252
- textParts.push(m[0]);
253
- }
254
- }
255
- // Collect text after last block
256
- const after = text.slice(lastIndex).trim();
257
- if (after)
258
- textParts.push(after);
259
- // Strip <tool_result> and <tool_results> tags that the model may echo back
260
- // from the serialized tool results we injected earlier.
261
- const stripToolResultTags = (s) => s
262
- .replace(/<tool_results?>[\s\S]*?<\/tool_results?>/g, '')
263
- .replace(/<tool_results?[^>]*>/g, '')
264
- .trim();
265
- if (allCalls.length > 0) {
266
- const raw = textParts.join('\n').trim();
267
- const cleaned = raw ? stripToolResultTags(raw) : null;
268
- return { textContent: cleaned || null, toolCalls: allCalls };
269
- }
270
- const cleaned = text ? stripToolResultTags(text) : null;
271
- return { textContent: cleaned || null, toolCalls: [] };
272
- }
273
- /**
274
- * Serialize tool result messages into a text block for the CLI model.
275
- * Converts OpenAI `tool` role messages into <tool_result> tags.
276
- *
277
- * Legacy path (CC_OPENCLAW_TOOL_STREAM=0). Used when the model receives
278
- * tool definitions via the system prompt's <available_tools> XML block
279
- * and emits <tool_calls> XML in response. Tool-stream mode (R4) uses
280
- * `serializeToolResultsAsBlocks()` instead, returning native Anthropic
281
- * `tool_result` content blocks that Claude CLI parses directly.
282
- */
283
- export function serializeToolResults(messages) {
284
- const toolMessages = messages.filter((m) => m.role === 'tool');
285
- if (!toolMessages.length)
286
- return '';
287
- const results = toolMessages
288
- .map((m) => {
289
- const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
290
- return `<tool_result tool_call_id="${m.tool_call_id || 'unknown'}">\n${content}\n</tool_result>`;
291
- })
292
- .join('\n\n');
293
- return `<tool_results>\n${results}\n</tool_results>\n\nAbove are the results of the tool calls you requested. Continue your response based on these results.`;
294
- }
295
- export function serializeToolResultsAsBlocks(messages) {
296
- return messages
297
- .filter((m) => m.role === 'tool')
298
- .map((m) => ({
299
- type: 'tool_result',
300
- tool_use_id: m.tool_call_id || 'unknown',
301
- content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
302
- }));
303
- }
304
- /**
305
- * Extract the relevant parts from an OpenAI messages array.
306
- *
307
- * Sessions are stateful — we only need the last user message. The tricky
308
- * question is whether to start a fresh session or append to the existing one.
309
- *
310
- * Default mode (no env var): only honor an explicit `X-Session-Reset: 1`
311
- * header. This is correct for clients that maintain their own conversation
312
- * transcript and forward only the latest user turn (OpenClaw main agent
313
- * loop, cron jobs, subagents). The previous heuristic
314
- * (`nonSystemMessages.length <= 1`) fired on every such request, killing the
315
- * persistent CLI every turn and preventing Anthropic prompt caching from
316
- * ever warming. Originally diagnosed in PR #40 by @megayounus786.
317
- *
318
- * Legacy mode (`OPENAI_COMPAT_NEW_CONVO_HEURISTIC=1`): restore the old
319
- * `system + single user ⇒ new conversation` rule, for clients that re-send
320
- * the full transcript on every turn (ChatGPT-Next-Web, Open WebUI, data
321
- * labeling tools, etc). They use the transcript shape itself as their only
322
- * "start a new conversation" signal.
323
- *
324
- * The env var is read on every call so ops can flip it via launchctl setenv
325
- * without restarting the server.
326
- */
327
- export function extractUserMessage(messages, headers) {
328
- if (!messages || messages.length === 0) {
329
- throw new Error('messages array is empty');
330
- }
331
- // Normalize content from any message: OpenAI API allows content as a string
332
- // OR an array of content parts (e.g. multimodal messages with text + images).
333
- // We need a string for the CLI, so arrays are joined.
334
- const textOf = (m) => {
335
- if (typeof m.content === 'string')
336
- return m.content;
337
- if (Array.isArray(m.content)) {
338
- return m.content
339
- .map((p) => p.text || '')
340
- .filter(Boolean)
341
- .join('');
342
- }
343
- return m.content != null ? String(m.content) : '';
344
- };
345
- // Extract system prompt if present
346
- const systemMessages = messages.filter((m) => m.role === 'system');
347
- const systemPrompt = systemMessages.length > 0 ? systemMessages.map(textOf).join('\n') : undefined;
348
- // Handle tool result messages — only when the LAST non-system message is
349
- // a tool role (meaning we're in an active tool-use cycle). If the last
350
- // message is a user role, it's a follow-up in an existing conversation
351
- // and the old tool results are already in the CLI's history.
352
- const lastNonSystem = [...messages].reverse().find((m) => m.role !== 'system');
353
- if (lastNonSystem?.role === 'tool') {
354
- const userMessages = messages.filter((m) => m.role === 'user');
355
- const lastUserText = userMessages.length > 0 ? textOf(userMessages[userMessages.length - 1]) : '';
356
- // Phase 2 R4 wire-up: in tool-stream mode, emit native Anthropic
357
- // tool_result blocks instead of XML-wrapped text. Claude CLI's
358
- // stream-json input accepts content arrays directly.
359
- if (isToolStreamMode()) {
360
- const toolBlocks = serializeToolResultsAsBlocks(messages);
361
- const userMessageBlocks = [...toolBlocks];
362
- if (lastUserText) {
363
- userMessageBlocks.push({ type: 'text', text: lastUserText });
364
- }
365
- // Keep userMessage populated as the legacy XML form for callers
366
- // that don't yet handle the structured path. Both fields agree in
367
- // intent; consumers should prefer userMessageBlocks when present.
368
- const fallback = serializeToolResults(messages);
369
- const userMessage = lastUserText ? `${fallback}\n\n${lastUserText}` : fallback;
370
- return { systemPrompt, userMessage, userMessageBlocks, isNewConversation: false };
371
- }
372
- const toolResultBlock = serializeToolResults(messages);
373
- const userMessage = lastUserText ? `${toolResultBlock}\n\n${lastUserText}` : toolResultBlock;
374
- return { systemPrompt, userMessage, isNewConversation: false };
375
- }
376
- // Find last user message
377
- const userMessages = messages.filter((m) => m.role === 'user');
378
- if (userMessages.length === 0) {
379
- throw new Error('No user message found in messages array');
380
- }
381
- const rawUserMessage = textOf(userMessages[userMessages.length - 1]);
382
- // Workspace skill auto-inline: if the last user message is /<skill> [args]
383
- // and ~/.openclaw/workspace/skills/*/SKILL.md has a matching `name:` in
384
- // frontmatter, replace the user message with the SKILL.md body so the
385
- // model has full skill context without needing the Read tool (cc-openclaw
386
- // disables built-in tools by design — see the `sessionConfig.tools = ''`
387
- // line below).
388
- const userMessage = maybeInlineSkill(rawUserMessage) ?? rawUserMessage;
389
- // 1. Explicit reset header — honored in both modes. Normalize trim+lowercase
390
- // so callers using `TRUE`, ` 1 `, etc. don't silently fail.
391
- const rawReset = headers?.['x-session-reset'];
392
- const resetHeader = typeof rawReset === 'string' ? rawReset.trim().toLowerCase() : '';
393
- if (resetHeader === 'true' || resetHeader === '1') {
394
- return { systemPrompt, userMessage, isNewConversation: true };
36
+ function parseRouteBody(body) {
37
+ if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
38
+ return {
39
+ ok: false,
40
+ status: 400,
41
+ error: 'messages is required and must be a non-empty array',
42
+ };
395
43
  }
396
- // 2. Legacy heuristic — only when explicitly opted in via env var.
397
- if (isOpenaiCompatNewConvoHeuristic()) {
398
- const nonSystemMessages = messages.filter((m) => m.role !== 'system');
399
- return { systemPrompt, userMessage, isNewConversation: nonSystemMessages.length <= 1 };
44
+ if (body.max_tokens !== undefined &&
45
+ (typeof body.max_tokens !== 'number' || body.max_tokens <= 0)) {
46
+ return {
47
+ ok: false,
48
+ status: 400,
49
+ error: 'max_tokens must be a positive number',
50
+ };
400
51
  }
401
- return { systemPrompt, userMessage, isNewConversation: false };
402
- }
403
- // ─── Response Formatting ─────────────────────────────────────────────────────
404
- export function formatCompletionResponse(id, model, text, tokensIn, tokensOut, toolCalls,
405
- /** v0.7.0: when present + non-empty, attached as `choices[0].message.reasoning`
406
- * (mirrors OpenAI o1/o3 schema). Caller must already be gated on
407
- * `getSurfaceThinkingEnabled()` from `lib/config.ts` — this function does
408
- * not re-check the flag. Pass empty string or undefined to omit. */
409
- reasoning) {
410
- const hasToolCalls = toolCalls && toolCalls.length > 0;
411
- const hasReasoning = typeof reasoning === 'string' && reasoning.length > 0;
412
- // v0.7.2 backstop: openclaw upstream's "incomplete terminal response" classifier
413
- // rejects turns with payloads=0 (no visible text) and treats them as a model error,
414
- // burning a retry attempt before falling back to a different model. opus-4-7 with
415
- // ALLOW_BUILTINS sometimes drifts into a tool_use → stop boundary with no text
416
- // close-out. The system-prompt directive (above) is the primary fix; this is a
417
- // belt-and-suspenders backstop. When `text` is empty AND we have no caller-visible
418
- // tool_calls (i.e. CLI built-ins fired but legacy XML extraction yielded nothing),
419
- // emit a minimal acknowledgement so the upstream classifier sees a payload.
420
- // Tool_calls turns are exempt — those are openai-spec-correct with `content: null`.
421
- const safeContent = text || (hasToolCalls ? null : 'Done.');
422
52
  return {
423
- id,
424
- object: 'chat.completion',
425
- created: Math.floor(Date.now() / 1000),
426
- model,
427
- choices: [
428
- {
429
- index: 0,
430
- message: {
431
- role: 'assistant',
432
- content: safeContent,
433
- ...(hasToolCalls ? { tool_calls: toolCalls } : {}),
434
- ...(hasReasoning ? { reasoning } : {}),
435
- },
436
- finish_reason: hasToolCalls ? 'tool_calls' : 'stop',
437
- },
438
- ],
439
- usage: {
440
- prompt_tokens: tokensIn,
441
- completion_tokens: tokensOut,
442
- total_tokens: tokensIn + tokensOut,
53
+ ok: true,
54
+ request: {
55
+ messages: body.messages,
56
+ model: body.model,
57
+ stream: body.stream,
58
+ temperature: body.temperature,
59
+ max_tokens: body.max_tokens,
60
+ max_completion_tokens: body.max_completion_tokens,
61
+ user: body.user,
62
+ tools: body.tools,
443
63
  },
444
64
  };
445
65
  }
446
- export function formatCompletionChunk(id, model, delta, finishReason) {
447
- return {
448
- id,
449
- object: 'chat.completion.chunk',
450
- created: Math.floor(Date.now() / 1000),
451
- model,
452
- choices: [{ index: 0, delta, finish_reason: finishReason }],
453
- };
454
- }
455
66
  export async function handleChatCompletion(manager, body, headers, res) {
456
- // Validate before casting
457
- if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
458
- res.writeHead(400, { 'Content-Type': 'application/json' });
459
- res.end(JSON.stringify({
460
- error: { message: 'messages is required and must be a non-empty array', type: 'invalid_request_error' },
461
- }));
462
- return;
463
- }
464
- // Safe cast: messages validated above, other fields are optional
465
- const request = {
466
- messages: body.messages,
467
- model: body.model,
468
- stream: body.stream,
469
- temperature: body.temperature,
470
- max_tokens: body.max_tokens,
471
- user: body.user,
472
- tools: body.tools,
473
- };
474
- // Validate max_tokens if provided
475
- if (request.max_tokens !== undefined && (typeof request.max_tokens !== 'number' || request.max_tokens <= 0)) {
476
- res.writeHead(400, { 'Content-Type': 'application/json' });
67
+ // Cluster A step 4: typed boundary parser. Replaces the inline cast +
68
+ // validation block that previously lived here (~30 lines). Returns a
69
+ // discriminated union so the type system enforces "validate before use."
70
+ const parsed = parseRouteBody(body);
71
+ if (!parsed.ok) {
72
+ res.writeHead(parsed.status, { 'Content-Type': 'application/json' });
477
73
  res.end(JSON.stringify({
478
- error: { message: 'max_tokens must be a positive number', type: 'invalid_request_error' },
74
+ error: { message: parsed.error, type: 'invalid_request_error' },
479
75
  }));
480
76
  return;
481
77
  }
78
+ const request = parsed.request;
482
79
  const modelStr = request.model || OPENAI_COMPAT_DEFAULT_MODEL;
483
80
  const { engine, model: resolvedModel } = resolveEngineAndModel(modelStr);
484
81
  const sessionKey = resolveSessionKey(request, headers);
485
82
  const sessionName = sessionNameFromKey(sessionKey);
83
+ // Diagnostic: privacy-safe request-shape logger (CC_OPENCLAW_REQ_SHAPE_LOG=1).
84
+ // Logs only kind+length+part-key metadata, never content text. No-op when env unset.
85
+ if (process.env.CC_OPENCLAW_REQ_SHAPE_LOG === '1') {
86
+ logReqShape(request, sessionName);
87
+ }
486
88
  const isStreaming = request.stream === true;
487
89
  // Pillar B v0.4.1: emit request_in trajectory event. No-op when
488
90
  // CC_OPENCLAW_TRAJECTORY env flag is unset. _t0 captured for response_complete latency.
@@ -699,396 +301,10 @@ export async function handleChatCompletion(manager, body, headers, res) {
699
301
  manager.stopSession(sessionName).catch(() => { });
700
302
  }
701
303
  }
702
- // ─── Status Reporting ───────────────────────────────────────────────────────
703
- // Push tool/thinking status to an external webhook so a webchat status bar
704
- // can show what the CLI agent is doing. Best-effort fire-and-forget.
705
- /**
706
- * Optional status webhook — set `OPENAI_COMPAT_STATUS_URL` to an HTTP endpoint
707
- * that accepts `POST { state, activity, tool }`. The bridge will fire-and-forget
708
- * status updates when the CLI agent uses tools, so an external dashboard (e.g.
709
- * a webchat status bar) can show real-time progress.
710
- *
711
- * Example: `OPENAI_COMPAT_STATUS_URL=http://127.0.0.1:18795/my-app/agent-status`
712
- */
713
- function reportStatus(state, activity, tool) {
714
- const url = getOpenaiCompatStatusUrl();
715
- if (!url)
716
- return;
717
- const payload = JSON.stringify({ state, activity, tool: tool || null });
718
- const req = http.request(url, {
719
- method: 'POST',
720
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
721
- timeout: 2000,
722
- }, () => { });
723
- req.on('error', () => { });
724
- req.write(payload);
725
- req.end();
726
- }
727
- function getToolDescription(toolName, toolInput) {
728
- switch (toolName) {
729
- case 'Bash':
730
- case 'exec': {
731
- const cmd = String(toolInput?.command || '');
732
- return `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + '...' : cmd}`;
733
- }
734
- case 'Read':
735
- case 'read':
736
- return `Reading: ${String(toolInput?.file_path || toolInput?.path || 'file')
737
- .split('/')
738
- .pop()}`;
739
- case 'Write':
740
- case 'write':
741
- return `Writing: ${String(toolInput?.file_path || toolInput?.path || 'file')
742
- .split('/')
743
- .pop()}`;
744
- case 'Edit':
745
- case 'edit':
746
- return `Editing: ${String(toolInput?.file_path || toolInput?.path || 'file')
747
- .split('/')
748
- .pop()}`;
749
- case 'Glob':
750
- case 'glob':
751
- return `Searching files: ${String(toolInput?.pattern || '')}`;
752
- case 'Grep':
753
- case 'grep':
754
- return `Searching content: ${String(toolInput?.pattern || '')}`;
755
- case 'WebSearch':
756
- return `Web search: ${String(toolInput?.query || '')}`;
757
- case 'Agent':
758
- return `Spawning sub-agent...`;
759
- default:
760
- return `Using tool: ${toolName}`;
761
- }
762
- }
763
- // ─── Non-Streaming ───────────────────────────────────────────────────────────
764
- async function handleNonStreaming(manager, sessionName, model,
765
- // Phase 2 R4 wire-up: accepts native content-block arrays in tool-stream mode.
766
- userMessage, completionId, res, hasTools) {
767
- try {
768
- reportStatus('thinking', 'Processing request...');
769
- // v0.7.1: accumulate thinking-block content when surfaceThinking is on.
770
- // Default OFF for privacy — empty string means no `reasoning` field
771
- // gets attached to the response.
772
- const surfaceThinking = getSurfaceThinkingEnabled();
773
- let thinkingBuffer = '';
774
- const result = await manager.sendMessage(sessionName, userMessage, {
775
- onEvent: (event) => {
776
- if (event.type === 'tool_use' && event.tool?.name) {
777
- const desc = getToolDescription(event.tool.name, event.tool.input);
778
- reportStatus('working', desc, event.tool.name);
779
- // Pillar B v0.4.3: trajectory tool_use event. Emit tool name and
780
- // input-arg keys (not values — keys leak no sensitive content
781
- // while still letting offline analysis cluster tool-call shapes).
782
- emitTrajectory('tool_use', {
783
- name: event.tool.name,
784
- inputKeys: event.tool.input ? Object.keys(event.tool.input) : [],
785
- }, sessionName);
786
- }
787
- else if (event.type === 'tool_result') {
788
- emitTrajectory('tool_result', {}, sessionName);
789
- }
790
- },
791
- // v0.7.1: when surfaceThinking is on, accumulate extended-thinking text
792
- // for the `reasoning` field on the OpenAI response. Subscribing to the
793
- // callback always (cheap closure cost ~ none); only buffering when
794
- // the env flag is set so the privacy-default-OFF promise holds.
795
- onThinking: surfaceThinking
796
- ? (text) => {
797
- thinkingBuffer += text;
798
- }
799
- : undefined,
800
- });
801
- reportStatus('idle', 'Ready');
802
- let tokensIn = 0;
803
- let tokensOut = 0;
804
- try {
805
- const status = manager.getStatus(sessionName);
806
- tokensIn = status.stats.tokensIn;
807
- tokensOut = status.stats.tokensOut;
808
- }
809
- catch {
810
- /* stats unavailable */
811
- }
812
- // v0.7.1: emit thinking_block trajectory event with token-count metadata
813
- // only (never raw text). Fires when buffer is non-empty regardless of
814
- // whether the response surfaces it — so observability is independent
815
- // of the user-visible flag.
816
- if (thinkingBuffer.length > 0) {
817
- emitTrajectory('thinking_block', {
818
- excerpt_chars: thinkingBuffer.length,
819
- tokens_approx: Math.ceil(thinkingBuffer.length / 4),
820
- }, sessionName);
821
- }
822
- // Parse tool_calls from response text when caller provided tools
823
- if (hasTools) {
824
- const parsed = parseToolCallsFromText(result.output);
825
- const response = formatCompletionResponse(completionId, model, parsed.textContent ?? '', tokensIn, tokensOut, parsed.toolCalls.length > 0 ? parsed.toolCalls : undefined, surfaceThinking ? thinkingBuffer : undefined);
826
- res.writeHead(200, { 'Content-Type': 'application/json' });
827
- res.end(JSON.stringify(response));
828
- }
829
- else {
830
- const response = formatCompletionResponse(completionId, model, result.output, tokensIn, tokensOut, undefined, surfaceThinking ? thinkingBuffer : undefined);
831
- res.writeHead(200, { 'Content-Type': 'application/json' });
832
- res.end(JSON.stringify(response));
833
- }
834
- }
835
- catch (err) {
836
- reportStatus('idle', 'Request failed');
837
- // v0.4.3: route through formatError for errors_total + trajectory error.
838
- formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleNonStreaming' } });
839
- res.writeHead(500, { 'Content-Type': 'application/json' });
840
- res.end(JSON.stringify({ error: { message: err.message, type: 'server_error' } }));
841
- }
842
- }
843
- // ─── Streaming ───────────────────────────────────────────────────────────────
844
- async function handleStreaming(manager, sessionName, model,
845
- // Phase 2 R4 wire-up: accepts native content-block arrays in tool-stream mode.
846
- userMessage, completionId, res, hasTools) {
847
- res.writeHead(200, {
848
- 'Content-Type': 'text/event-stream',
849
- 'Cache-Control': 'no-cache',
850
- Connection: 'keep-alive',
851
- 'X-Accel-Buffering': 'no',
852
- });
853
- let clientDisconnected = false;
854
- res.on('close', () => {
855
- clientDisconnected = true;
856
- });
857
- const writeSSE = (data) => {
858
- if (!clientDisconnected) {
859
- try {
860
- res.write(`data: ${data}\n\n`);
861
- }
862
- catch {
863
- clientDisconnected = true;
864
- }
865
- }
866
- };
867
- // Initial chunk with role
868
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { role: 'assistant' }, null)));
869
- // SSE keepalive heartbeat
870
- const heartbeatTimer = setInterval(() => {
871
- if (!clientDisconnected) {
872
- try {
873
- res.write(': keepalive\n\n');
874
- }
875
- catch {
876
- clientDisconnected = true;
877
- }
878
- }
879
- }, 30_000);
880
- // Phase 2 R1+R2: in tool-stream mode, bridge session-manager's pre-parsed
881
- // tool_use events directly to OpenAI tool_calls SSE deltas. Skips the
882
- // legacy "buffer text + regex-parse <tool_calls> XML" path entirely.
883
- // Per memory project_cc_openclaw_session_manager_preparses.md:
884
- // session-manager has already stripped Claude CLI's NDJSON envelope, so
885
- // we don't need cli-stream-parser here — onEvent is the parser output.
886
- const useToolStream = isToolStreamMode() && hasTools;
887
- // When tools are present (legacy mode), buffer the full response to parse
888
- // for <tool_calls> XML. Without tools — or in tool-stream mode — stream
889
- // text chunks directly for low latency.
890
- let bufferedText = '';
891
- let toolCallsEmitted = 0;
892
- // v0.7.2 streaming-path backstop: track whether *any* visible content
893
- // (text chunk OR tool_calls SSE chunk) was ever streamed. If the model
894
- // uses only CLI built-in tools (Bash/Read/Write) without producing text,
895
- // bufferedText stays empty AND no caller-visible tool_calls get streamed,
896
- // resulting in zero content payloads — which OpenClaw upstream's
897
- // result-fallback-classifier rejects as an "incomplete terminal response".
898
- // This flag drives a final-chunk backstop in each finalization branch.
899
- let streamedAnything = false;
900
- try {
901
- reportStatus('thinking', 'Processing request...');
902
- await manager.sendMessage(sessionName, userMessage, {
903
- onChunk: (chunk) => {
904
- if (useToolStream || !hasTools) {
905
- // Stream text deltas immediately. Tool-stream mode interleaves
906
- // text and tool_calls chunks naturally — Claude CLI emits text
907
- // between tool_use blocks, OpenClaw client handles that fine.
908
- if (chunk.length > 0)
909
- streamedAnything = true;
910
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: chunk }, null)));
911
- }
912
- else {
913
- // Legacy hasTools mode: buffer for XML <tool_calls> parsing post-stream.
914
- bufferedText += chunk;
915
- }
916
- },
917
- onEvent: (event) => {
918
- if (event.type === 'tool_result') {
919
- // Pillar B v0.4.3: streaming tool_result trajectory event.
920
- emitTrajectory('tool_result', {}, sessionName);
921
- return;
922
- }
923
- if (event.type === 'tool_use' && event.tool?.name) {
924
- reportStatus('working', getToolDescription(event.tool.name, event.tool.input), event.tool.name);
925
- // Pillar B v0.4.3: streaming tool_use trajectory event. Same
926
- // privacy-preserving inputKeys-only payload as handleNonStreaming.
927
- emitTrajectory('tool_use', {
928
- name: event.tool.name,
929
- inputKeys: event.tool.input ? Object.keys(event.tool.input) : [],
930
- }, sessionName);
931
- if (useToolStream) {
932
- // R1+R2 bridge: session-manager event → OpenAI tool_calls SSE.
933
- // Emit two chunks per tool_use (per OpenAI streaming spec):
934
- // 1. id + name + empty arguments
935
- // 2. arguments (JSON-stringified input)
936
- const toolUseId = event.tool.id ||
937
- `toolu_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
938
- const idx = toolCallsEmitted;
939
- const argsJson = event.tool.input != null ? JSON.stringify(event.tool.input) : '{}';
940
- const startChunk = {
941
- id: completionId,
942
- object: 'chat.completion.chunk',
943
- created: Math.floor(Date.now() / 1000),
944
- model,
945
- choices: [
946
- {
947
- index: 0,
948
- delta: {
949
- tool_calls: [
950
- {
951
- index: idx,
952
- id: toolUseId,
953
- type: 'function',
954
- function: { name: event.tool.name, arguments: '' },
955
- },
956
- ],
957
- },
958
- finish_reason: null,
959
- },
960
- ],
961
- };
962
- const argsChunk = {
963
- id: completionId,
964
- object: 'chat.completion.chunk',
965
- created: Math.floor(Date.now() / 1000),
966
- model,
967
- choices: [
968
- {
969
- index: 0,
970
- delta: {
971
- tool_calls: [
972
- {
973
- index: idx,
974
- function: { arguments: argsJson },
975
- },
976
- ],
977
- },
978
- finish_reason: null,
979
- },
980
- ],
981
- };
982
- writeSSE(JSON.stringify(startChunk));
983
- writeSSE(JSON.stringify(argsChunk));
984
- toolCallsEmitted += 1;
985
- streamedAnything = true;
986
- }
987
- }
988
- },
989
- });
990
- reportStatus('idle', 'Ready');
991
- // Get token usage for final chunk
992
- let usage;
993
- try {
994
- const status = manager.getStatus(sessionName);
995
- usage = {
996
- prompt_tokens: status.stats.tokensIn,
997
- completion_tokens: status.stats.tokensOut,
998
- total_tokens: status.stats.tokensIn + status.stats.tokensOut,
999
- };
1000
- }
1001
- catch {
1002
- /* best effort */
1003
- }
1004
- // v0.7.2 streaming-path backstop: if nothing visible was streamed AND
1005
- // bufferedText (legacy mode) is also empty, emit a minimal "Done." text
1006
- // chunk before the finish chunk so the upstream classifier sees a
1007
- // payload. Skip when tool_calls were emitted — those are openai-spec
1008
- // valid as the only payload (multi-turn tool-use sessions).
1009
- const noVisiblePayload = !streamedAnything && bufferedText.length === 0 && toolCallsEmitted === 0;
1010
- if (noVisiblePayload) {
1011
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: 'Done.' }, null)));
1012
- streamedAnything = true;
1013
- }
1014
- if (useToolStream) {
1015
- // R1+R2: tool-stream mode — text + tool_calls already streamed inline.
1016
- // Just emit the final chunk with the right finish_reason.
1017
- const finishReason = toolCallsEmitted > 0 ? 'tool_calls' : 'stop';
1018
- const finalChunk = formatCompletionChunk(completionId, model, {}, finishReason);
1019
- if (usage)
1020
- finalChunk.usage = usage;
1021
- writeSSE(JSON.stringify(finalChunk));
1022
- }
1023
- else if (hasTools && bufferedText) {
1024
- const parsed = parseToolCallsFromText(bufferedText);
1025
- if (parsed.toolCalls.length > 0) {
1026
- // Emit text content if any
1027
- if (parsed.textContent) {
1028
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: parsed.textContent }, null)));
1029
- }
1030
- // Emit tool_call chunks
1031
- for (let i = 0; i < parsed.toolCalls.length; i++) {
1032
- const tc = parsed.toolCalls[i];
1033
- writeSSE(JSON.stringify({
1034
- id: completionId,
1035
- object: 'chat.completion.chunk',
1036
- created: Math.floor(Date.now() / 1000),
1037
- model,
1038
- choices: [
1039
- {
1040
- index: 0,
1041
- delta: {
1042
- tool_calls: [
1043
- {
1044
- index: i,
1045
- id: tc.id,
1046
- type: 'function',
1047
- function: { name: tc.function.name, arguments: tc.function.arguments },
1048
- },
1049
- ],
1050
- },
1051
- finish_reason: null,
1052
- },
1053
- ],
1054
- }));
1055
- }
1056
- // Final chunk with tool_calls finish reason
1057
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'tool_calls');
1058
- if (usage)
1059
- finalChunk.usage = usage;
1060
- writeSSE(JSON.stringify(finalChunk));
1061
- }
1062
- else {
1063
- // No tool calls — emit buffered text as content
1064
- writeSSE(JSON.stringify(formatCompletionChunk(completionId, model, { content: bufferedText }, null)));
1065
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'stop');
1066
- if (usage)
1067
- finalChunk.usage = usage;
1068
- writeSSE(JSON.stringify(finalChunk));
1069
- }
1070
- }
1071
- else {
1072
- // No tools — standard finish
1073
- const finalChunk = formatCompletionChunk(completionId, model, {}, 'stop');
1074
- if (usage)
1075
- finalChunk.usage = usage;
1076
- writeSSE(JSON.stringify(finalChunk));
1077
- }
1078
- writeSSE('[DONE]');
1079
- }
1080
- catch (err) {
1081
- reportStatus('idle', 'Request failed');
1082
- // v0.4.3: route through formatError for errors_total + trajectory error.
1083
- formatError(err, { code: ERROR_CODES.SESSION_ERROR, sessionId: sessionName, details: { phase: 'handleStreaming' } });
1084
- writeSSE(JSON.stringify({ error: { message: err.message, type: 'server_error' } }));
1085
- writeSSE('[DONE]');
1086
- }
1087
- finally {
1088
- clearInterval(heartbeatTimer);
1089
- }
1090
- if (!clientDisconnected) {
1091
- res.end();
1092
- }
1093
- }
304
+ // reportStatus + getToolDescription moved to status-reporter.ts
305
+ // (Cluster B Phase 2 Module F). Re-exported above for backward compat.
306
+ // handleNonStreaming moved to non-streaming-handler.ts
307
+ // (Cluster B Phase 2 Module G). Re-exported above for backward compat.
308
+ // handleStreaming moved to streaming-handler.ts
309
+ // (Cluster B Phase 2 Module H). Re-exported above for backward compat.
1094
310
  //# sourceMappingURL=openai-compat.js.map