@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.
@@ -80,7 +80,7 @@ services:
80
80
  condition: service_healthy
81
81
  worker:
82
82
  condition: service_healthy
83
- image: outputai/api:${OUTPUT_API_VERSION:-0.12.0}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.12.1-next.69255d7.0}
84
84
  init: true
85
85
  networks:
86
86
  - main
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.12.0"
2
+ "framework": "0.12.1-next.69255d7.0"
3
3
  }
@@ -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
- total: input + output,
56
- status: 'precise',
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 },
@@ -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
+ });
@@ -1752,5 +1752,5 @@
1752
1752
  ]
1753
1753
  }
1754
1754
  },
1755
- "version": "0.12.0"
1755
+ "version": "0.12.1-next.69255d7.0"
1756
1756
  }
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/llm": "0.12.0",
41
- "@outputai/evals": "0.12.0"
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",