@outputai/cli 0.7.1-next.d67ad85.0 → 0.7.1-next.db8ddd7.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 (34) hide show
  1. package/dist/api/generated/api.d.ts +2 -2
  2. package/dist/api/generated/api.js +2 -2
  3. package/dist/api/workflow_catalog.d.ts +7 -0
  4. package/dist/api/workflow_catalog.js +11 -0
  5. package/dist/api/workflow_catalog.spec.d.ts +1 -0
  6. package/dist/api/workflow_catalog.spec.js +30 -0
  7. package/dist/assets/docker/docker-compose-dev.yml +1 -1
  8. package/dist/commands/workflow/list.d.ts +2 -0
  9. package/dist/commands/workflow/list.js +23 -21
  10. package/dist/commands/workflow/list.spec.js +57 -0
  11. package/dist/commands/workflow/status.js +6 -1
  12. package/dist/generated/framework_version.json +1 -1
  13. package/dist/services/cost_calculator.d.ts +1 -5
  14. package/dist/services/cost_calculator.js +214 -102
  15. package/dist/services/cost_calculator.spec.js +329 -253
  16. package/dist/services/workflow_runs.js +6 -1
  17. package/dist/types/cost.d.ts +64 -23
  18. package/dist/types/cost.js +4 -0
  19. package/dist/utils/cost_formatter.js +65 -43
  20. package/dist/utils/format_workflow_result.js +4 -2
  21. package/dist/utils/format_workflow_result.spec.js +13 -3
  22. package/dist/utils/normalize_workflow_status.d.ts +8 -0
  23. package/dist/utils/normalize_workflow_status.js +8 -0
  24. package/dist/utils/normalize_workflow_status.spec.d.ts +1 -0
  25. package/dist/utils/normalize_workflow_status.spec.js +13 -0
  26. package/dist/utils/scenario_resolver.js +3 -11
  27. package/dist/utils/scenario_resolver.spec.js +5 -9
  28. package/dist/views/dev/components/workflow_status.js +1 -1
  29. package/dist/views/dev/hooks/use_run_detail.js +6 -1
  30. package/dist/views/dev/hooks/use_run_detail.spec.js +1 -1
  31. package/dist/views/dev/hooks/use_workflow_catalog.js +2 -6
  32. package/dist/views/dev/panels/runs_panel.js +1 -1
  33. package/oclif.manifest.json +19 -2
  34. package/package.json +4 -4
@@ -9,7 +9,46 @@ const mockLoad = vi.fn();
9
9
  vi.mock('js-yaml', () => ({
10
10
  default: { load: (...args) => mockLoad(...args) }
11
11
  }));
12
- import { extractValue, findLLMCalls, findHTTPCalls, calculateLLMCallCost, identifyService, calculateServiceCost, calculateCost, loadPricingConfig } from '#services/cost_calculator.js';
12
+ import { extractValue, findLLMCalls, findHTTPCalls, identifyService, calculateServiceCost, calculateCost, loadPricingConfig } from '#services/cost_calculator.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Fixture builders
15
+ // ---------------------------------------------------------------------------
16
+ function llmLines(inAmt, inPpm, outAmt, outPpm, cachedAmt = 0, cachedPpm = 0) {
17
+ return [
18
+ { type: 'input', ppm: inPpm, amount: inAmt, total: (inAmt / 1e6) * inPpm },
19
+ { type: 'input_cached', ppm: cachedPpm, amount: cachedAmt, total: (cachedAmt / 1e6) * cachedPpm },
20
+ { type: 'output', ppm: outPpm, amount: outAmt, total: (outAmt / 1e6) * outPpm }
21
+ ];
22
+ }
23
+ function llmEventNode(id, model, lines) {
24
+ const total = lines.reduce((s, l) => s + l.total, 0);
25
+ return {
26
+ id,
27
+ kind: 'llm',
28
+ name: 'gen',
29
+ attributes: {
30
+ 'llm:usage': {
31
+ type: 'llm:usage',
32
+ modelId: model,
33
+ usage: lines,
34
+ total,
35
+ tokensUsed: lines.reduce((s, l) => s + l.amount, 0)
36
+ }
37
+ }
38
+ };
39
+ }
40
+ function httpEventNode(id, url, method, total, output = { status: 200, body: {} }, inputBody) {
41
+ return {
42
+ id,
43
+ kind: 'http',
44
+ name: 'request',
45
+ input: { url, method, ...(inputBody ? { body: inputBody } : {}) },
46
+ output,
47
+ attributes: {
48
+ 'http:request:cost': { type: 'http:request:cost', url, requestId: id, total }
49
+ }
50
+ };
51
+ }
13
52
  const llmTrace = {
14
53
  id: 'test-trace-1',
15
54
  kind: 'workflow',
@@ -21,29 +60,13 @@ const llmTrace = {
21
60
  id: 'step-1',
22
61
  kind: 'step',
23
62
  name: 'test_workflow#generate_summary',
24
- children: [
25
- {
26
- id: 'llm-1',
27
- kind: 'llm',
28
- name: 'generate_summary',
29
- input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5' } } },
30
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
31
- }
32
- ]
63
+ children: [llmEventNode('llm-1', 'claude-sonnet-4-5', llmLines(1000, 3, 500, 15))]
33
64
  },
34
65
  {
35
66
  id: 'step-2',
36
67
  kind: 'step',
37
68
  name: 'test_workflow#analyze_data',
38
- children: [
39
- {
40
- id: 'llm-2',
41
- kind: 'llm',
42
- name: 'analyze_data',
43
- input: { loadedPrompt: { config: { model: 'claude-haiku-4-5' } } },
44
- output: { usage: { inputTokens: 2000, outputTokens: 1000, cachedInputTokens: 500 } }
45
- }
46
- ]
69
+ children: [llmEventNode('llm-2', 'claude-haiku-4-5', llmLines(1500, 1, 1000, 5, 500, 0.1))]
47
70
  }
48
71
  ]
49
72
  };
@@ -58,35 +81,13 @@ const httpTrace = {
58
81
  id: 'step-1',
59
82
  kind: 'step',
60
83
  name: 'test_workflow#fetch_content',
61
- children: [
62
- {
63
- id: 'http-1',
64
- kind: 'http',
65
- name: 'jina_request',
66
- input: { url: 'https://r.jina.ai/https://example.com', method: 'GET' },
67
- output: { status: 200, body: { data: { usage: { tokens: 5000 } } } }
68
- }
69
- ]
84
+ children: [httpEventNode('http-1', 'https://r.jina.ai/https://example.com', 'GET', 0.0002)]
70
85
  },
71
86
  {
72
87
  id: 'step-2',
73
88
  kind: 'step',
74
89
  name: 'test_workflow#search',
75
- children: [
76
- {
77
- id: 'http-2',
78
- kind: 'http',
79
- name: 'exa_request',
80
- input: { url: 'https://api.exa.ai/research', method: 'GET' },
81
- output: {
82
- status: 200,
83
- body: {
84
- model: 'exa-research',
85
- costDollars: { total: 0.15, numSearches: 1, numPages: 5 }
86
- }
87
- }
88
- }
89
- ]
90
+ children: [httpEventNode('http-2', 'https://api.exa.ai/research', 'POST', 0.012)]
90
91
  }
91
92
  ]
92
93
  };
@@ -101,36 +102,21 @@ const duplicateTrace = {
101
102
  id: 'step-1',
102
103
  kind: 'step',
103
104
  name: 'test_workflow#step_one',
104
- children: [
105
- {
106
- id: 'llm-same-id',
107
- kind: 'llm',
108
- name: 'generate',
109
- input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5' } } },
110
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
111
- }
112
- ]
105
+ children: [llmEventNode('llm-same-id', 'claude-sonnet-4-5', llmLines(1000, 3, 500, 15))]
113
106
  },
114
107
  {
115
108
  id: 'child-workflow',
116
109
  kind: 'workflow',
117
110
  name: 'child_workflow',
118
- children: [
119
- {
120
- id: 'llm-same-id',
121
- kind: 'llm',
122
- name: 'generate',
123
- input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5' } } },
124
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
125
- }
126
- ]
111
+ children: [llmEventNode('llm-same-id', 'claude-sonnet-4-5', llmLines(1000, 3, 500, 15))]
127
112
  }
128
113
  ]
129
114
  };
130
115
  const testConfig = {
131
116
  models: {
132
117
  'claude-sonnet-4-5': { provider: 'anthropic', input: 3.0, output: 15.0, cached_input: 0.30 },
133
- 'claude-haiku-4-5': { provider: 'anthropic', input: 1.0, output: 5.0, cached_input: 0.10 }
118
+ 'claude-haiku-4-5': { provider: 'anthropic', input: 1.0, output: 5.0, cached_input: 0.10 },
119
+ 'claude-opus-4': { provider: 'anthropic', input: 15.0, output: 75.0, cached_input: 1.50 }
134
120
  },
135
121
  services: {
136
122
  jina: {
@@ -144,6 +130,14 @@ const testConfig = {
144
130
  url_pattern: 'api.exa.ai',
145
131
  cost_path: 'output.body.costDollars.total',
146
132
  billable_method: 'POST'
133
+ },
134
+ tavily: {
135
+ type: 'request',
136
+ url_pattern: 'api.tavily.com',
137
+ endpoints: {
138
+ search: { pattern: '/search', price: 0.01 },
139
+ extract: { pattern: '/extract', price_per_item: 0.005, items_path: 'body.urls' }
140
+ }
147
141
  }
148
142
  }
149
143
  };
@@ -167,29 +161,43 @@ describe('extractValue', () => {
167
161
  });
168
162
  });
169
163
  describe('findLLMCalls', () => {
170
- it('finds nested LLM calls', () => {
164
+ it('finds nested LLM calls and extracts step names', () => {
171
165
  const calls = findLLMCalls(llmTrace);
172
166
  expect(calls).toHaveLength(2);
173
- });
174
- it('extracts step names correctly', () => {
175
- const calls = findLLMCalls(llmTrace);
176
167
  expect(calls[0].stepName).toBe('generate_summary');
177
168
  expect(calls[1].stepName).toBe('analyze_data');
178
- });
179
- it('extracts model names from loadedPrompt config', () => {
180
- const calls = findLLMCalls(llmTrace);
181
169
  expect(calls[0].model).toBe('claude-sonnet-4-5');
182
- expect(calls[1].model).toBe('claude-haiku-4-5');
183
- });
184
- it('extracts token usage', () => {
185
- const calls = findLLMCalls(llmTrace);
186
- expect(calls[0].usage.inputTokens).toBe(1000);
187
- expect(calls[0].usage.outputTokens).toBe(500);
188
170
  expect(calls[1].usage.cachedInputTokens).toBe(500);
189
171
  });
190
- it('deduplicates by ID', () => {
191
- const calls = findLLMCalls(duplicateTrace);
172
+ it('reads model, tokens and as-charged cost from llm:usage event', () => {
173
+ const trace = {
174
+ kind: 'workflow',
175
+ name: 'test',
176
+ children: [llmEventNode('llm-ev', 'gpt-5.5', llmLines(27400, 5, 7275, 30))]
177
+ };
178
+ const calls = findLLMCalls(trace);
192
179
  expect(calls).toHaveLength(1);
180
+ expect(calls[0].model).toBe('gpt-5.5');
181
+ expect(calls[0].usage.inputTokens).toBe(27400);
182
+ expect(calls[0].usage.outputTokens).toBe(7275);
183
+ expect(calls[0].originalCost).toBeCloseTo(0.35525, 5);
184
+ });
185
+ it('ignores llm nodes without a llm:usage event', () => {
186
+ const trace = {
187
+ kind: 'workflow',
188
+ name: 'test',
189
+ children: [{
190
+ id: 'llm-old',
191
+ kind: 'llm',
192
+ name: 'gen',
193
+ input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5' } } },
194
+ output: { usage: { inputTokens: 1000, outputTokens: 500 } }
195
+ }]
196
+ };
197
+ expect(findLLMCalls(trace)).toHaveLength(0);
198
+ });
199
+ it('deduplicates nodes by ID', () => {
200
+ expect(findLLMCalls(duplicateTrace)).toHaveLength(1);
193
201
  });
194
202
  });
195
203
  describe('findHTTPCalls', () => {
@@ -197,71 +205,43 @@ describe('findHTTPCalls', () => {
197
205
  const calls = findHTTPCalls(httpTrace);
198
206
  expect(calls).toHaveLength(2);
199
207
  });
200
- it('extracts URLs and methods', () => {
208
+ it('extracts URLs, methods and host', () => {
201
209
  const calls = findHTTPCalls(httpTrace);
202
210
  expect(calls[0].url).toContain('r.jina.ai');
203
211
  expect(calls[0].method).toBe('GET');
212
+ expect(calls[0].host).toBe('r.jina.ai');
204
213
  });
205
214
  it('extracts step names', () => {
206
215
  const calls = findHTTPCalls(httpTrace);
207
216
  expect(calls[0].stepName).toBe('fetch_content');
208
217
  expect(calls[1].stepName).toBe('search');
209
218
  });
210
- });
211
- describe('calculateLLMCallCost', () => {
212
- it('calculates cost for known model', () => {
213
- const usage = { inputTokens: 1000000, outputTokens: 500000 };
214
- const modelPricing = { provider: 'anthropic', input: 3.0, output: 15.0 };
215
- const { cost } = calculateLLMCallCost(usage, modelPricing);
216
- // 1M input * $3/M + 0.5M output * $15/M = $3 + $7.5 = $10.5
217
- expect(cost).toBeCloseTo(10.5, 2);
218
- });
219
- it('includes cached input tokens at reduced rate', () => {
220
- const usage = { inputTokens: 1000000, outputTokens: 0, cachedInputTokens: 1000000 };
221
- const modelPricing = { provider: 'anthropic', input: 3.0, output: 15.0, cached_input: 0.3 };
222
- const { cost } = calculateLLMCallCost(usage, modelPricing);
223
- // 1M input * $3/M + 1M cached * $0.3/M = $3 + $0.3 = $3.3
224
- expect(cost).toBeCloseTo(3.3, 2);
225
- });
226
- it('returns zero with warning for unknown model', () => {
227
- const usage = { inputTokens: 1000, outputTokens: 500 };
228
- const { cost, warning } = calculateLLMCallCost(usage, undefined);
229
- expect(cost).toBe(0);
230
- expect(warning).toBe('unknown model');
219
+ it('reads host and as-charged cost from http:request:cost event', () => {
220
+ const trace = {
221
+ kind: 'workflow',
222
+ name: 'test',
223
+ children: [httpEventNode('req-1', 'https://api.exa.ai/search', 'POST', 0.012)]
224
+ };
225
+ const calls = findHTTPCalls(trace);
226
+ expect(calls).toHaveLength(1);
227
+ expect(calls[0].host).toBe('api.exa.ai');
228
+ expect(calls[0].originalCost).toBe(0.012);
231
229
  });
232
230
  });
233
231
  describe('identifyService', () => {
232
+ const baseCall = (url) => ({
233
+ stepName: 'test', url, method: 'GET', input: {}, output: {}, host: url
234
+ });
234
235
  it('identifies Jina by URL pattern', () => {
235
- const call = {
236
- stepName: 'test',
237
- url: 'https://r.jina.ai/https://example.com',
238
- method: 'GET',
239
- input: {},
240
- output: {}
241
- };
242
- const result = identifyService(call, testConfig.services);
236
+ const result = identifyService(baseCall('https://r.jina.ai/https://example.com'), testConfig.services);
243
237
  expect(result?.serviceName).toBe('jina');
244
238
  });
245
239
  it('identifies Exa by URL pattern', () => {
246
- const call = {
247
- stepName: 'test',
248
- url: 'https://api.exa.ai/research',
249
- method: 'GET',
250
- input: {},
251
- output: {}
252
- };
253
- const result = identifyService(call, testConfig.services);
240
+ const result = identifyService(baseCall('https://api.exa.ai/research'), testConfig.services);
254
241
  expect(result?.serviceName).toBe('exa');
255
242
  });
256
243
  it('returns null for unknown URLs', () => {
257
- const call = {
258
- stepName: 'test',
259
- url: 'https://unknown-api.com/endpoint',
260
- method: 'GET',
261
- input: {},
262
- output: {}
263
- };
264
- const result = identifyService(call, testConfig.services);
244
+ const result = identifyService(baseCall('https://unknown-api.com/endpoint'), testConfig.services);
265
245
  expect(result).toBeNull();
266
246
  });
267
247
  });
@@ -271,10 +251,9 @@ describe('calculateServiceCost', () => {
271
251
  stepName: 'test',
272
252
  url: 'https://r.jina.ai/https://example.com',
273
253
  method: 'GET',
254
+ host: 'r.jina.ai',
274
255
  input: {},
275
- output: {
276
- body: { data: { usage: { tokens: 1000000 } } }
277
- }
256
+ output: { body: { data: { usage: { tokens: 1000000 } } } }
278
257
  };
279
258
  const serviceInfo = identifyService(call, testConfig.services);
280
259
  const result = calculateServiceCost(call, serviceInfo);
@@ -286,12 +265,10 @@ describe('calculateServiceCost', () => {
286
265
  stepName: 'test',
287
266
  url: 'https://api.exa.ai/research',
288
267
  method: 'GET',
268
+ host: 'api.exa.ai',
289
269
  input: {},
290
270
  output: {
291
- body: {
292
- model: 'exa-research',
293
- costDollars: { total: 0.15, numSearches: 1, numPages: 5 }
294
- }
271
+ body: { model: 'exa-research', costDollars: { total: 0.15, numSearches: 1, numPages: 5 } }
295
272
  }
296
273
  };
297
274
  const serviceInfo = identifyService(call, testConfig.services);
@@ -305,10 +282,9 @@ describe('calculateServiceCost', () => {
305
282
  stepName: 'test',
306
283
  url: 'https://api.exa.ai/research',
307
284
  method: 'GET',
285
+ host: 'api.exa.ai',
308
286
  input: {},
309
- output: {
310
- body: { status: 'pending' }
311
- }
287
+ output: { body: { status: 'pending' } }
312
288
  };
313
289
  const serviceInfo = identifyService(call, testConfig.services);
314
290
  const result = calculateServiceCost(call, serviceInfo);
@@ -316,92 +292,22 @@ describe('calculateServiceCost', () => {
316
292
  expect(result.warning).toBe('no cost data');
317
293
  });
318
294
  });
319
- describe('response_cost filtering in calculateCost', () => {
320
- it('skips Exa polling requests without cost data', () => {
321
- const trace = {
322
- kind: 'workflow',
323
- name: 'test_workflow',
324
- startedAt: 1700000000000,
325
- endedAt: 1700000100000,
326
- children: [
327
- {
328
- id: 'step-exa',
329
- kind: 'step',
330
- name: 'test_workflow#search',
331
- children: [
332
- {
333
- id: 'http-exa-poll',
334
- kind: 'http',
335
- name: 'exa_poll',
336
- input: { url: 'https://api.exa.ai/research/task-123', method: 'GET' },
337
- output: { status: 200, body: { status: 'in_progress' } }
338
- }
339
- ]
340
- }
341
- ]
342
- };
343
- const report = calculateCost(trace, testConfig, 'test.json');
344
- expect(report.serviceTotalCost).toBe(0);
345
- expect(report.services).toHaveLength(0);
346
- });
347
- it('counts Exa responses that have costDollars', () => {
348
- const trace = {
349
- kind: 'workflow',
350
- name: 'test_workflow',
351
- startedAt: 1700000000000,
352
- endedAt: 1700000100000,
353
- children: [
354
- {
355
- id: 'step-exa',
356
- kind: 'step',
357
- name: 'test_workflow#search',
358
- children: [
359
- {
360
- id: 'http-exa-poll',
361
- kind: 'http',
362
- name: 'exa_poll',
363
- input: { url: 'https://api.exa.ai/research/task-123', method: 'GET' },
364
- output: { status: 200, body: { status: 'in_progress' } }
365
- },
366
- {
367
- id: 'http-exa-result',
368
- kind: 'http',
369
- name: 'exa_result',
370
- input: { url: 'https://api.exa.ai/research/task-123', method: 'GET' },
371
- output: {
372
- status: 200,
373
- body: {
374
- model: 'exa-research',
375
- costDollars: { total: 0.08, numSearches: 1, numPages: 3 }
376
- }
377
- }
378
- }
379
- ]
380
- }
381
- ]
382
- };
383
- const report = calculateCost(trace, testConfig, 'test.json');
384
- expect(report.services).toHaveLength(1);
385
- expect(report.services[0].calls).toHaveLength(1);
386
- expect(report.services[0].totalCost).toBeCloseTo(0.08, 4);
387
- });
388
- });
389
295
  describe('calculateCost', () => {
390
296
  it('calculates total cost for LLM trace', () => {
391
297
  const report = calculateCost(llmTrace, testConfig, 'test.json');
392
298
  expect(report.llmCalls).toHaveLength(2);
393
299
  expect(report.workflowName).toBe('test_workflow');
394
- expect(report.llmTotalCost).toBeGreaterThan(0);
395
- expect(report.totalCost).toBe(report.llmTotalCost + report.serviceTotalCost);
300
+ expect(report.llmAdjustedCost).toBeGreaterThan(0);
301
+ expect(report.totalCost).toBe(report.llmAdjustedCost + report.httpAdjustedCost);
302
+ expect(report.originalTotalCost).toBe(report.llmOriginalCost + report.httpOriginalCost);
396
303
  });
397
304
  it('calculates total cost for HTTP trace', () => {
398
305
  const report = calculateCost(httpTrace, testConfig, 'test.json');
399
- expect(report.services.length).toBeGreaterThan(0);
400
- expect(report.serviceTotalCost).toBeGreaterThan(0);
306
+ expect(report.httpCosts.length).toBeGreaterThan(0);
307
+ expect(report.httpAdjustedCost).toBeGreaterThan(0);
401
308
  });
402
309
  it('calculates duration from timestamps', () => {
403
310
  const report = calculateCost(llmTrace, testConfig, 'test.json');
404
- // 1700000100000 - 1700000000000 = 100000ms
405
311
  expect(report.durationMs).toBe(100000);
406
312
  });
407
313
  it('handles deduplication in cost calculation', () => {
@@ -412,65 +318,235 @@ describe('calculateCost', () => {
412
318
  const trace = {
413
319
  kind: 'workflow',
414
320
  name: 'test',
415
- children: [{
416
- id: 'step-1',
417
- kind: 'step',
418
- name: 'test#gen',
419
- children: [{
420
- id: 'llm-1',
421
- kind: 'llm',
422
- name: 'gen',
423
- input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5-20250514' } } },
424
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
425
- }]
426
- }]
321
+ children: [
322
+ llmEventNode('llm-1', 'claude-sonnet-4-5-20250514', llmLines(1000, 3, 500, 15))
323
+ ]
427
324
  };
428
325
  const report = calculateCost(trace, testConfig, 'test.json');
429
- expect(report.llmTotalCost).toBeGreaterThan(0);
430
- expect(report.unknownModels).toHaveLength(0);
431
- expect(report.llmCalls[0].warning).toBe('priced as claude-sonnet-4-5');
326
+ // priced at the claude-sonnet-4-5 prefix rates: 1000@$3/M + 500@$15/M
327
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(0.0105, 5);
432
328
  });
433
- it('reports unknown model when no prefix match exists', () => {
329
+ });
330
+ describe('event-driven LLM costs (original vs adjusted)', () => {
331
+ it('matches original when costs.yml rate equals the event rate', () => {
332
+ // haiku event priced at the same rate as testConfig (input 1 / output 5)
434
333
  const trace = {
435
334
  kind: 'workflow',
436
335
  name: 'test',
437
- children: [{
438
- id: 'step-1',
439
- kind: 'step',
440
- name: 'test#gen',
441
- children: [{
442
- id: 'llm-1',
443
- kind: 'llm',
444
- name: 'gen',
445
- input: { loadedPrompt: { config: { model: 'totally-unknown-model' } } },
446
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
447
- }]
448
- }]
336
+ children: [llmEventNode('h', 'claude-haiku-4-5', llmLines(1_000_000, 1, 1_000_000, 5))]
449
337
  };
450
338
  const report = calculateCost(trace, testConfig, 'test.json');
451
- expect(report.llmTotalCost).toBe(0);
452
- expect(report.unknownModels).toContain('totally-unknown-model');
453
- expect(report.llmCalls[0].warning).toBe('unknown model');
339
+ expect(report.llmCalls[0].originalCost).toBeCloseTo(6, 5);
340
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(6, 5);
454
341
  });
455
- it('prefers exact model match over prefix', () => {
342
+ it('overrides via prefix match when the configured rate differs (opus-4-8 → opus-4)', () => {
343
+ // event charged at 5/25; costs.yml has no opus-4-8, prefix-matches opus-4 at 15/75
456
344
  const trace = {
457
345
  kind: 'workflow',
458
346
  name: 'test',
459
- children: [{
460
- id: 'step-1',
461
- kind: 'step',
462
- name: 'test#gen',
463
- children: [{
464
- id: 'llm-1',
465
- kind: 'llm',
466
- name: 'gen',
467
- input: { loadedPrompt: { config: { model: 'claude-sonnet-4-5' } } },
468
- output: { usage: { inputTokens: 1000, outputTokens: 500 } }
469
- }]
470
- }]
347
+ children: [llmEventNode('o', 'claude-opus-4-8', llmLines(1_000_000, 5, 1_000_000, 25))]
348
+ };
349
+ const report = calculateCost(trace, testConfig, 'test.json');
350
+ expect(report.llmCalls[0].originalCost).toBeCloseTo(30, 5);
351
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(90, 5);
352
+ expect(report.llmOriginalCost).toBeCloseTo(30, 5);
353
+ expect(report.llmAdjustedCost).toBeCloseTo(90, 5);
354
+ });
355
+ it('leaves adjusted equal to original for a model not in costs.yml (gpt-5.5)', () => {
356
+ const trace = {
357
+ kind: 'workflow',
358
+ name: 'test',
359
+ children: [llmEventNode('g', 'gpt-5.5', llmLines(27400, 5, 7275, 30))]
360
+ };
361
+ const report = calculateCost(trace, testConfig, 'test.json');
362
+ expect(report.llmCalls[0].originalCost).toBeCloseTo(0.35525, 5);
363
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(0.35525, 5);
364
+ });
365
+ it('prices an unknown line type at its as-charged total, not $0', () => {
366
+ const lines = [
367
+ ...llmLines(1_000_000, 1, 1_000_000, 5),
368
+ { type: 'input_cache_write', ppm: 1.25, amount: 200_000, total: 0.25 }
369
+ ];
370
+ const trace = {
371
+ kind: 'workflow',
372
+ name: 'test',
373
+ children: [llmEventNode('cw', 'claude-haiku-4-5', lines)]
374
+ };
375
+ const report = calculateCost(trace, testConfig, 'test.json');
376
+ // known lines reprice to $6 at config rates; unknown line passes through at $0.25
377
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(6.25, 5);
378
+ expect(report.llmCalls[0].originalCost).toBeCloseTo(6.25, 5);
379
+ });
380
+ it('reports inputTokens including cached tokens for event traces', () => {
381
+ const trace = {
382
+ kind: 'workflow',
383
+ name: 'test',
384
+ children: [llmEventNode('c', 'claude-haiku-4-5', llmLines(1000, 1, 500, 5, 600, 0.1))]
385
+ };
386
+ const report = calculateCost(trace, testConfig, 'test.json');
387
+ // producer 'input' line excludes cached — display restores the AI SDK total
388
+ expect(report.llmCalls[0].input).toBe(1600);
389
+ expect(report.llmCalls[0].cached).toBe(600);
390
+ // repriced: 1000@$1/M + 600@$0.1/M + 500@$5/M
391
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(0.00356, 6);
392
+ });
393
+ });
394
+ describe('event-driven HTTP costs (original vs adjusted)', () => {
395
+ it('falls back to as-charged cost when service recompute is not usable', () => {
396
+ // exa POST with a cost event but no costDollars body → recompute is $0 → use event
397
+ const trace = {
398
+ kind: 'workflow',
399
+ name: 'test',
400
+ children: [httpEventNode('exa-1', 'https://api.exa.ai/search', 'POST', 0.012)]
401
+ };
402
+ const report = calculateCost(trace, testConfig, 'test.json');
403
+ expect(report.httpCosts).toHaveLength(1);
404
+ expect(report.httpCosts[0].host).toBe('api.exa.ai');
405
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.012, 5);
406
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.012, 5);
407
+ });
408
+ it('overrides with the recomputed cost for a request-based service (tavily)', () => {
409
+ const trace = {
410
+ kind: 'workflow',
411
+ name: 'test',
412
+ children: [httpEventNode('tv-1', 'https://api.tavily.com/search', 'POST', 0.008)]
413
+ };
414
+ const report = calculateCost(trace, testConfig, 'test.json');
415
+ expect(report.httpCosts[0].host).toBe('api.tavily.com');
416
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.008, 5);
417
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.01, 5);
418
+ });
419
+ it('uses as-charged cost for an unconfigured host (firecrawl)', () => {
420
+ const trace = {
421
+ kind: 'workflow',
422
+ name: 'test',
423
+ children: [httpEventNode('fc-1', 'https://api.firecrawl.dev/v1/scrape', 'POST', 0.0008)]
424
+ };
425
+ const report = calculateCost(trace, testConfig, 'test.json');
426
+ expect(report.httpCosts[0].host).toBe('api.firecrawl.dev');
427
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.0008, 5);
428
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.0008, 5);
429
+ });
430
+ it('counts an event-bearing 4xx call as-charged (the event proves a charge)', () => {
431
+ const trace = {
432
+ kind: 'workflow',
433
+ name: 'test',
434
+ children: [httpEventNode('fc-429', 'https://api.firecrawl.dev/v1/scrape', 'POST', 0.10, { status: 429, body: {} })]
435
+ };
436
+ const report = calculateCost(trace, testConfig, 'test.json');
437
+ expect(report.httpCosts).toHaveLength(1);
438
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.10, 5);
439
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.10, 5);
440
+ });
441
+ it('excludes eventless calls — only http:request:cost events are billable', () => {
442
+ const trace = {
443
+ kind: 'workflow',
444
+ name: 'test',
445
+ children: [
446
+ httpEventNode('fc-1', 'https://api.firecrawl.dev/v1/scrape', 'POST', 0.05),
447
+ // configured service with priceable body, but no cost event → not billed
448
+ {
449
+ id: 'jina-no-event',
450
+ kind: 'http',
451
+ name: 'request',
452
+ input: { url: 'https://r.jina.ai/https://example.com', method: 'GET' },
453
+ output: { status: 200, body: { data: { usage: { tokens: 1000000 } } } }
454
+ }
455
+ ]
456
+ };
457
+ const report = calculateCost(trace, testConfig, 'test.json');
458
+ expect(report.httpCosts.find(h => h.host === 'r.jina.ai')).toBeUndefined();
459
+ expect(report.httpCosts.find(h => h.host === 'api.firecrawl.dev')).toBeDefined();
460
+ });
461
+ it('applies a legitimately computed $0 override', () => {
462
+ const config = {
463
+ ...testConfig,
464
+ services: {
465
+ ...testConfig.services,
466
+ tavily: {
467
+ type: 'request',
468
+ url_pattern: 'api.tavily.com',
469
+ endpoints: { search: { pattern: '/search', price: 0 } }
470
+ }
471
+ }
472
+ };
473
+ const trace = {
474
+ kind: 'workflow',
475
+ name: 'test',
476
+ children: [httpEventNode('tv-free', 'https://api.tavily.com/search', 'POST', 0.008)]
477
+ };
478
+ const report = calculateCost(trace, config, 'test.json');
479
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.008, 5);
480
+ expect(report.httpCosts[0].adjustedTotalCost).toBe(0);
481
+ });
482
+ it('does not let a fallback estimate override an exact event cost', () => {
483
+ const config = {
484
+ ...testConfig,
485
+ services: {
486
+ ...testConfig.services,
487
+ exa: {
488
+ ...testConfig.services.exa,
489
+ fallback_models: { 'exa-research': 0.10 },
490
+ default_fallback: 0.10
491
+ }
492
+ }
493
+ };
494
+ const trace = {
495
+ kind: 'workflow',
496
+ name: 'test',
497
+ children: [httpEventNode('exa-est', 'https://api.exa.ai/search', 'POST', 0.012)]
498
+ };
499
+ const report = calculateCost(trace, config, 'test.json');
500
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.012, 5);
501
+ });
502
+ it('treats an un-captured request body as failed, preserving the as-charged cost', () => {
503
+ // tavily /extract is price_per_item over body.urls — body missing from trace
504
+ const trace = {
505
+ kind: 'workflow',
506
+ name: 'test',
507
+ children: [httpEventNode('tv-x', 'https://api.tavily.com/extract', 'POST', 0.015)]
508
+ };
509
+ const report = calculateCost(trace, testConfig, 'test.json');
510
+ expect(report.httpCosts[0].adjustedTotalCost).toBeCloseTo(0.015, 5);
511
+ });
512
+ it('overrides with a real measured item count, including an empty array', () => {
513
+ const trace = {
514
+ kind: 'workflow',
515
+ name: 'test',
516
+ children: [httpEventNode('tv-0', 'https://api.tavily.com/extract', 'POST', 0.015, { status: 200, body: {} }, { urls: [] })]
517
+ };
518
+ const report = calculateCost(trace, testConfig, 'test.json');
519
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.015, 5);
520
+ expect(report.httpCosts[0].adjustedTotalCost).toBe(0);
521
+ });
522
+ it('ignores count-only nodes and groups billable requests by host', () => {
523
+ const trace = {
524
+ kind: 'workflow',
525
+ name: 'test',
526
+ children: [
527
+ // count-only webhook (no cost event) — must be excluded
528
+ {
529
+ id: 'wh-1',
530
+ kind: 'http',
531
+ name: 'request',
532
+ input: { url: 'https://os.growthx.ai/webhooks/output', method: 'POST' },
533
+ attributes: {
534
+ 'http:request:count': {
535
+ type: 'http:request:count',
536
+ url: 'https://os.growthx.ai/webhooks/output',
537
+ requestId: 'wh-1'
538
+ }
539
+ }
540
+ },
541
+ httpEventNode('fc-1', 'https://api.firecrawl.dev/v1/scrape', 'POST', 0.10),
542
+ httpEventNode('fc-2', 'https://api.firecrawl.dev/v1/scrape', 'POST', 0.05)
543
+ ]
471
544
  };
472
545
  const report = calculateCost(trace, testConfig, 'test.json');
473
- expect(report.llmCalls[0].warning).toBeUndefined();
546
+ expect(report.httpCosts).toHaveLength(1);
547
+ expect(report.httpCosts[0].host).toBe('api.firecrawl.dev');
548
+ expect(report.httpCosts[0].calls).toHaveLength(2);
549
+ expect(report.httpCosts[0].originalTotalCost).toBeCloseTo(0.15, 5);
474
550
  });
475
551
  });
476
552
  describe('loadPricingConfig', () => {