elasticdash-sdk 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (349) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +775 -0
  3. package/dist/browser-ui.d.ts +43 -0
  4. package/dist/browser-ui.d.ts.map +1 -0
  5. package/dist/browser-ui.js +246 -0
  6. package/dist/browser-ui.js.map +1 -0
  7. package/dist/capture/event.d.ts +33 -0
  8. package/dist/capture/event.d.ts.map +1 -0
  9. package/dist/capture/event.js +2 -0
  10. package/dist/capture/event.js.map +1 -0
  11. package/dist/capture/index.d.ts +4 -0
  12. package/dist/capture/index.d.ts.map +1 -0
  13. package/dist/capture/index.js +4 -0
  14. package/dist/capture/index.js.map +1 -0
  15. package/dist/capture/recorder.d.ts +24 -0
  16. package/dist/capture/recorder.d.ts.map +1 -0
  17. package/dist/capture/recorder.js +46 -0
  18. package/dist/capture/recorder.js.map +1 -0
  19. package/dist/capture/replay.d.ts +20 -0
  20. package/dist/capture/replay.d.ts.map +1 -0
  21. package/dist/capture/replay.js +47 -0
  22. package/dist/capture/replay.js.map +1 -0
  23. package/dist/ci/api-client.d.ts +38 -0
  24. package/dist/ci/api-client.d.ts.map +1 -0
  25. package/dist/ci/api-client.js +96 -0
  26. package/dist/ci/api-client.js.map +1 -0
  27. package/dist/ci/benchmark.d.ts +33 -0
  28. package/dist/ci/benchmark.d.ts.map +1 -0
  29. package/dist/ci/benchmark.js +213 -0
  30. package/dist/ci/benchmark.js.map +1 -0
  31. package/dist/ci/ed-runner.d.ts +48 -0
  32. package/dist/ci/ed-runner.d.ts.map +1 -0
  33. package/dist/ci/ed-runner.js +260 -0
  34. package/dist/ci/ed-runner.js.map +1 -0
  35. package/dist/ci/executor.d.ts +13 -0
  36. package/dist/ci/executor.d.ts.map +1 -0
  37. package/dist/ci/executor.js +542 -0
  38. package/dist/ci/executor.js.map +1 -0
  39. package/dist/ci/git-info.d.ts +17 -0
  40. package/dist/ci/git-info.d.ts.map +1 -0
  41. package/dist/ci/git-info.js +102 -0
  42. package/dist/ci/git-info.js.map +1 -0
  43. package/dist/ci/index.d.ts +6 -0
  44. package/dist/ci/index.d.ts.map +1 -0
  45. package/dist/ci/index.js +4 -0
  46. package/dist/ci/index.js.map +1 -0
  47. package/dist/ci/measurement.d.ts +9 -0
  48. package/dist/ci/measurement.d.ts.map +1 -0
  49. package/dist/ci/measurement.js +15 -0
  50. package/dist/ci/measurement.js.map +1 -0
  51. package/dist/ci/replay.d.ts +31 -0
  52. package/dist/ci/replay.d.ts.map +1 -0
  53. package/dist/ci/replay.js +96 -0
  54. package/dist/ci/replay.js.map +1 -0
  55. package/dist/ci/reporters/default.d.ts +8 -0
  56. package/dist/ci/reporters/default.d.ts.map +1 -0
  57. package/dist/ci/reporters/default.js +46 -0
  58. package/dist/ci/reporters/default.js.map +1 -0
  59. package/dist/ci/reporters/index.d.ts +8 -0
  60. package/dist/ci/reporters/index.d.ts.map +1 -0
  61. package/dist/ci/reporters/index.js +14 -0
  62. package/dist/ci/reporters/index.js.map +1 -0
  63. package/dist/ci/reporters/json.d.ts +8 -0
  64. package/dist/ci/reporters/json.d.ts.map +1 -0
  65. package/dist/ci/reporters/json.js +14 -0
  66. package/dist/ci/reporters/json.js.map +1 -0
  67. package/dist/ci/reporters/junit.d.ts +8 -0
  68. package/dist/ci/reporters/junit.d.ts.map +1 -0
  69. package/dist/ci/reporters/junit.js +48 -0
  70. package/dist/ci/reporters/junit.js.map +1 -0
  71. package/dist/ci/runner.d.ts +3 -0
  72. package/dist/ci/runner.d.ts.map +1 -0
  73. package/dist/ci/runner.js +187 -0
  74. package/dist/ci/runner.js.map +1 -0
  75. package/dist/ci/test-discovery.d.ts +5 -0
  76. package/dist/ci/test-discovery.d.ts.map +1 -0
  77. package/dist/ci/test-discovery.js +11 -0
  78. package/dist/ci/test-discovery.js.map +1 -0
  79. package/dist/ci/test-loader.d.ts +19 -0
  80. package/dist/ci/test-loader.d.ts.map +1 -0
  81. package/dist/ci/test-loader.js +149 -0
  82. package/dist/ci/test-loader.js.map +1 -0
  83. package/dist/ci/test-registry.d.ts +42 -0
  84. package/dist/ci/test-registry.d.ts.map +1 -0
  85. package/dist/ci/test-registry.js +18 -0
  86. package/dist/ci/test-registry.js.map +1 -0
  87. package/dist/ci/trace-schema.d.ts +30 -0
  88. package/dist/ci/trace-schema.d.ts.map +1 -0
  89. package/dist/ci/trace-schema.js +66 -0
  90. package/dist/ci/trace-schema.js.map +1 -0
  91. package/dist/ci/trace-writer.d.ts +16 -0
  92. package/dist/ci/trace-writer.d.ts.map +1 -0
  93. package/dist/ci/trace-writer.js +108 -0
  94. package/dist/ci/trace-writer.js.map +1 -0
  95. package/dist/ci/types.d.ts +108 -0
  96. package/dist/ci/types.d.ts.map +1 -0
  97. package/dist/ci/types.js +3 -0
  98. package/dist/ci/types.js.map +1 -0
  99. package/dist/ci/upload-client.d.ts +74 -0
  100. package/dist/ci/upload-client.d.ts.map +1 -0
  101. package/dist/ci/upload-client.js +195 -0
  102. package/dist/ci/upload-client.js.map +1 -0
  103. package/dist/cli.d.ts +3 -0
  104. package/dist/cli.d.ts.map +1 -0
  105. package/dist/cli.js +716 -0
  106. package/dist/cli.js.map +1 -0
  107. package/dist/core/agent-state.d.ts +47 -0
  108. package/dist/core/agent-state.d.ts.map +1 -0
  109. package/dist/core/agent-state.js +137 -0
  110. package/dist/core/agent-state.js.map +1 -0
  111. package/dist/core/judge-utils.d.ts +22 -0
  112. package/dist/core/judge-utils.d.ts.map +1 -0
  113. package/dist/core/judge-utils.js +211 -0
  114. package/dist/core/judge-utils.js.map +1 -0
  115. package/dist/core/registry.d.ts +28 -0
  116. package/dist/core/registry.d.ts.map +1 -0
  117. package/dist/core/registry.js +52 -0
  118. package/dist/core/registry.js.map +1 -0
  119. package/dist/dashboard-server.d.ts +65 -0
  120. package/dist/dashboard-server.d.ts.map +1 -0
  121. package/dist/dashboard-server.js +3940 -0
  122. package/dist/dashboard-server.js.map +1 -0
  123. package/dist/execution/tool-runner.d.ts +26 -0
  124. package/dist/execution/tool-runner.d.ts.map +1 -0
  125. package/dist/execution/tool-runner.js +316 -0
  126. package/dist/execution/tool-runner.js.map +1 -0
  127. package/dist/html/dashboard.html +2218 -0
  128. package/dist/http.d.ts +14 -0
  129. package/dist/http.d.ts.map +1 -0
  130. package/dist/http.js +13 -0
  131. package/dist/http.js.map +1 -0
  132. package/dist/index.cjs +8102 -0
  133. package/dist/index.d.ts +61 -0
  134. package/dist/index.d.ts.map +1 -0
  135. package/dist/index.js +67 -0
  136. package/dist/index.js.map +1 -0
  137. package/dist/interceptors/ai-interceptor.d.ts +26 -0
  138. package/dist/interceptors/ai-interceptor.d.ts.map +1 -0
  139. package/dist/interceptors/ai-interceptor.js +756 -0
  140. package/dist/interceptors/ai-interceptor.js.map +1 -0
  141. package/dist/interceptors/db-auto.d.ts +8 -0
  142. package/dist/interceptors/db-auto.d.ts.map +1 -0
  143. package/dist/interceptors/db-auto.js +217 -0
  144. package/dist/interceptors/db-auto.js.map +1 -0
  145. package/dist/interceptors/db.d.ts +23 -0
  146. package/dist/interceptors/db.d.ts.map +1 -0
  147. package/dist/interceptors/db.js +137 -0
  148. package/dist/interceptors/db.js.map +1 -0
  149. package/dist/interceptors/http.d.ts +28 -0
  150. package/dist/interceptors/http.d.ts.map +1 -0
  151. package/dist/interceptors/http.js +356 -0
  152. package/dist/interceptors/http.js.map +1 -0
  153. package/dist/interceptors/side-effects.d.ts +7 -0
  154. package/dist/interceptors/side-effects.d.ts.map +1 -0
  155. package/dist/interceptors/side-effects.js +72 -0
  156. package/dist/interceptors/side-effects.js.map +1 -0
  157. package/dist/interceptors/telemetry-push.d.ts +142 -0
  158. package/dist/interceptors/telemetry-push.d.ts.map +1 -0
  159. package/dist/interceptors/telemetry-push.js +463 -0
  160. package/dist/interceptors/telemetry-push.js.map +1 -0
  161. package/dist/interceptors/tool.d.ts +2 -0
  162. package/dist/interceptors/tool.d.ts.map +1 -0
  163. package/dist/interceptors/tool.js +274 -0
  164. package/dist/interceptors/tool.js.map +1 -0
  165. package/dist/interceptors/workflow-ai.d.ts +5 -0
  166. package/dist/interceptors/workflow-ai.d.ts.map +1 -0
  167. package/dist/interceptors/workflow-ai.js +382 -0
  168. package/dist/interceptors/workflow-ai.js.map +1 -0
  169. package/dist/internals/conditional-recorder.d.ts +21 -0
  170. package/dist/internals/conditional-recorder.d.ts.map +1 -0
  171. package/dist/internals/conditional-recorder.js +54 -0
  172. package/dist/internals/conditional-recorder.js.map +1 -0
  173. package/dist/internals/mock-resolver.d.ts +146 -0
  174. package/dist/internals/mock-resolver.d.ts.map +1 -0
  175. package/dist/internals/mock-resolver.js +427 -0
  176. package/dist/internals/mock-resolver.js.map +1 -0
  177. package/dist/matchers/index.d.ts +96 -0
  178. package/dist/matchers/index.d.ts.map +1 -0
  179. package/dist/matchers/index.js +668 -0
  180. package/dist/matchers/index.js.map +1 -0
  181. package/dist/observability.d.ts +82 -0
  182. package/dist/observability.d.ts.map +1 -0
  183. package/dist/observability.js +471 -0
  184. package/dist/observability.js.map +1 -0
  185. package/dist/portal-executor.d.ts +30 -0
  186. package/dist/portal-executor.d.ts.map +1 -0
  187. package/dist/portal-executor.js +324 -0
  188. package/dist/portal-executor.js.map +1 -0
  189. package/dist/portal-server.d.ts +3 -0
  190. package/dist/portal-server.d.ts.map +1 -0
  191. package/dist/portal-server.js +279 -0
  192. package/dist/portal-server.js.map +1 -0
  193. package/dist/proxy/llm-capture.d.ts +14 -0
  194. package/dist/proxy/llm-capture.d.ts.map +1 -0
  195. package/dist/proxy/llm-capture.js +264 -0
  196. package/dist/proxy/llm-capture.js.map +1 -0
  197. package/dist/reporter.d.ts +3 -0
  198. package/dist/reporter.d.ts.map +1 -0
  199. package/dist/reporter.js +72 -0
  200. package/dist/reporter.js.map +1 -0
  201. package/dist/runWorkflowSubprocess.d.ts +14 -0
  202. package/dist/runWorkflowSubprocess.d.ts.map +1 -0
  203. package/dist/runWorkflowSubprocess.js +66 -0
  204. package/dist/runWorkflowSubprocess.js.map +1 -0
  205. package/dist/runner.d.ts +16 -0
  206. package/dist/runner.d.ts.map +1 -0
  207. package/dist/runner.js +138 -0
  208. package/dist/runner.js.map +1 -0
  209. package/dist/socket-connector.d.ts +22 -0
  210. package/dist/socket-connector.d.ts.map +1 -0
  211. package/dist/socket-connector.js +104 -0
  212. package/dist/socket-connector.js.map +1 -0
  213. package/dist/telemetry-batcher.d.ts +56 -0
  214. package/dist/telemetry-batcher.d.ts.map +1 -0
  215. package/dist/telemetry-batcher.js +143 -0
  216. package/dist/telemetry-batcher.js.map +1 -0
  217. package/dist/test-setup.d.ts +12 -0
  218. package/dist/test-setup.d.ts.map +1 -0
  219. package/dist/test-setup.js +13 -0
  220. package/dist/test-setup.js.map +1 -0
  221. package/dist/tool-registry.d.ts +31 -0
  222. package/dist/tool-registry.d.ts.map +1 -0
  223. package/dist/tool-registry.js +73 -0
  224. package/dist/tool-registry.js.map +1 -0
  225. package/dist/tool-runner-worker.d.ts +2 -0
  226. package/dist/tool-runner-worker.d.ts.map +1 -0
  227. package/dist/tool-runner-worker.js +215 -0
  228. package/dist/tool-runner-worker.js.map +1 -0
  229. package/dist/trace-adapter/context.d.ts +72 -0
  230. package/dist/trace-adapter/context.d.ts.map +1 -0
  231. package/dist/trace-adapter/context.js +80 -0
  232. package/dist/trace-adapter/context.js.map +1 -0
  233. package/dist/tracing.d.ts +2 -0
  234. package/dist/tracing.d.ts.map +1 -0
  235. package/dist/tracing.js +59 -0
  236. package/dist/tracing.js.map +1 -0
  237. package/dist/trigger-executor.d.ts +12 -0
  238. package/dist/trigger-executor.d.ts.map +1 -0
  239. package/dist/trigger-executor.js +130 -0
  240. package/dist/trigger-executor.js.map +1 -0
  241. package/dist/types/portal.d.ts +76 -0
  242. package/dist/types/portal.d.ts.map +1 -0
  243. package/dist/types/portal.js +2 -0
  244. package/dist/types/portal.js.map +1 -0
  245. package/dist/utils/debug.d.ts +3 -0
  246. package/dist/utils/debug.d.ts.map +1 -0
  247. package/dist/utils/debug.js +8 -0
  248. package/dist/utils/debug.js.map +1 -0
  249. package/dist/utils/license-error.d.ts +23 -0
  250. package/dist/utils/license-error.d.ts.map +1 -0
  251. package/dist/utils/license-error.js +42 -0
  252. package/dist/utils/license-error.js.map +1 -0
  253. package/dist/utils/redact.d.ts +7 -0
  254. package/dist/utils/redact.d.ts.map +1 -0
  255. package/dist/utils/redact.js +26 -0
  256. package/dist/utils/redact.js.map +1 -0
  257. package/dist/workflow-runner-worker.d.ts +2 -0
  258. package/dist/workflow-runner-worker.d.ts.map +1 -0
  259. package/dist/workflow-runner-worker.js +329 -0
  260. package/dist/workflow-runner-worker.js.map +1 -0
  261. package/dist/workflow-runner.d.ts +14 -0
  262. package/dist/workflow-runner.d.ts.map +1 -0
  263. package/dist/workflow-runner.js +34 -0
  264. package/dist/workflow-runner.js.map +1 -0
  265. package/docs/agent-coding-instructions.md +138 -0
  266. package/docs/agent-integration-guide.md +564 -0
  267. package/docs/agents.md +140 -0
  268. package/docs/dashboard.md +394 -0
  269. package/docs/deno.md +69 -0
  270. package/docs/instrumentation.md +424 -0
  271. package/docs/langfuse-trace-structure.md +145 -0
  272. package/docs/matchers.md +173 -0
  273. package/docs/observability_contract.md +192 -0
  274. package/docs/observability_mode.md +195 -0
  275. package/docs/quickstart.md +621 -0
  276. package/docs/security-compliance.md +566 -0
  277. package/docs/test-writing-guidelines.md +444 -0
  278. package/docs/tools.md +165 -0
  279. package/docs/workflow-modes.md +253 -0
  280. package/package.json +76 -0
  281. package/src/browser-ui.ts +281 -0
  282. package/src/capture/event.ts +30 -0
  283. package/src/capture/index.ts +3 -0
  284. package/src/capture/recorder.ts +62 -0
  285. package/src/capture/replay.ts +55 -0
  286. package/src/ci/api-client.ts +136 -0
  287. package/src/ci/benchmark.ts +257 -0
  288. package/src/ci/ed-runner.ts +351 -0
  289. package/src/ci/executor.ts +671 -0
  290. package/src/ci/git-info.ts +127 -0
  291. package/src/ci/index.ts +5 -0
  292. package/src/ci/measurement.ts +25 -0
  293. package/src/ci/replay.ts +127 -0
  294. package/src/ci/reporters/default.ts +50 -0
  295. package/src/ci/reporters/index.ts +21 -0
  296. package/src/ci/reporters/json.ts +18 -0
  297. package/src/ci/reporters/junit.ts +61 -0
  298. package/src/ci/runner.ts +208 -0
  299. package/src/ci/test-discovery.ts +16 -0
  300. package/src/ci/test-loader.ts +187 -0
  301. package/src/ci/test-registry.ts +62 -0
  302. package/src/ci/trace-schema.ts +96 -0
  303. package/src/ci/trace-writer.ts +107 -0
  304. package/src/ci/types.ts +115 -0
  305. package/src/ci/upload-client.ts +300 -0
  306. package/src/cli.ts +811 -0
  307. package/src/core/agent-state.ts +162 -0
  308. package/src/core/judge-utils.ts +232 -0
  309. package/src/core/registry.ts +92 -0
  310. package/src/dashboard-server.ts +2047 -0
  311. package/src/execution/tool-runner.ts +352 -0
  312. package/src/html/dashboard.html +2218 -0
  313. package/src/http.ts +13 -0
  314. package/src/index.ts +138 -0
  315. package/src/interceptors/ai-interceptor.ts +798 -0
  316. package/src/interceptors/db-auto.ts +243 -0
  317. package/src/interceptors/db.ts +156 -0
  318. package/src/interceptors/http.ts +393 -0
  319. package/src/interceptors/side-effects.ts +83 -0
  320. package/src/interceptors/telemetry-push.ts +537 -0
  321. package/src/interceptors/tool.ts +287 -0
  322. package/src/interceptors/workflow-ai.ts +419 -0
  323. package/src/internals/conditional-recorder.ts +63 -0
  324. package/src/internals/mock-resolver.ts +492 -0
  325. package/src/matchers/index.ts +824 -0
  326. package/src/observability.ts +501 -0
  327. package/src/portal-executor.ts +355 -0
  328. package/src/portal-server.ts +304 -0
  329. package/src/proxy/llm-capture.ts +301 -0
  330. package/src/reporter.ts +81 -0
  331. package/src/runWorkflowSubprocess.ts +74 -0
  332. package/src/runner.ts +178 -0
  333. package/src/socket-connector.ts +117 -0
  334. package/src/telemetry-batcher.ts +191 -0
  335. package/src/test-setup.ts +16 -0
  336. package/src/tool-registry.ts +94 -0
  337. package/src/tool-runner-worker.ts +244 -0
  338. package/src/trace-adapter/context.ts +156 -0
  339. package/src/tracing.ts +62 -0
  340. package/src/trigger-executor.ts +171 -0
  341. package/src/types/agent.d.ts +63 -0
  342. package/src/types/expect.d.ts +81 -0
  343. package/src/types/modules.d.ts +2 -0
  344. package/src/types/portal.ts +69 -0
  345. package/src/utils/debug.ts +8 -0
  346. package/src/utils/license-error.ts +43 -0
  347. package/src/utils/redact.ts +25 -0
  348. package/src/workflow-runner-worker.ts +386 -0
  349. package/src/workflow-runner.ts +58 -0
@@ -0,0 +1,798 @@
1
+ import { getCurrentTrace } from '../trace-adapter/context.js'
2
+ import { getCaptureContext } from '../capture/recorder.js'
3
+ import { rawDateNow } from './side-effects.js'
4
+ import { getObservabilityContext, getHttpRunContext, getHttpFrozenEvent, pushTelemetryEvent } from './telemetry-push.js'
5
+ import type { WorkflowEvent } from '../capture/event.js'
6
+
7
+ /**
8
+ * Check the global flag set by wrapAI to avoid double-recording.
9
+ * Reads the global directly instead of importing from workflow-ai.ts
10
+ * to avoid circular dependency (telemetry-push → ai-interceptor → workflow-ai → telemetry-push).
11
+ */
12
+ const AI_WRAPPER_KEY = '__elasticdash_ai_wrapper_depth__'
13
+ function isAIWrapperActive(): boolean {
14
+ return ((globalThis as Record<string, unknown>)[AI_WRAPPER_KEY] as number ?? 0) > 0
15
+ }
16
+
17
+ /**
18
+ * When inside a wrapAI call, the ai-interceptor captures the actual HTTP
19
+ * request payload and stashes it here so wrapAI can attach it to its event.
20
+ */
21
+ const LLM_REQUEST_KEY = '__elasticdash_last_llm_request__'
22
+ export interface CapturedLLMRequest {
23
+ url: string
24
+ provider: string
25
+ model: string
26
+ messages?: unknown[]
27
+ body: Record<string, unknown>
28
+ promptSnippet?: string
29
+ usage?: UsageInfo
30
+ }
31
+
32
+ export function consumeCapturedLLMRequest(): CapturedLLMRequest | undefined {
33
+ const g = globalThis as Record<string, unknown>
34
+ const req = g[LLM_REQUEST_KEY] as CapturedLLMRequest | undefined
35
+ if (req) g[LLM_REQUEST_KEY] = undefined
36
+ return req
37
+ }
38
+
39
+ function extractPromptSnippet(body: Record<string, unknown>): string | undefined {
40
+ let messages: unknown[] | undefined
41
+ if (Array.isArray(body.messages)) messages = body.messages
42
+ else if (Array.isArray(body.contents)) messages = body.contents
43
+
44
+ if (!messages || messages.length === 0) return undefined
45
+
46
+ // Find the last user message
47
+ for (let i = messages.length - 1; i >= 0; i--) {
48
+ const msg = messages[i] as Record<string, unknown> | undefined
49
+ if (!msg) continue
50
+ if (msg.role === 'user') {
51
+ let content = msg.content
52
+ if (Array.isArray(content)) {
53
+ content = content
54
+ .map((b: unknown) => (b && typeof b === 'object' ? String((b as Record<string, unknown>).text ?? '') : String(b)))
55
+ .filter(Boolean)
56
+ .join('')
57
+ }
58
+ if (typeof content === 'string') {
59
+ return content.slice(0, 100)
60
+ }
61
+ }
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ type UsageInfo = { inputTokens?: number; outputTokens?: number; totalTokens?: number }
67
+
68
+ function extractUsage(provider: string, body: Record<string, unknown>): UsageInfo | undefined {
69
+ if (provider === 'openai' || provider === 'grok' || provider === 'kimi') {
70
+ const u = body.usage as Record<string, number> | undefined
71
+ if (!u) return undefined
72
+ return { inputTokens: u.prompt_tokens, outputTokens: u.completion_tokens, totalTokens: u.total_tokens }
73
+ }
74
+ if (provider === 'anthropic') {
75
+ const u = body.usage as Record<string, number> | undefined
76
+ if (!u) return undefined
77
+ return { inputTokens: u.input_tokens, outputTokens: u.output_tokens, totalTokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0) }
78
+ }
79
+ if (provider === 'gemini') {
80
+ const u = body.usageMetadata as Record<string, number> | undefined
81
+ if (!u) return undefined
82
+ return { inputTokens: u.promptTokenCount, outputTokens: u.candidatesTokenCount, totalTokens: u.totalTokenCount }
83
+ }
84
+ return undefined
85
+ }
86
+
87
+ function extractAssistantMessage(provider: string, body: Record<string, unknown>): Record<string, unknown> | null {
88
+ if (provider === 'openai' || provider === 'grok' || provider === 'kimi') {
89
+ const choices = body.choices
90
+ if (Array.isArray(choices) && choices.length > 0) {
91
+ const msg = (choices[0] as Record<string, unknown>).message
92
+ if (msg && typeof msg === 'object') return msg as Record<string, unknown>
93
+ }
94
+ }
95
+ if (provider === 'anthropic') {
96
+ const content = body.content
97
+ if (Array.isArray(content)) {
98
+ return { role: 'assistant', content }
99
+ }
100
+ }
101
+ if (provider === 'gemini') {
102
+ const candidates = body.candidates
103
+ if (Array.isArray(candidates) && candidates.length > 0) {
104
+ const content = (candidates[0] as Record<string, unknown>).content
105
+ if (content && typeof content === 'object') return content as Record<string, unknown>
106
+ }
107
+ }
108
+ return null
109
+ }
110
+
111
+ /** URL patterns for known AI providers */
112
+ const AI_PATTERNS: Record<string, RegExp> = {
113
+ openai: /https?:\/\/api\.openai\.com\/v1\/((chat\/)?completions|embeddings)/,
114
+ anthropic: /https?:\/\/api\.anthropic\.com\/v1\/messages/,
115
+ gemini: /https?:\/\/generativelanguage\.googleapis\.com\/.*\/models\/[^\/:]+:(generateContent|streamGenerateContent)/,
116
+ grok: /https?:\/\/api\.x\.ai\/v1\/(chat\/)?completions/,
117
+ kimi: /https?:\/\/api\.moonshot\.ai\/v1\/(chat\/)?completions/,
118
+ }
119
+
120
+ /** Detect which provider (if any) a URL belongs to */
121
+ function detectProvider(url: string): string | null {
122
+ for (const [provider, pattern] of Object.entries(AI_PATTERNS)) {
123
+ if (pattern.test(url)) return provider
124
+ }
125
+ return null
126
+ }
127
+
128
+ /** Extract model name from request body or URL (for Gemini) */
129
+ function extractModel(provider: string, body: Record<string, unknown>, url: string): string {
130
+ if (provider === 'gemini') {
131
+ // URL shape: .../models/gemini-1.5-pro:generateContent
132
+ const match = /\/models\/([^/:]+):/.exec(url)
133
+ return match ? match[1] : 'unknown'
134
+ }
135
+ return typeof body.model === 'string' ? body.model : 'unknown'
136
+ }
137
+
138
+ /** Extract prompt text from request body */
139
+ function extractPrompt(provider: string, body: Record<string, unknown>): string {
140
+ if (provider === 'openai' || provider === 'anthropic' || provider === 'grok' || provider === 'kimi') {
141
+ let systemPrefix = ''
142
+ // Anthropic supports a top-level `system` parameter
143
+ if (provider === 'anthropic') {
144
+ if (typeof body.system === 'string') {
145
+ systemPrefix = `system: ${body.system}\n`
146
+ } else if (Array.isArray(body.system)) {
147
+ systemPrefix = body.system
148
+ .map((b: unknown) => {
149
+ if (b && typeof b === 'object') {
150
+ return String((b as Record<string, unknown>).text ?? '')
151
+ }
152
+ return String(b)
153
+ })
154
+ .filter(Boolean)
155
+ .map((t) => `system: ${t}`)
156
+ .join('\n') + '\n'
157
+ }
158
+ }
159
+ const messages = body.messages
160
+ if (Array.isArray(messages)) {
161
+ const msgText = messages
162
+ .map((m: unknown) => {
163
+ if (m && typeof m === 'object') {
164
+ const msg = m as Record<string, unknown>
165
+ // Anthropic content can be a string or an array of content blocks
166
+ let content = msg.content
167
+ if (Array.isArray(content)) {
168
+ content = content
169
+ .map((b: unknown) => {
170
+ if (b && typeof b === 'object') {
171
+ return String((b as Record<string, unknown>).text ?? '')
172
+ }
173
+ return String(b)
174
+ })
175
+ .filter(Boolean)
176
+ .join('')
177
+ }
178
+ return `${msg.role}: ${content}`
179
+ }
180
+ return String(m)
181
+ })
182
+ .join('\n')
183
+ return systemPrefix + msgText
184
+ }
185
+ // Legacy completions API (OpenAI)
186
+ if (typeof body.prompt === 'string') return body.prompt
187
+ if (typeof body.input === 'string') return body.input
188
+ if (Array.isArray(body.input)) return body.input.map((v) => String(v)).join('\n')
189
+ return ''
190
+ }
191
+
192
+ if (provider === 'gemini') {
193
+ const contents = body.contents
194
+ if (Array.isArray(contents)) {
195
+ return contents
196
+ .flatMap((c: unknown) => {
197
+ if (c && typeof c === 'object') {
198
+ const parts = (c as Record<string, unknown>).parts
199
+ if (Array.isArray(parts)) {
200
+ return parts.map((p: unknown) => {
201
+ if (p && typeof p === 'object') {
202
+ return String((p as Record<string, unknown>).text ?? '')
203
+ }
204
+ return ''
205
+ })
206
+ }
207
+ }
208
+ return []
209
+ })
210
+ .join('\n')
211
+ }
212
+ }
213
+
214
+ return ''
215
+ }
216
+
217
+ /** Extract completion text from response body */
218
+ function extractCompletion(provider: string, responseBody: Record<string, unknown>): string {
219
+ // Handle buffered streaming format
220
+ if (responseBody.streamed === true && typeof responseBody.completion === 'string') {
221
+ return responseBody.completion
222
+ }
223
+
224
+ if (provider === 'openai' || provider === 'grok' || provider === 'kimi') {
225
+ const choices = responseBody.choices
226
+ if (Array.isArray(choices) && choices.length > 0) {
227
+ const first = choices[0] as Record<string, unknown>
228
+ if (first.message && typeof first.message === 'object') {
229
+ return String((first.message as Record<string, unknown>).content ?? '')
230
+ }
231
+ if (typeof first.text === 'string') return first.text
232
+ }
233
+ // Embedding response: data[].embedding
234
+ const data = responseBody.data
235
+ if (Array.isArray(data) && data.length > 0) {
236
+ const first = data[0] as Record<string, unknown>
237
+ if (Array.isArray(first?.embedding)) {
238
+ return `[${data.length} embedding(s), ${first.embedding.length} dimensions]`
239
+ }
240
+ }
241
+ }
242
+
243
+ if (provider === 'anthropic') {
244
+ const content = responseBody.content
245
+ if (Array.isArray(content)) {
246
+ return content
247
+ .map((block: unknown) => {
248
+ if (block && typeof block === 'object') {
249
+ const b = block as Record<string, unknown>
250
+ if (b.type === 'text' && typeof b.text === 'string') return b.text
251
+ }
252
+ return ''
253
+ })
254
+ .filter(Boolean)
255
+ .join('')
256
+ }
257
+ }
258
+
259
+ if (provider === 'gemini') {
260
+ const candidates = responseBody.candidates
261
+ if (Array.isArray(candidates) && candidates.length > 0) {
262
+ const first = candidates[0] as Record<string, unknown>
263
+ if (first.content && typeof first.content === 'object') {
264
+ const parts = (first.content as Record<string, unknown>).parts
265
+ if (Array.isArray(parts) && parts.length > 0) {
266
+ return String((parts[0] as Record<string, unknown>).text ?? '')
267
+ }
268
+ }
269
+ }
270
+ // Gemini embedding response: embeddings[].values
271
+ const embeddings = responseBody.embeddings
272
+ if (Array.isArray(embeddings) && embeddings.length > 0) {
273
+ const first = embeddings[0] as Record<string, unknown>
274
+ if (Array.isArray(first?.values)) {
275
+ return `[${embeddings.length} embedding(s), ${first.values.length} dimensions]`
276
+ }
277
+ }
278
+ }
279
+
280
+ return ''
281
+ }
282
+
283
+ /** Buffer a streaming SSE/NDJSON response to extract the completion text */
284
+ async function bufferSSEStream(
285
+ provider: string,
286
+ stream: ReadableStream<Uint8Array>,
287
+ ): Promise<string> {
288
+ const decoder = new TextDecoder()
289
+ const reader = stream.getReader()
290
+ let raw = ''
291
+
292
+ try {
293
+ for (;;) {
294
+ const { done, value } = await reader.read()
295
+ if (done) break
296
+ raw += decoder.decode(value, { stream: true })
297
+ }
298
+ } finally {
299
+ reader.releaseLock()
300
+ }
301
+
302
+ const lines = raw.split('\n')
303
+ let completion = ''
304
+
305
+ if (provider === 'gemini') {
306
+ // NDJSON: lines may be wrapped in `[` / `]` / `,`
307
+ for (const line of lines) {
308
+ const trimmed = line.trim().replace(/^[,\[]/, '').replace(/[,\]]$/, '')
309
+ if (!trimmed) continue
310
+ try {
311
+ const obj = JSON.parse(trimmed) as Record<string, unknown>
312
+ const candidates = obj.candidates
313
+ if (Array.isArray(candidates) && candidates.length > 0) {
314
+ const first = candidates[0] as Record<string, unknown>
315
+ if (first.content && typeof first.content === 'object') {
316
+ const parts = (first.content as Record<string, unknown>).parts
317
+ if (Array.isArray(parts) && parts.length > 0) {
318
+ completion += String((parts[0] as Record<string, unknown>).text ?? '')
319
+ }
320
+ }
321
+ }
322
+ } catch {
323
+ // skip unparseable lines
324
+ }
325
+ }
326
+ } else if (provider === 'anthropic') {
327
+ // Anthropic SSE format: event: <type>\ndata: <json>
328
+ for (const line of lines) {
329
+ if (!line.startsWith('data: ')) continue
330
+ const data = line.slice(6).trim()
331
+ try {
332
+ const obj = JSON.parse(data) as Record<string, unknown>
333
+ if (obj.type === 'content_block_delta') {
334
+ const delta = obj.delta as Record<string, unknown> | undefined
335
+ if (delta && delta.type === 'text_delta' && typeof delta.text === 'string') {
336
+ completion += delta.text
337
+ }
338
+ }
339
+ } catch {
340
+ // skip unparseable lines
341
+ }
342
+ }
343
+ } else {
344
+ // OpenAI / Grok / Kimi SSE format
345
+ for (const line of lines) {
346
+ if (!line.startsWith('data: ')) continue
347
+ const data = line.slice(6).trim()
348
+ if (data === '[DONE]') continue
349
+ try {
350
+ const obj = JSON.parse(data) as Record<string, unknown>
351
+ const choices = obj.choices
352
+ if (Array.isArray(choices) && choices.length > 0) {
353
+ const first = choices[0] as Record<string, unknown>
354
+ if (first.delta && typeof first.delta === 'object') {
355
+ completion += String((first.delta as Record<string, unknown>).content ?? '')
356
+ }
357
+ }
358
+ } catch {
359
+ // skip unparseable lines
360
+ }
361
+ }
362
+ }
363
+
364
+ return completion
365
+ }
366
+
367
+ /** Extract usage from buffered raw SSE text */
368
+ function extractStreamUsage(provider: string, rawSSE: string): UsageInfo | undefined {
369
+ const lines = rawSSE.split('\n')
370
+ // Walk backwards to find usage in the final events
371
+ for (let i = lines.length - 1; i >= 0; i--) {
372
+ const line = lines[i]
373
+ if (!line.startsWith('data: ')) continue
374
+ const data = line.slice(6).trim()
375
+ if (data === '[DONE]') continue
376
+ try {
377
+ const obj = JSON.parse(data) as Record<string, unknown>
378
+ // OpenAI / Grok / Kimi: usage in the final chunk
379
+ const usage = extractUsage(provider, obj)
380
+ if (usage) return usage
381
+ // Anthropic: usage in message_delta event
382
+ if (obj.type === 'message_delta') {
383
+ const u = (obj as Record<string, unknown>).usage as Record<string, number> | undefined
384
+ if (u) return extractUsage('anthropic', { usage: u })
385
+ }
386
+ // Anthropic: usage in message_start event
387
+ if (obj.type === 'message_start') {
388
+ const msg = (obj as Record<string, unknown>).message as Record<string, unknown> | undefined
389
+ if (msg?.usage) return extractUsage('anthropic', { usage: msg.usage })
390
+ }
391
+ } catch { /* skip */ }
392
+ }
393
+ return undefined
394
+ }
395
+
396
+ /** Build a minimal non-streaming JSON response body from a completion string (for replay) */
397
+ function synthesizeCompletionJSON(
398
+ provider: string,
399
+ completion: string,
400
+ ): Record<string, unknown> {
401
+ if (provider === 'gemini') {
402
+ return {
403
+ candidates: [{ content: { parts: [{ text: completion }], role: 'model' }, finishReason: 'STOP' }],
404
+ }
405
+ }
406
+ if (provider === 'anthropic') {
407
+ return {
408
+ id: 'replay',
409
+ type: 'message',
410
+ role: 'assistant',
411
+ content: [{ type: 'text', text: completion }],
412
+ stop_reason: 'end_turn',
413
+ stop_sequence: null,
414
+ }
415
+ }
416
+ // OpenAI / Grok / Kimi format
417
+ return {
418
+ id: 'replay',
419
+ object: 'chat.completion',
420
+ choices: [{ index: 0, message: { role: 'assistant', content: completion }, finish_reason: 'stop' }],
421
+ }
422
+ }
423
+
424
+ /** Build a minimal SSE/NDJSON ReadableStream from a completion string (for replay) */
425
+ function synthesizeSSEStream(
426
+ provider: string,
427
+ completion: string,
428
+ ): ReadableStream<Uint8Array> {
429
+ const encoder = new TextEncoder()
430
+
431
+ return new ReadableStream<Uint8Array>({
432
+ start(ctrl) {
433
+ if (provider === 'gemini') {
434
+ const chunk = `[{"candidates":[{"content":{"parts":[{"text":${JSON.stringify(completion)}}],"role":"model"},"finishReason":"STOP"}]}]\n`
435
+ ctrl.enqueue(encoder.encode(chunk)) } else if (provider === 'anthropic') {
436
+ const msgStart = `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: { id: 'replay', type: 'message', role: 'assistant', content: [], stop_reason: null, stop_sequence: null } })}\n\n`
437
+ const blockStart = `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`
438
+ const delta = `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: completion } })}\n\n`
439
+ const blockStop = `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`
440
+ const msgDelta = `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null } })}\n\n`
441
+ const msgStop = `event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`
442
+ ctrl.enqueue(encoder.encode(msgStart))
443
+ ctrl.enqueue(encoder.encode(blockStart))
444
+ ctrl.enqueue(encoder.encode(delta))
445
+ ctrl.enqueue(encoder.encode(blockStop))
446
+ ctrl.enqueue(encoder.encode(msgDelta))
447
+ ctrl.enqueue(encoder.encode(msgStop)) } else {
448
+ const frame1 = `data: ${JSON.stringify({ id: 'replay', choices: [{ delta: { content: completion }, index: 0, finish_reason: null }] })}\n\n`
449
+ const frame2 = 'data: [DONE]\n\n'
450
+ ctrl.enqueue(encoder.encode(frame1))
451
+ ctrl.enqueue(encoder.encode(frame2))
452
+ }
453
+ ctrl.close()
454
+ },
455
+ })
456
+ }
457
+
458
+ // Keep a reference to the original fetch so we can restore it
459
+ let originalFetch: typeof globalThis.fetch | null = null
460
+
461
+ /**
462
+ * Install the AI fetch interceptor. Wraps globalThis.fetch to automatically
463
+ * record LLM steps into the active trace for OpenAI, Gemini, and Grok calls.
464
+ */
465
+ export function installAIInterceptor(): void {
466
+ if (originalFetch) return // already installed
467
+
468
+ originalFetch = globalThis.fetch
469
+
470
+ globalThis.fetch = async function patchedFetch(
471
+ input: Parameters<typeof globalThis.fetch>[0],
472
+ init?: Parameters<typeof globalThis.fetch>[1],
473
+ ): Promise<Response> {
474
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as { url: string }).url
475
+
476
+ const provider = detectProvider(url)
477
+
478
+ // Skip recording when inside a wrapAI call to avoid duplicate events,
479
+ // but capture the actual HTTP request body and response usage so wrapAI
480
+ // can attach them to its event.
481
+ if (provider && isAIWrapperActive()) {
482
+ let capturedReq: Record<string, unknown> | undefined
483
+ let capturedModel = 'unknown'
484
+ let capturedMessages: unknown[] | undefined
485
+ let capturedSnippet: string | undefined
486
+ try {
487
+ const rawBody = init?.body
488
+ if (rawBody && typeof rawBody === 'string') {
489
+ capturedReq = JSON.parse(rawBody) as Record<string, unknown>
490
+ capturedModel = extractModel(provider, capturedReq, url)
491
+ capturedMessages = Array.isArray(capturedReq.messages) ? capturedReq.messages : Array.isArray(capturedReq.contents) ? capturedReq.contents : undefined
492
+ capturedSnippet = extractPromptSnippet(capturedReq)
493
+ }
494
+ } catch {
495
+ // Ignore parse errors
496
+ }
497
+
498
+ const response = await originalFetch!(input, init)
499
+
500
+ // Extract usage from the response (clone to avoid consuming the body)
501
+ if (capturedReq) {
502
+ const captured: CapturedLLMRequest = {
503
+ url, provider, model: capturedModel, messages: capturedMessages,
504
+ body: capturedReq, promptSnippet: capturedSnippet,
505
+ }
506
+ const isStreaming = capturedReq.stream === true
507
+ try {
508
+ const cloned = response.clone()
509
+ if (!isStreaming) {
510
+ // Non-streaming: parse JSON response for usage
511
+ const responseBody = await cloned.json() as Record<string, unknown>
512
+ captured.usage = extractUsage(provider, responseBody)
513
+ } else if (cloned.body) {
514
+ // Streaming: read the raw SSE text to extract usage from final events
515
+ try {
516
+ const decoder = new TextDecoder()
517
+ const reader = cloned.body.getReader()
518
+ let rawSSE = ''
519
+ for (;;) {
520
+ const { done, value } = await reader.read()
521
+ if (done) break
522
+ rawSSE += decoder.decode(value, { stream: true })
523
+ }
524
+ reader.releaseLock()
525
+ captured.usage = extractStreamUsage(provider, rawSSE)
526
+ } catch { /* stream read failed */ }
527
+ }
528
+ } catch {
529
+ // Response body not available — usage won't be captured
530
+ }
531
+ ;(globalThis as Record<string, unknown>)[LLM_REQUEST_KEY] = captured
532
+ }
533
+
534
+ return response
535
+ }
536
+
537
+ const traceAtCall = getCurrentTrace()
538
+ const obsCtx = getObservabilityContext()
539
+
540
+ const httpCtx = getHttpRunContext()
541
+
542
+ // No match or no active context: pass through unchanged
543
+ if (!provider || (!traceAtCall && !obsCtx && !httpCtx)) {
544
+ return originalFetch!(input, init)
545
+ }
546
+
547
+ // Parse request body to extract model and prompt
548
+ let model = 'unknown'
549
+ let prompt = ''
550
+ let isStreaming = false
551
+ let messages: unknown[] | undefined
552
+
553
+ try {
554
+ const rawBody = init?.body
555
+ if (rawBody && typeof rawBody === 'string') {
556
+ const body = JSON.parse(rawBody) as Record<string, unknown>
557
+ model = extractModel(provider, body, url)
558
+ prompt = extractPrompt(provider, body)
559
+ isStreaming = body.stream === true
560
+ // Capture full messages array for rich display in the dashboard
561
+ if (Array.isArray(body.messages)) messages = body.messages
562
+ else if (Array.isArray(body.contents)) messages = body.contents // Gemini
563
+ }
564
+ } catch {
565
+ // Ignore parse errors — still pass through
566
+ }
567
+
568
+ const ctx = getCaptureContext()
569
+
570
+ // Observability-only mode: no trace handle, no capture context — record via pushTelemetryEvent
571
+ // Skip when inside a wrapAI call to avoid duplicate events (wrapAI records its own)
572
+ if (!traceAtCall && !ctx && obsCtx && !isAIWrapperActive()) {
573
+ const id = obsCtx.nextId()
574
+ const start = rawDateNow()
575
+ const eventInput = { url, provider, model, prompt, messages }
576
+
577
+ const response = await originalFetch!(input, init)
578
+
579
+ if (isStreaming && response.body) {
580
+ const [streamForCaller, streamForRecorder] = response.body.tee()
581
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
582
+ const durationMs = rawDateNow() - start
583
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { streamed: true, completion }, timestamp: start, durationMs })
584
+ }).catch(() => {
585
+ const durationMs = rawDateNow() - start
586
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
587
+ })
588
+ return new Response(streamForCaller, { status: response.status, statusText: response.statusText, headers: response.headers })
589
+ }
590
+
591
+ try {
592
+ const cloned = response.clone()
593
+ const responseBody = await cloned.json() as Record<string, unknown>
594
+ const completion = extractCompletion(provider, responseBody)
595
+ const usage = extractUsage(provider, responseBody)
596
+ const durationMs = rawDateNow() - start
597
+ const event: WorkflowEvent = {
598
+ id, type: 'ai', name: model, input: eventInput, output: { completion },
599
+ timestamp: start, durationMs,
600
+ ...(usage ? { usage } : {}),
601
+ }
602
+ pushTelemetryEvent(event)
603
+ } catch {
604
+ const durationMs = rawDateNow() - start
605
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, timestamp: start, durationMs })
606
+ }
607
+
608
+ return response
609
+ }
610
+
611
+ // HTTP mode (no capture context): replay frozen AI events or execute live + push telemetry
612
+ // Skip when inside a wrapAI call to avoid duplicate events (wrapAI records its own)
613
+ if (!ctx && httpCtx && !isAIWrapperActive()) {
614
+ const id = httpCtx.nextId()
615
+ const eventInput = { url, provider, model, prompt, messages }
616
+
617
+ // Replay frozen step
618
+ const frozen = getHttpFrozenEvent(id)
619
+ if (frozen && frozen.type === 'ai') {
620
+ pushTelemetryEvent(frozen)
621
+ const frozenOutput = frozen.output as Record<string, unknown> | null
622
+ const completion = frozenOutput ? extractCompletion(provider, frozenOutput) : '(replayed)'
623
+
624
+ if (isStreaming) {
625
+ return new Response(synthesizeSSEStream(provider, completion), {
626
+ status: 200,
627
+ headers: { 'Content-Type': provider === 'gemini' ? 'application/json' : 'text/event-stream' },
628
+ })
629
+ }
630
+
631
+ const body = frozenOutput?.streamed === true
632
+ ? synthesizeCompletionJSON(provider, completion)
633
+ : (frozenOutput ?? synthesizeCompletionJSON(provider, completion))
634
+ return new Response(JSON.stringify(body), {
635
+ status: 200,
636
+ headers: { 'Content-Type': 'application/json' },
637
+ })
638
+ }
639
+
640
+ // Not frozen → execute live, push telemetry
641
+ const start = rawDateNow()
642
+ const response = await originalFetch!(input, init)
643
+
644
+ if (isStreaming && response.body) {
645
+ const [streamForCaller, streamForRecorder] = response.body.tee()
646
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
647
+ const durationMs = rawDateNow() - start
648
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { streamed: true, completion }, timestamp: start, durationMs })
649
+ }).catch(() => {
650
+ const durationMs = rawDateNow() - start
651
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
652
+ })
653
+ return new Response(streamForCaller, { status: response.status, statusText: response.statusText, headers: response.headers })
654
+ }
655
+
656
+ try {
657
+ const cloned = response.clone()
658
+ const responseBody = await cloned.json() as Record<string, unknown>
659
+ const completion = extractCompletion(provider, responseBody)
660
+ const usage = extractUsage(provider, responseBody)
661
+ const durationMs = rawDateNow() - start
662
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: { completion }, timestamp: start, durationMs, ...(usage ? { usage } : {}) })
663
+ } catch {
664
+ const durationMs = rawDateNow() - start
665
+ pushTelemetryEvent({ id, type: 'ai', name: model, input: eventInput, output: null, timestamp: start, durationMs })
666
+ }
667
+
668
+ return response
669
+ }
670
+
671
+ if (ctx && traceAtCall) {
672
+ const { recorder, replay } = ctx
673
+ const id = recorder.nextId()
674
+ const start = rawDateNow()
675
+
676
+ // Replay mode: return the historical response without making a real call
677
+ if (replay.shouldReplay(id)) {
678
+ const historicalEvent = replay.getRecordedEvent(id)
679
+ const historicalInput = historicalEvent?.input as Record<string, unknown> | undefined
680
+ const historicalUrl = typeof historicalInput?.url === 'string' ? historicalInput.url : undefined
681
+ const historicalProvider = typeof historicalInput?.provider === 'string' ? historicalInput.provider : undefined
682
+ const isReplayMatch = !!historicalEvent
683
+ && historicalEvent.type === 'ai'
684
+ && historicalProvider === provider
685
+ && historicalUrl === url
686
+
687
+ if (isReplayMatch && historicalEvent) {
688
+ recorder.record(historicalEvent)
689
+ const historicalOutput = historicalEvent.output as Record<string, unknown> | null
690
+ const completion = historicalOutput ? extractCompletion(provider, historicalOutput) : '(replayed)'
691
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion, workflowEventId: id })
692
+
693
+ if (isStreaming) {
694
+ // Current caller expects a streaming response — always synthesize SSE
695
+ return new Response(synthesizeSSEStream(provider, completion), {
696
+ status: 200,
697
+ headers: { 'Content-Type': provider === 'gemini' ? 'application/json' : 'text/event-stream' },
698
+ })
699
+ }
700
+
701
+ if (historicalOutput?.streamed === true) {
702
+ // Original was streamed but caller now expects JSON — synthesize a completion response
703
+ return new Response(JSON.stringify(synthesizeCompletionJSON(provider, completion)), {
704
+ status: 200,
705
+ headers: { 'Content-Type': 'application/json' },
706
+ })
707
+ }
708
+
709
+ return new Response(
710
+ historicalOutput != null ? JSON.stringify(historicalOutput) : null,
711
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
712
+ )
713
+ }
714
+ // No historical event found — fall through to fresh execution
715
+ }
716
+
717
+ // Fresh execution: make the real call and record to both systems
718
+ const response = await originalFetch!(input, init)
719
+ const durationMs = rawDateNow() - start
720
+
721
+ if (isStreaming) {
722
+ if (response.body) {
723
+ const [streamForCaller, streamForRecorder] = response.body.tee()
724
+ recorder.trackAsync(
725
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
726
+ const durationMs = rawDateNow() - start
727
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion, workflowEventId: id, durationMs })
728
+ recorder.record({ id, type: 'ai', name: model, input: { url, provider, model, prompt, messages }, output: { streamed: true, completion }, timestamp: start, durationMs })
729
+ }).catch(() => {
730
+ const durationMs = rawDateNow() - start
731
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion: '(streamed-error)', workflowEventId: id, durationMs })
732
+ recorder.record({ id, type: 'ai', name: model, input: { url, provider, model, prompt, messages }, output: null, timestamp: start, durationMs })
733
+ })
734
+ )
735
+ return new Response(streamForCaller, {
736
+ status: response.status,
737
+ statusText: response.statusText,
738
+ headers: response.headers,
739
+ })
740
+ } else {
741
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion: '(streamed)', workflowEventId: id, durationMs })
742
+ recorder.record({ id, type: 'ai', name: model, input: { url, provider, model, prompt, messages }, output: null, timestamp: start, durationMs })
743
+ }
744
+ } else {
745
+ try {
746
+ const cloned = response.clone()
747
+ const responseBody = await cloned.json() as Record<string, unknown>
748
+ const completion = extractCompletion(provider, responseBody)
749
+ const usage = extractUsage(provider, responseBody)
750
+ const assistantMessage = extractAssistantMessage(provider, responseBody)
751
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion, workflowEventId: id, durationMs })
752
+ recorder.record({ id, type: 'ai', name: model, input: { url, provider, model, prompt, messages }, output: assistantMessage ?? responseBody, timestamp: start, durationMs, usage })
753
+ } catch {
754
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion: '', workflowEventId: id, durationMs })
755
+ recorder.record({ id, type: 'ai', name: model, input: { url, provider, model, prompt, messages }, output: null, timestamp: start, durationMs })
756
+ }
757
+ }
758
+
759
+ return response
760
+ }
761
+
762
+ // No capture context — original behaviour (trace handle only, outside of a workflow run)
763
+ if (!traceAtCall) return originalFetch!(input, init)
764
+
765
+ const response = await originalFetch!(input, init)
766
+
767
+ if (isStreaming && response.body) {
768
+ const [streamForCaller, streamForRecorder] = response.body.tee()
769
+ bufferSSEStream(provider, streamForRecorder).then((completion) => {
770
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion })
771
+ }).catch(() => {
772
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion: '(streamed-error)' })
773
+ })
774
+ return new Response(streamForCaller, { status: response.status, statusText: response.statusText, headers: response.headers })
775
+ } else if (!isStreaming) {
776
+ try {
777
+ const cloned = response.clone()
778
+ const responseBody = await cloned.json() as Record<string, unknown>
779
+ const completion = extractCompletion(provider, responseBody)
780
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion })
781
+ } catch {
782
+ traceAtCall.recordLLMStep({ model, provider, prompt, completion: '' })
783
+ }
784
+ }
785
+
786
+ return response
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Uninstall the AI fetch interceptor, restoring globalThis.fetch to its original value.
792
+ */
793
+ export function uninstallAIInterceptor(): void {
794
+ if (originalFetch) {
795
+ globalThis.fetch = originalFetch
796
+ originalFetch = null
797
+ }
798
+ }