@outputai/cli 0.7.1-next.ae5bab4.0 → 0.7.1-next.bd6bd49.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.
- package/bin/run.js +1 -1
- package/dist/api/generated/api.d.ts +38 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/hooks/init.js +12 -3
- package/dist/hooks/init.spec.js +18 -8
- package/dist/scripts/refresh_version_check.d.ts +1 -0
- package/dist/scripts/refresh_version_check.js +9 -0
- package/dist/services/cost_calculator.d.ts +1 -5
- package/dist/services/cost_calculator.js +214 -102
- package/dist/services/cost_calculator.spec.js +329 -253
- package/dist/services/npm_update_service.js +11 -3
- package/dist/services/npm_update_service.spec.js +20 -7
- package/dist/services/version_check.d.ts +19 -1
- package/dist/services/version_check.js +53 -17
- package/dist/services/version_check.spec.js +88 -58
- package/dist/types/cost.d.ts +64 -23
- package/dist/types/cost.js +4 -0
- package/dist/utils/cost_formatter.js +65 -43
- package/dist/utils/proxy.d.ts +3 -2
- package/dist/utils/proxy.js +4 -3
- package/dist/utils/proxy.spec.js +4 -4
- package/oclif.manifest.json +1428 -0
- package/package.json +6 -5
|
@@ -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,
|
|
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('
|
|
191
|
-
const
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
expect(
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
395
|
-
expect(report.totalCost).toBe(report.
|
|
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.
|
|
400
|
-
expect(report.
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
430
|
-
expect(report.
|
|
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
|
-
|
|
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.
|
|
452
|
-
expect(report.
|
|
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('
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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.
|
|
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', () => {
|