elasticdash-test 0.1.18-alpha-21 → 0.1.18-alpha-23

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elasticdash-test",
3
- "version": "0.1.18-alpha-21",
3
+ "version": "0.1.18-alpha-23",
4
4
  "description": "AI-native test runner for ElasticDash workflow testing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,55 @@ function isAIWrapperActive(): boolean {
14
14
  return ((globalThis as Record<string, unknown>)[AI_WRAPPER_KEY] as number ?? 0) > 0
15
15
  }
16
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
+
17
66
  type UsageInfo = { inputTokens?: number; outputTokens?: number; totalTokens?: number }
18
67
 
19
68
  function extractUsage(provider: string, body: Record<string, unknown>): UsageInfo | undefined {
@@ -397,9 +446,45 @@ export function installAIInterceptor(): void {
397
446
 
398
447
  const provider = detectProvider(url)
399
448
 
400
- // Skip recording when inside a wrapAI call to avoid duplicate events
449
+ // Skip recording when inside a wrapAI call to avoid duplicate events,
450
+ // but capture the actual HTTP request body and response usage so wrapAI
451
+ // can attach them to its event.
401
452
  if (provider && isAIWrapperActive()) {
402
- return originalFetch!(input, init)
453
+ let capturedReq: Record<string, unknown> | undefined
454
+ let capturedModel = 'unknown'
455
+ let capturedMessages: unknown[] | undefined
456
+ let capturedSnippet: string | undefined
457
+ try {
458
+ const rawBody = init?.body
459
+ if (rawBody && typeof rawBody === 'string') {
460
+ capturedReq = JSON.parse(rawBody) as Record<string, unknown>
461
+ capturedModel = extractModel(provider, capturedReq, url)
462
+ capturedMessages = Array.isArray(capturedReq.messages) ? capturedReq.messages : Array.isArray(capturedReq.contents) ? capturedReq.contents : undefined
463
+ capturedSnippet = extractPromptSnippet(capturedReq)
464
+ }
465
+ } catch {
466
+ // Ignore parse errors
467
+ }
468
+
469
+ const response = await originalFetch!(input, init)
470
+
471
+ // Extract usage from the response (clone to avoid consuming the body)
472
+ if (capturedReq) {
473
+ const captured: CapturedLLMRequest = {
474
+ url, provider, model: capturedModel, messages: capturedMessages,
475
+ body: capturedReq, promptSnippet: capturedSnippet,
476
+ }
477
+ try {
478
+ const cloned = response.clone()
479
+ const responseBody = await cloned.json() as Record<string, unknown>
480
+ captured.usage = extractUsage(provider, responseBody)
481
+ } catch {
482
+ // Streaming or non-JSON response — usage not available from response
483
+ }
484
+ ;(globalThis as Record<string, unknown>)[LLM_REQUEST_KEY] = captured
485
+ }
486
+
487
+ return response
403
488
  }
404
489
 
405
490
  const traceAtCall = getCurrentTrace()
@@ -2,6 +2,7 @@ import { getCaptureContext } from '../capture/recorder.js'
2
2
  import { rawDateNow } from './side-effects.js'
3
3
  import { getHttpRunContext, getHttpFrozenEvent, getHttpPromptMock, getHttpUserPromptMock, getHttpAIMock, pushTelemetryEvent, tryAutoInitHttpContext, getObservabilityContext } from './telemetry-push.js'
4
4
  import { resolveAIMock, resolvePromptMock, resolveUserPromptMock } from '../internals/mock-resolver.js'
5
+ import { consumeCapturedLLMRequest } from './ai-interceptor.js'
5
6
  import type { WorkflowEvent } from '../capture/event.js'
6
7
 
7
8
  /**
@@ -57,6 +58,23 @@ function extractUsage(output: unknown): UsageInfo | undefined {
57
58
  return undefined
58
59
  }
59
60
 
61
+ /**
62
+ * After callFn returns, consume any captured LLM request from ai-interceptor
63
+ * and merge it into the event input so the recorded event contains the actual
64
+ * HTTP payload sent to the LLM (messages, model, parameters).
65
+ * Also returns captured usage when the app's output doesn't include it.
66
+ */
67
+ function enrichFromLLMCapture(input: unknown, appUsage: UsageInfo | undefined): { input: unknown; usage: UsageInfo | undefined } {
68
+ const captured = consumeCapturedLLMRequest()
69
+ if (!captured) return { input, usage: appUsage }
70
+ const enrichedInput = (input && typeof input === 'object')
71
+ ? { ...(input as Record<string, unknown>), llmRequest: captured.body, promptSnippet: captured.promptSnippet }
72
+ : { originalInput: input, llmRequest: captured.body, promptSnippet: captured.promptSnippet }
73
+ // Prefer app-level usage if available, fall back to HTTP-level usage
74
+ const usage = appUsage ?? captured.usage
75
+ return { input: enrichedInput, usage }
76
+ }
77
+
60
78
  function isReadableStream(v: unknown): v is ReadableStream<Uint8Array> {
61
79
  return (
62
80
  typeof v === 'object' &&
@@ -127,10 +145,8 @@ function wrapAsyncIterable<T>(
127
145
  export function wrapAI<Args extends unknown[], R>(
128
146
  modelName: string,
129
147
  callFn: (...args: Args) => Promise<R>,
130
- options?: { model?: string; provider?: string },
148
+ _options?: { model?: string; provider?: string },
131
149
  ): (...args: Args) => Promise<R> {
132
- const actualModel = options?.model
133
- const actualProvider = options?.provider
134
150
  return async (...args: Args): Promise<R> => {
135
151
  await tryAutoInitHttpContext()
136
152
  const ctx = getCaptureContext()
@@ -146,22 +162,19 @@ export function wrapAI<Args extends unknown[], R>(
146
162
  // Observability-only mode: record and push, no mocks/replay
147
163
  if (!ctx && !httpCtx && obsCtx) {
148
164
  const id = obsCtx.nextId()
149
- const rawInput = args.length === 1 ? args[0] : args
150
- // Attach actual model/provider so reruns use the correct API model name
151
- const input = (actualModel || actualProvider)
152
- ? { ...(rawInput && typeof rawInput === 'object' ? rawInput as Record<string, unknown> : { prompt: rawInput }), ...(actualModel ? { model: actualModel } : {}), ...(actualProvider ? { provider: actualProvider } : {}) }
153
- : rawInput
165
+ const input = args.length === 1 ? args[0] : args
154
166
  try {
155
167
  const output = await callFn(...args)
168
+ const enriched = enrichFromLLMCapture(input, extractUsage(output))
156
169
 
157
170
  if (isReadableStream(output)) {
158
171
  const [streamForCaller, streamForRecorder] = output.tee()
159
172
  bufferReadableStream(streamForRecorder).then((rawText) => {
160
173
  const durationMs = rawDateNow() - start
161
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
174
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
162
175
  }).catch(() => {
163
176
  const durationMs = rawDateNow() - start
164
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
177
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
165
178
  })
166
179
  return streamForCaller as unknown as R
167
180
  }
@@ -170,23 +183,23 @@ export function wrapAI<Args extends unknown[], R>(
170
183
  return wrapAsyncIterable(output, (chunks) => {
171
184
  const durationMs = rawDateNow() - start
172
185
  const rawText = chunks.map((c) => (typeof c === 'string' ? c : JSON.stringify(c))).join('')
173
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
186
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
174
187
  }) as unknown as R
175
188
  }
176
189
 
177
190
  const durationMs = rawDateNow() - start
178
- const usage = extractUsage(output)
179
191
  const event: WorkflowEvent = {
180
- id, type: 'ai', name: modelName, input, output,
192
+ id, type: 'ai', name: modelName, input: enriched.input, output,
181
193
  timestamp: start, durationMs,
182
- ...(usage ? { usage } : {}),
194
+ ...(enriched.usage ? { usage: enriched.usage } : {}),
183
195
  }
184
196
  pushTelemetryEvent(event)
185
197
  return output
186
198
  } catch (e) {
199
+ const enriched = enrichFromLLMCapture(input, undefined)
187
200
  const durationMs = rawDateNow() - start
188
201
  pushTelemetryEvent({
189
- id, type: 'ai', name: modelName, input,
202
+ id, type: 'ai', name: modelName, input: enriched.input,
190
203
  output: { error: String(e) }, timestamp: start, durationMs,
191
204
  })
192
205
  throw e
@@ -314,15 +327,16 @@ export function wrapAI<Args extends unknown[], R>(
314
327
 
315
328
  try {
316
329
  const output = await callFn(...httpEffectiveArgs)
330
+ const enriched = enrichFromLLMCapture(input, extractUsage(output))
317
331
 
318
332
  if (isReadableStream(output)) {
319
333
  const [streamForCaller, streamForRecorder] = output.tee()
320
334
  bufferReadableStream(streamForRecorder).then((rawText) => {
321
335
  const durationMs = rawDateNow() - start
322
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
336
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
323
337
  }).catch(() => {
324
338
  const durationMs = rawDateNow() - start
325
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs })
339
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: '', timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
326
340
  })
327
341
  return streamForCaller as unknown as R
328
342
  }
@@ -331,23 +345,23 @@ export function wrapAI<Args extends unknown[], R>(
331
345
  return wrapAsyncIterable(output, (chunks) => {
332
346
  const durationMs = rawDateNow() - start
333
347
  const rawText = chunks.map((c) => (typeof c === 'string' ? c : JSON.stringify(c))).join('')
334
- pushTelemetryEvent({ id, type: 'ai', name: modelName, input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs })
348
+ pushTelemetryEvent({ id, type: 'ai', name: modelName, input: enriched.input, output: null, streamed: true, streamRaw: rawText, timestamp: start, durationMs, ...(enriched.usage ? { usage: enriched.usage } : {}) })
335
349
  }) as unknown as R
336
350
  }
337
351
 
338
352
  const durationMs = rawDateNow() - start
339
- const usage = extractUsage(output)
340
353
  const event: WorkflowEvent = {
341
- id, type: 'ai', name: modelName, input, output,
354
+ id, type: 'ai', name: modelName, input: enriched.input, output,
342
355
  timestamp: start, durationMs,
343
- ...(usage ? { usage } : {}),
356
+ ...(enriched.usage ? { usage: enriched.usage } : {}),
344
357
  }
345
358
  pushTelemetryEvent(event)
346
359
  return output
347
360
  } catch (e) {
361
+ const enriched = enrichFromLLMCapture(input, undefined)
348
362
  const durationMs = rawDateNow() - start
349
363
  pushTelemetryEvent({
350
- id, type: 'ai', name: modelName, input,
364
+ id, type: 'ai', name: modelName, input: enriched.input,
351
365
  output: { error: String(e) }, timestamp: start, durationMs,
352
366
  })
353
367
  throw e