@outputai/cli 0.12.0 → 0.12.1-next.69255d7.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/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/cost_calculator.js +13 -4
- package/dist/services/cost_calculator.spec.js +68 -4
- package/dist/types/cost.d.ts +3 -0
- package/dist/utils/cost_formatter.js +20 -4
- package/dist/utils/cost_formatter.spec.d.ts +1 -0
- package/dist/utils/cost_formatter.spec.js +60 -0
- package/oclif.manifest.json +1 -1
- package/package.json +4 -4
|
@@ -84,7 +84,8 @@ function parseLegacyLLMUsageEvent(node, stepName, event) {
|
|
|
84
84
|
model: event.modelId || 'unknown',
|
|
85
85
|
usage: eventTokenUsage(event.usage ?? []),
|
|
86
86
|
originalCost: event.total,
|
|
87
|
-
lines: event.usage ?? []
|
|
87
|
+
lines: event.usage ?? [],
|
|
88
|
+
incomplete: false
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
function parseNormalizedLLMUsage(node, stepName, event) {
|
|
@@ -100,7 +101,11 @@ function parseNormalizedLLMUsage(node, stepName, event) {
|
|
|
100
101
|
model: event.modelId || 'unknown',
|
|
101
102
|
usage: normalizedItemTokenUsage(event.items),
|
|
102
103
|
originalCost: 0,
|
|
103
|
-
lines
|
|
104
|
+
lines,
|
|
105
|
+
// This path runs whenever cost computation produced no llm:generation:cost
|
|
106
|
+
// attribute at all (e.g. pricing config missing, not just unrated grounding),
|
|
107
|
+
// so every line here is unpriced by construction, not only grounding calls.
|
|
108
|
+
incomplete: true
|
|
104
109
|
};
|
|
105
110
|
}
|
|
106
111
|
function parseLLMCostEvent(node, stepName, event) {
|
|
@@ -116,7 +121,10 @@ function parseLLMCostEvent(node, stepName, event) {
|
|
|
116
121
|
model: event.modelId || 'unknown',
|
|
117
122
|
usage: normalizedItemTokenUsage(event.items),
|
|
118
123
|
originalCost: event.total ?? 0,
|
|
119
|
-
lines
|
|
124
|
+
lines,
|
|
125
|
+
// An INCOMPLETE event carries a real but unpriced charge (e.g. unrated
|
|
126
|
+
// grounding). Flag it so the report can mark the understated total.
|
|
127
|
+
incomplete: event.status === 'incomplete'
|
|
120
128
|
};
|
|
121
129
|
}
|
|
122
130
|
export function extractValue(obj, path) {
|
|
@@ -461,7 +469,8 @@ function aggregateLLMCosts(llmCalls, config) {
|
|
|
461
469
|
cached: call.usage.cachedInputTokens ?? 0,
|
|
462
470
|
reasoning: call.usage.reasoningTokens ?? 0,
|
|
463
471
|
originalCost,
|
|
464
|
-
adjustedCost
|
|
472
|
+
adjustedCost,
|
|
473
|
+
incomplete: call.incomplete
|
|
465
474
|
});
|
|
466
475
|
totals.inputTokens += call.usage.inputTokens ?? 0;
|
|
467
476
|
totals.outputTokens += call.usage.outputTokens ?? 0;
|
|
@@ -37,10 +37,11 @@ function llmEventNode(id, model, lines) {
|
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
function llmCostNode(id, model, items) {
|
|
40
|
+
function llmCostNode(id, model, items, status = 'precise') {
|
|
41
41
|
const totalOf = (group) => items.filter(item => item.group === group).reduce((sum, item) => sum + (item.total ?? 0), 0);
|
|
42
42
|
const input = totalOf('input');
|
|
43
43
|
const output = totalOf('output');
|
|
44
|
+
const request = totalOf('request');
|
|
44
45
|
return {
|
|
45
46
|
id,
|
|
46
47
|
kind: 'llm',
|
|
@@ -52,8 +53,9 @@ function llmCostNode(id, model, items) {
|
|
|
52
53
|
modelId: model,
|
|
53
54
|
input,
|
|
54
55
|
output,
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
request,
|
|
57
|
+
total: input + output + request,
|
|
58
|
+
status,
|
|
57
59
|
items
|
|
58
60
|
}
|
|
59
61
|
}
|
|
@@ -280,6 +282,66 @@ describe('findLLMCalls', () => {
|
|
|
280
282
|
]);
|
|
281
283
|
expect(calls[0].originalCost).toBeCloseTo(0.001965, 8);
|
|
282
284
|
});
|
|
285
|
+
it('reads a priced grounding request item without counting it as tokens', () => {
|
|
286
|
+
const items = [
|
|
287
|
+
{ group: 'input', label: 'no_cache', amount: 1032, ppm: 0.3, total: 0.0003096, status: 'ok' },
|
|
288
|
+
{ group: 'output', label: 'text', amount: 793, ppm: 2.5, total: 0.0019825, status: 'ok' },
|
|
289
|
+
{ group: 'request', label: 'grounding_prompt', amount: 1, ppm: 35_000, total: 0.035, status: 'ok' }
|
|
290
|
+
];
|
|
291
|
+
const calls = findLLMCalls({
|
|
292
|
+
kind: 'workflow',
|
|
293
|
+
children: [llmCostNode('grounded', 'gemini-2.5-flash', items)]
|
|
294
|
+
});
|
|
295
|
+
expect(calls[0].incomplete).toBe(false);
|
|
296
|
+
expect(calls[0].originalCost).toBeCloseTo(0.0372921, 8);
|
|
297
|
+
// Grounding bills per request, so it must not inflate token usage.
|
|
298
|
+
expect(calls[0].usage).toEqual({
|
|
299
|
+
inputTokens: 1032,
|
|
300
|
+
cachedInputTokens: 0,
|
|
301
|
+
outputTokens: 793,
|
|
302
|
+
reasoningTokens: 0
|
|
303
|
+
});
|
|
304
|
+
expect(calls[0].lines.find(l => l.type === 'request_grounding_prompt')).toEqual({
|
|
305
|
+
type: 'request_grounding_prompt',
|
|
306
|
+
ppm: 35_000,
|
|
307
|
+
amount: 1,
|
|
308
|
+
total: 0.035
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
it('preserves a priced grounding charge as-charged when re-pricing at costs.yml rates', () => {
|
|
312
|
+
const items = [
|
|
313
|
+
{ group: 'input', label: 'no_cache', amount: 1032, ppm: 0.3, total: 0.0003096, status: 'ok' },
|
|
314
|
+
{ group: 'output', label: 'text', amount: 793, ppm: 2.5, total: 0.0019825, status: 'ok' },
|
|
315
|
+
{ group: 'request', label: 'grounding_prompt', amount: 1, ppm: 35_000, total: 0.035, status: 'ok' }
|
|
316
|
+
];
|
|
317
|
+
const config = {
|
|
318
|
+
models: { 'gemini-2.5-flash': { provider: 'google-vertex', input: 0.3, output: 2.5 } },
|
|
319
|
+
services: {}
|
|
320
|
+
};
|
|
321
|
+
const report = calculateCost({ kind: 'workflow', name: 'w', children: [llmCostNode('grounded', 'gemini-2.5-flash', items)] }, config);
|
|
322
|
+
// grounding has no costs.yml line rate, so it degrades to its as-charged total, not $0.
|
|
323
|
+
expect(report.llmAdjustedCost).toBeCloseTo(0.0372921, 8);
|
|
324
|
+
});
|
|
325
|
+
it('flags an unrated grounding charge as incomplete instead of a silent $0', () => {
|
|
326
|
+
const items = [
|
|
327
|
+
{ group: 'input', label: 'no_cache', amount: 1000, ppm: 1, total: 0.001, status: 'ok' },
|
|
328
|
+
{ group: 'output', label: null, amount: 500, ppm: 5, total: 0.0025, status: 'ok' },
|
|
329
|
+
{ group: 'request', label: 'grounding', amount: 3, ppm: null, total: null, status: 'missing' }
|
|
330
|
+
];
|
|
331
|
+
const calls = findLLMCalls({
|
|
332
|
+
kind: 'workflow',
|
|
333
|
+
children: [llmCostNode('unrated', 'gemini-9-ultra', items, 'incomplete')]
|
|
334
|
+
});
|
|
335
|
+
expect(calls[0].incomplete).toBe(true);
|
|
336
|
+
// The unrated line still shows up (as $0), but the call carries the signal.
|
|
337
|
+
expect(calls[0].lines.find(l => l.type === 'request_grounding')).toEqual({
|
|
338
|
+
type: 'request_grounding',
|
|
339
|
+
ppm: 0,
|
|
340
|
+
amount: 3,
|
|
341
|
+
total: 0
|
|
342
|
+
});
|
|
343
|
+
expect(calls[0].usage).toMatchObject({ inputTokens: 1000, outputTokens: 500 });
|
|
344
|
+
});
|
|
283
345
|
it('reads normalized usage without interpreting token totals as cost', () => {
|
|
284
346
|
const items = [
|
|
285
347
|
{ group: 'input', label: null, amount: 100 },
|
|
@@ -303,7 +365,9 @@ describe('findLLMCalls', () => {
|
|
|
303
365
|
outputTokens: 110,
|
|
304
366
|
reasoningTokens: 70
|
|
305
367
|
},
|
|
306
|
-
originalCost: 0
|
|
368
|
+
originalCost: 0,
|
|
369
|
+
// Usage-only means cost computation produced no priced result at all.
|
|
370
|
+
incomplete: true
|
|
307
371
|
});
|
|
308
372
|
expect(calls[0].lines).toEqual([
|
|
309
373
|
{ type: 'input', ppm: 0, amount: 100, total: 0 },
|
package/dist/types/cost.d.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface LLMCall {
|
|
|
51
51
|
usage: TokenUsage;
|
|
52
52
|
originalCost: number;
|
|
53
53
|
lines: LLMUsageLine[];
|
|
54
|
+
incomplete: boolean;
|
|
54
55
|
}
|
|
55
56
|
export interface HTTPCall {
|
|
56
57
|
stepName: string;
|
|
@@ -109,6 +110,7 @@ export interface LLMCostResult {
|
|
|
109
110
|
reasoning: number;
|
|
110
111
|
originalCost: number;
|
|
111
112
|
adjustedCost: number;
|
|
113
|
+
incomplete: boolean;
|
|
112
114
|
}
|
|
113
115
|
export interface ServiceCostResult {
|
|
114
116
|
step: string;
|
|
@@ -155,6 +157,7 @@ export interface LLMModelSummary {
|
|
|
155
157
|
count: number;
|
|
156
158
|
originalCost: number;
|
|
157
159
|
adjustedCost: number;
|
|
160
|
+
incomplete: boolean;
|
|
158
161
|
}
|
|
159
162
|
export interface HostSummary {
|
|
160
163
|
host: string;
|
|
@@ -25,15 +25,23 @@ function formatCurrency(amount) {
|
|
|
25
25
|
function pluralize(count, singular) {
|
|
26
26
|
return count === 1 ? `1 ${singular}` : `${count} ${singular}s`;
|
|
27
27
|
}
|
|
28
|
+
// Marks a figure whose call had a real but unpriced charge (e.g. unrated
|
|
29
|
+
// grounding), so a $0.00 line reads as "unpriced" rather than a clean zero.
|
|
30
|
+
const INCOMPLETE_MARKER = '*';
|
|
31
|
+
const INCOMPLETE_FOOTNOTE = '* cost incomplete (unpriced charge such as grounding, or missing usage); the total understates the bill';
|
|
32
|
+
function markIncomplete(text, incomplete) {
|
|
33
|
+
return incomplete ? `${text} ${INCOMPLETE_MARKER}` : text;
|
|
34
|
+
}
|
|
28
35
|
export function parseCostData(report) {
|
|
29
36
|
const byModel = {};
|
|
30
37
|
for (const r of report.llmCalls) {
|
|
31
38
|
if (!byModel[r.model]) {
|
|
32
|
-
byModel[r.model] = { count: 0, originalCost: 0, adjustedCost: 0 };
|
|
39
|
+
byModel[r.model] = { count: 0, originalCost: 0, adjustedCost: 0, incomplete: false };
|
|
33
40
|
}
|
|
34
41
|
byModel[r.model].count++;
|
|
35
42
|
byModel[r.model].originalCost += r.originalCost;
|
|
36
43
|
byModel[r.model].adjustedCost += r.adjustedCost;
|
|
44
|
+
byModel[r.model].incomplete ||= r.incomplete;
|
|
37
45
|
}
|
|
38
46
|
const llmModels = Object.entries(byModel)
|
|
39
47
|
.sort((a, b) => b[1].adjustedCost - a[1].adjustedCost)
|
|
@@ -82,12 +90,13 @@ function formatSummary(data) {
|
|
|
82
90
|
style: { head: ['cyan'] },
|
|
83
91
|
colAligns: ['left', 'right', 'right', 'right']
|
|
84
92
|
});
|
|
93
|
+
const anyIncomplete = data.llmModels.some(m => m.incomplete);
|
|
85
94
|
for (const m of data.llmModels) {
|
|
86
95
|
table.push([
|
|
87
96
|
m.model,
|
|
88
97
|
pluralize(m.count, 'call'),
|
|
89
|
-
formatCurrency(m.originalCost),
|
|
90
|
-
formatCurrency(m.adjustedCost)
|
|
98
|
+
markIncomplete(formatCurrency(m.originalCost), m.incomplete),
|
|
99
|
+
markIncomplete(formatCurrency(m.adjustedCost), m.incomplete)
|
|
91
100
|
]);
|
|
92
101
|
}
|
|
93
102
|
table.push([
|
|
@@ -98,6 +107,9 @@ function formatSummary(data) {
|
|
|
98
107
|
]);
|
|
99
108
|
lines.push('LLM Costs:');
|
|
100
109
|
lines.push(table.toString());
|
|
110
|
+
if (anyIncomplete) {
|
|
111
|
+
lines.push(INCOMPLETE_FOOTNOTE);
|
|
112
|
+
}
|
|
101
113
|
lines.push('');
|
|
102
114
|
}
|
|
103
115
|
if (data.hosts.length > 0) {
|
|
@@ -146,6 +158,7 @@ function formatVerbose(data) {
|
|
|
146
158
|
style: { head: ['cyan'] },
|
|
147
159
|
colAligns
|
|
148
160
|
});
|
|
161
|
+
const anyIncomplete = data.llmCalls.some(r => r.incomplete);
|
|
149
162
|
for (const r of data.llmCalls) {
|
|
150
163
|
const row = [
|
|
151
164
|
r.step,
|
|
@@ -159,7 +172,7 @@ function formatVerbose(data) {
|
|
|
159
172
|
if (data.verbose.hasReasoning) {
|
|
160
173
|
row.push(formatNumber(r.reasoning));
|
|
161
174
|
}
|
|
162
|
-
row.push(formatCurrency(r.originalCost), formatCurrency(r.adjustedCost));
|
|
175
|
+
row.push(markIncomplete(formatCurrency(r.originalCost), r.incomplete), markIncomplete(formatCurrency(r.adjustedCost), r.incomplete));
|
|
163
176
|
table.push(row);
|
|
164
177
|
}
|
|
165
178
|
const totalRow = [
|
|
@@ -178,6 +191,9 @@ function formatVerbose(data) {
|
|
|
178
191
|
table.push(totalRow);
|
|
179
192
|
lines.push('LLM Calls:');
|
|
180
193
|
lines.push(table.toString());
|
|
194
|
+
if (anyIncomplete) {
|
|
195
|
+
lines.push(INCOMPLETE_FOOTNOTE);
|
|
196
|
+
}
|
|
181
197
|
lines.push('');
|
|
182
198
|
}
|
|
183
199
|
if (data.httpDetails.length > 0) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatCostReport } from '#utils/cost_formatter.js';
|
|
3
|
+
function llmResult(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
step: 'gen',
|
|
6
|
+
model: 'gemini-2.5-flash',
|
|
7
|
+
input: 1032,
|
|
8
|
+
output: 793,
|
|
9
|
+
cached: 0,
|
|
10
|
+
reasoning: 0,
|
|
11
|
+
originalCost: 0.0037,
|
|
12
|
+
adjustedCost: 0.0037,
|
|
13
|
+
incomplete: false,
|
|
14
|
+
...overrides
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function report(llmCalls) {
|
|
18
|
+
const originalCost = llmCalls.reduce((s, c) => s + c.originalCost, 0);
|
|
19
|
+
const adjustedCost = llmCalls.reduce((s, c) => s + c.adjustedCost, 0);
|
|
20
|
+
return {
|
|
21
|
+
traceFile: 'trace.json',
|
|
22
|
+
workflowName: 'w',
|
|
23
|
+
durationMs: 1000,
|
|
24
|
+
llmCalls,
|
|
25
|
+
llmOriginalCost: originalCost,
|
|
26
|
+
llmAdjustedCost: adjustedCost,
|
|
27
|
+
totalInputTokens: 1032,
|
|
28
|
+
totalOutputTokens: 793,
|
|
29
|
+
totalCachedTokens: 0,
|
|
30
|
+
totalReasoningTokens: 0,
|
|
31
|
+
httpCosts: [],
|
|
32
|
+
httpOriginalCost: 0,
|
|
33
|
+
httpAdjustedCost: 0,
|
|
34
|
+
originalTotalCost: originalCost,
|
|
35
|
+
totalCost: adjustedCost
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
describe('formatCostReport incomplete marker', () => {
|
|
39
|
+
it('marks the model row with an unpriced charge and prints the footnote', () => {
|
|
40
|
+
const out = formatCostReport(report([llmResult({ model: 'gemini-2.5-flash', incomplete: true })]));
|
|
41
|
+
const modelRow = out.split('\n').find(line => line.includes('gemini-2.5-flash'));
|
|
42
|
+
expect(modelRow).toContain('$0.0037 *');
|
|
43
|
+
expect(out).toContain('cost incomplete');
|
|
44
|
+
});
|
|
45
|
+
it('omits the marker and footnote when every call is fully priced', () => {
|
|
46
|
+
const out = formatCostReport(report([llmResult()]));
|
|
47
|
+
const modelRow = out.split('\n').find(line => line.includes('gemini-2.5-flash'));
|
|
48
|
+
expect(modelRow).not.toContain('*');
|
|
49
|
+
expect(out).not.toContain('cost incomplete');
|
|
50
|
+
});
|
|
51
|
+
it('marks the flagged call row in the verbose table', () => {
|
|
52
|
+
const out = formatCostReport(report([llmResult({ step: 'grounded', incomplete: true }), llmResult({ step: 'plain' })]), { verbose: true });
|
|
53
|
+
const lines = out.split('\n');
|
|
54
|
+
const groundedRow = lines.find(line => line.includes('grounded'));
|
|
55
|
+
const plainRow = lines.find(line => line.includes('plain'));
|
|
56
|
+
expect(groundedRow).toContain('$0.0037 *');
|
|
57
|
+
expect(plainRow).not.toContain('*');
|
|
58
|
+
expect(out).toContain('cost incomplete');
|
|
59
|
+
});
|
|
60
|
+
});
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/cli",
|
|
3
|
-
"version": "0.12.0",
|
|
3
|
+
"version": "0.12.1-next.69255d7.0",
|
|
4
4
|
"description": "CLI for Output.ai workflow generation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"react": "19.2.5",
|
|
37
37
|
"semver": "7.7.4",
|
|
38
38
|
"undici": "8.9.0",
|
|
39
|
-
"@outputai/credentials": "0.12.0",
|
|
40
|
-
"@outputai/
|
|
41
|
-
"@outputai/
|
|
39
|
+
"@outputai/credentials": "0.12.1-next.69255d7.0",
|
|
40
|
+
"@outputai/evals": "0.12.1-next.69255d7.0",
|
|
41
|
+
"@outputai/llm": "0.12.1-next.69255d7.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/cli-progress": "3.11.6",
|