@outputai/cli 0.11.1-next.b3beef1.0 → 0.11.1-next.da26845.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.11.1-next.b3beef1.0}
83
+ image: outputai/api:${OUTPUT_API_VERSION:-0.11.1-next.da26845.0}
84
84
  init: true
85
85
  networks:
86
86
  - main
@@ -1,3 +1,3 @@
1
1
  {
2
- "framework": "0.11.1-next.b3beef1.0"
2
+ "framework": "0.11.1-next.da26845.0"
3
3
  }
@@ -15,6 +15,8 @@ function lineRate(type, pricing) {
15
15
  const rates = {
16
16
  input: pricing.input ?? 0,
17
17
  input_cached: pricing.cached_input ?? 0,
18
+ input_cache_read: pricing.cached_input ?? pricing.input ?? 0,
19
+ input_cache_write: pricing.input ?? 0,
18
20
  output: pricing.output ?? 0,
19
21
  reasoning: pricing.reasoning ?? pricing.output ?? 0
20
22
  };
@@ -42,6 +44,81 @@ function eventTokenUsage(lines) {
42
44
  reasoningTokens: sumOf('reasoning')
43
45
  };
44
46
  }
47
+ function normalizedItemType(item) {
48
+ if (item.group === 'input') {
49
+ if (item.label === null || item.label === 'no_cache') {
50
+ return 'input';
51
+ }
52
+ if (item.label === 'cache_read') {
53
+ return 'input_cache_read';
54
+ }
55
+ if (item.label === 'cache_write') {
56
+ return 'input_cache_write';
57
+ }
58
+ }
59
+ if (item.group === 'output') {
60
+ if (item.label === null || item.label === 'text') {
61
+ return 'output';
62
+ }
63
+ if (item.label === 'reasoning') {
64
+ return 'reasoning';
65
+ }
66
+ }
67
+ return item.label ? `${item.group}_${item.label}` : item.group;
68
+ }
69
+ function normalizedItemTokenUsage(items) {
70
+ const sumOf = (group, labels) => items
71
+ .filter(item => item.group === group && (!labels || labels.includes(item.label)))
72
+ .reduce((sum, item) => sum + item.amount, 0);
73
+ return {
74
+ inputTokens: sumOf('input'),
75
+ cachedInputTokens: sumOf('input', ['cache_read']),
76
+ outputTokens: sumOf('output', [null, 'output', 'text']),
77
+ reasoningTokens: sumOf('output', ['reasoning'])
78
+ };
79
+ }
80
+ function parseLegacyLLMUsageEvent(node, stepName, event) {
81
+ return {
82
+ stepName: stepName || node.name || 'unknown',
83
+ llmName: node.name || 'llm',
84
+ model: event.modelId || 'unknown',
85
+ usage: eventTokenUsage(event.usage ?? []),
86
+ originalCost: event.total,
87
+ lines: event.usage ?? []
88
+ };
89
+ }
90
+ function parseNormalizedLLMUsage(node, stepName, event) {
91
+ const lines = event.items.map(item => ({
92
+ type: normalizedItemType(item),
93
+ ppm: 0,
94
+ amount: item.amount,
95
+ total: 0
96
+ }));
97
+ return {
98
+ stepName: stepName || node.name || 'unknown',
99
+ llmName: node.name || 'llm',
100
+ model: event.modelId || 'unknown',
101
+ usage: normalizedItemTokenUsage(event.items),
102
+ originalCost: 0,
103
+ lines
104
+ };
105
+ }
106
+ function parseLLMCostEvent(node, stepName, event) {
107
+ const lines = event.items.map(item => ({
108
+ type: normalizedItemType(item),
109
+ ppm: item.ppm ?? 0,
110
+ amount: item.amount,
111
+ total: item.total ?? 0
112
+ }));
113
+ return {
114
+ stepName: stepName || node.name || 'unknown',
115
+ llmName: node.name || 'llm',
116
+ model: event.modelId || 'unknown',
117
+ usage: normalizedItemTokenUsage(event.items),
118
+ originalCost: event.total ?? 0,
119
+ lines
120
+ };
121
+ }
45
122
  export function extractValue(obj, path) {
46
123
  if (!path || !obj) {
47
124
  return obj;
@@ -106,20 +183,20 @@ function findCalls(node, match, extract, parentStepName = null, seenIds = new Se
106
183
  }
107
184
  return calls;
108
185
  }
109
- // Only nodes carrying an llm:usage event are priced the event holds the
110
- // as-charged cost and the per-token-type amounts. Traces from SDKs that
111
- // predate cost attributes report no LLM costs.
186
+ // Prefer generation cost, then normalized generation usage, then legacy usage.
112
187
  export function findLLMCalls(node, parentStepName = null, seenIds = new Set()) {
113
- return findCalls(node, n => n.kind === 'llm' && !!n.attributes?.['llm:usage'], (n, stepName) => {
114
- const event = n.attributes['llm:usage'];
115
- return {
116
- stepName: stepName || n.name || 'unknown',
117
- llmName: n.name || 'llm',
118
- model: event.modelId || 'unknown',
119
- usage: eventTokenUsage(event.usage ?? []),
120
- originalCost: event.total,
121
- lines: event.usage ?? []
122
- };
188
+ return findCalls(node, n => n.kind === 'llm' && !!(n.attributes?.['llm:generation:cost'] ||
189
+ n.attributes?.['llm:generation:usage'] ||
190
+ n.attributes?.['llm:usage']), (n, stepName) => {
191
+ const cost = n.attributes?.['llm:generation:cost'];
192
+ if (cost) {
193
+ return parseLLMCostEvent(n, stepName, cost);
194
+ }
195
+ const usage = n.attributes?.['llm:generation:usage'];
196
+ if (usage) {
197
+ return parseNormalizedLLMUsage(n, stepName, usage);
198
+ }
199
+ return parseLegacyLLMUsageEvent(n, stepName, n.attributes['llm:usage']);
123
200
  }, parentStepName, seenIds);
124
201
  }
125
202
  export function findHTTPCalls(node, parentStepName = null, seenIds = new Set()) {
@@ -360,7 +437,9 @@ function findModelPricing(model, models) {
360
437
  if (models[model]) {
361
438
  return models[model];
362
439
  }
363
- return Object.entries(models).find(([key]) => model.startsWith(key))?.[1];
440
+ return Object.entries(models)
441
+ .filter(([key]) => model.startsWith(key))
442
+ .sort(([left], [right]) => right.length - left.length)[0]?.[1];
364
443
  }
365
444
  function aggregateLLMCosts(llmCalls, config) {
366
445
  const results = [];
@@ -37,6 +37,50 @@ function llmEventNode(id, model, lines) {
37
37
  }
38
38
  };
39
39
  }
40
+ function llmCostNode(id, model, items) {
41
+ const totalOf = (group) => items.filter(item => item.group === group).reduce((sum, item) => sum + (item.total ?? 0), 0);
42
+ const input = totalOf('input');
43
+ const output = totalOf('output');
44
+ return {
45
+ id,
46
+ kind: 'llm',
47
+ name: 'gen',
48
+ attributes: {
49
+ 'llm:generation:cost': {
50
+ type: 'llm:generation:cost',
51
+ providerId: 'test-provider',
52
+ modelId: model,
53
+ input,
54
+ output,
55
+ total: input + output,
56
+ status: 'precise',
57
+ items
58
+ }
59
+ }
60
+ };
61
+ }
62
+ function llmUsageNode(id, model, items) {
63
+ const totalOf = (group) => items.filter(item => item.group === group).reduce((sum, item) => sum + item.amount, 0);
64
+ const input = totalOf('input');
65
+ const output = totalOf('output');
66
+ return {
67
+ id,
68
+ kind: 'llm',
69
+ name: 'gen',
70
+ attributes: {
71
+ 'llm:generation:usage': {
72
+ type: 'llm:generation:usage',
73
+ providerId: 'test-provider',
74
+ modelId: model,
75
+ input,
76
+ output,
77
+ total: input + output,
78
+ status: 'complete',
79
+ items
80
+ }
81
+ }
82
+ };
83
+ }
40
84
  function httpEventNode(id, url, method, total, output = { status: 200, body: {} }, inputBody) {
41
85
  return {
42
86
  id,
@@ -182,7 +226,109 @@ describe('findLLMCalls', () => {
182
226
  expect(calls[0].usage.outputTokens).toBe(7275);
183
227
  expect(calls[0].originalCost).toBeCloseTo(0.35525, 5);
184
228
  });
185
- it('ignores llm nodes without a llm:usage event', () => {
229
+ it('does not interpret transitional component names in legacy llm:usage events', () => {
230
+ const node = llmEventNode('transitional-components', 'legacy-model', [
231
+ { type: 'input', ppm: 1, amount: 10, total: 0.00001 },
232
+ { type: 'input_cached', ppm: 0.1, amount: 20, total: 0.000002 },
233
+ { type: 'output', ppm: 5, amount: 30, total: 0.00015 },
234
+ { type: 'reasoning', ppm: 5, amount: 40, total: 0.0002 },
235
+ { type: 'input_cache_read', ppm: 0.1, amount: 100, total: 0.00001 },
236
+ { type: 'input_cache_write', ppm: 1.25, amount: 200, total: 0.00025 },
237
+ { type: 'output_text', ppm: 5, amount: 300, total: 0.0015 },
238
+ { type: 'output_reasoning', ppm: 5, amount: 400, total: 0.002 }
239
+ ]);
240
+ const calls = findLLMCalls({ kind: 'workflow', children: [node] });
241
+ expect(calls[0].usage).toEqual({
242
+ inputTokens: 30,
243
+ cachedInputTokens: 20,
244
+ outputTokens: 30,
245
+ reasoningTokens: 40
246
+ });
247
+ });
248
+ it('reads every supported item from an llm:generation:cost attribute', () => {
249
+ const items = [
250
+ { group: 'input', label: null, amount: 100, ppm: 1, total: 0.0001, status: 'ok' },
251
+ { group: 'input', label: 'no_cache', amount: 200, ppm: 1, total: 0.0002, status: 'ok' },
252
+ { group: 'input', label: 'cache_write', amount: 300, ppm: 1.25, total: 0.000375, status: 'ok' },
253
+ { group: 'input', label: 'cache_read', amount: 400, ppm: 0.1, total: 0.00004, status: 'ok' },
254
+ { group: 'output', label: null, amount: 50, ppm: 5, total: 0.00025, status: 'ok' },
255
+ { group: 'output', label: 'text', amount: 60, ppm: 5, total: 0.0003, status: 'ok' },
256
+ { group: 'output', label: 'reasoning', amount: 70, ppm: 10, total: 0.0007, status: 'ok' }
257
+ ];
258
+ const calls = findLLMCalls({
259
+ kind: 'workflow',
260
+ children: [llmCostNode('llm-cost', 'new-model', items)]
261
+ });
262
+ expect(calls).toHaveLength(1);
263
+ expect(calls[0]).toMatchObject({
264
+ model: 'new-model',
265
+ usage: {
266
+ inputTokens: 1000,
267
+ cachedInputTokens: 400,
268
+ outputTokens: 110,
269
+ reasoningTokens: 70
270
+ }
271
+ });
272
+ expect(calls[0].lines.map(line => line.type)).toEqual([
273
+ 'input',
274
+ 'input',
275
+ 'input_cache_write',
276
+ 'input_cache_read',
277
+ 'output',
278
+ 'output',
279
+ 'reasoning'
280
+ ]);
281
+ expect(calls[0].originalCost).toBeCloseTo(0.001965, 8);
282
+ });
283
+ it('reads normalized usage without interpreting token totals as cost', () => {
284
+ const items = [
285
+ { group: 'input', label: null, amount: 100 },
286
+ { group: 'input', label: 'no_cache', amount: 200 },
287
+ { group: 'input', label: 'cache_write', amount: 300 },
288
+ { group: 'input', label: 'cache_read', amount: 400 },
289
+ { group: 'output', label: null, amount: 50 },
290
+ { group: 'output', label: 'text', amount: 60 },
291
+ { group: 'output', label: 'reasoning', amount: 70 }
292
+ ];
293
+ const calls = findLLMCalls({
294
+ kind: 'workflow',
295
+ children: [llmUsageNode('llm-usage', 'new-model', items)]
296
+ });
297
+ expect(calls).toHaveLength(1);
298
+ expect(calls[0]).toMatchObject({
299
+ model: 'new-model',
300
+ usage: {
301
+ inputTokens: 1000,
302
+ cachedInputTokens: 400,
303
+ outputTokens: 110,
304
+ reasoningTokens: 70
305
+ },
306
+ originalCost: 0
307
+ });
308
+ expect(calls[0].lines).toEqual([
309
+ { type: 'input', ppm: 0, amount: 100, total: 0 },
310
+ { type: 'input', ppm: 0, amount: 200, total: 0 },
311
+ { type: 'input_cache_write', ppm: 0, amount: 300, total: 0 },
312
+ { type: 'input_cache_read', ppm: 0, amount: 400, total: 0 },
313
+ { type: 'output', ppm: 0, amount: 50, total: 0 },
314
+ { type: 'output', ppm: 0, amount: 60, total: 0 },
315
+ { type: 'reasoning', ppm: 0, amount: 70, total: 0 }
316
+ ]);
317
+ });
318
+ it('prefers generation cost when both generation attributes are present', () => {
319
+ const node = llmCostNode('both', 'new-model', [
320
+ { group: 'input', label: null, amount: 100, ppm: 1, total: 0.0001, status: 'ok' }
321
+ ]);
322
+ node.attributes['llm:generation:usage'] = llmUsageNode('usage', 'usage-model', [
323
+ { group: 'input', label: null, amount: 10 }
324
+ ])
325
+ .attributes['llm:generation:usage'];
326
+ const calls = findLLMCalls({ kind: 'workflow', children: [node] });
327
+ expect(calls).toHaveLength(1);
328
+ expect(calls[0].model).toBe('new-model');
329
+ expect(calls[0].originalCost).toBe(0.0001);
330
+ });
331
+ it('ignores llm nodes without a cost event', () => {
186
332
  const trace = {
187
333
  kind: 'workflow',
188
334
  name: 'test',
@@ -314,20 +460,67 @@ describe('calculateCost', () => {
314
460
  const report = calculateCost(duplicateTrace, testConfig, 'test.json');
315
461
  expect(report.llmCalls).toHaveLength(1);
316
462
  });
317
- it('matches versioned model names by prefix', () => {
463
+ it('uses the longest matching prefix for versioned model names', () => {
318
464
  const trace = {
319
465
  kind: 'workflow',
320
466
  name: 'test',
321
467
  children: [
322
- llmEventNode('llm-1', 'claude-sonnet-4-5-20250514', llmLines(1000, 3, 500, 15))
468
+ llmEventNode('llm-1', 'gpt-4.1-mini-20250514', llmLines(1_000_000, 9, 1_000_000, 9))
323
469
  ]
324
470
  };
325
- const report = calculateCost(trace, testConfig, 'test.json');
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);
471
+ const config = {
472
+ ...testConfig,
473
+ models: {
474
+ 'gpt-4.1': { provider: 'openai', input: 2, output: 8 },
475
+ 'gpt-4.1-mini': { provider: 'openai', input: 0.4, output: 1.6 }
476
+ }
477
+ };
478
+ const report = calculateCost(trace, config, 'test.json');
479
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(2, 8);
328
480
  });
329
481
  });
330
482
  describe('event-driven LLM costs (original vs adjusted)', () => {
483
+ it('re-prices normalized usage when generation cost is absent', () => {
484
+ const trace = {
485
+ kind: 'workflow',
486
+ name: 'test',
487
+ children: [llmUsageNode('usage-only', 'claude-sonnet-4-5', [
488
+ { group: 'input', label: 'no_cache', amount: 1_000_000 },
489
+ { group: 'input', label: 'cache_read', amount: 1_000_000 },
490
+ { group: 'input', label: 'cache_write', amount: 1_000_000 },
491
+ { group: 'output', label: 'text', amount: 1_000_000 },
492
+ { group: 'output', label: 'reasoning', amount: 1_000_000 }
493
+ ])]
494
+ };
495
+ const report = calculateCost(trace, testConfig, 'test.json');
496
+ expect(report.llmCalls[0]).toMatchObject({
497
+ input: 3_000_000,
498
+ cached: 1_000_000,
499
+ output: 1_000_000,
500
+ reasoning: 1_000_000,
501
+ originalCost: 0,
502
+ adjustedCost: 36.3
503
+ });
504
+ expect(report.llmOriginalCost).toBe(0);
505
+ expect(report.llmAdjustedCost).toBeCloseTo(36.3, 8);
506
+ });
507
+ it('falls back to input pricing for normalized cache reads', () => {
508
+ const trace = {
509
+ kind: 'workflow',
510
+ name: 'test',
511
+ children: [llmUsageNode('cache-read', 'cache-model', [
512
+ { group: 'input', label: 'cache_read', amount: 1_000_000 }
513
+ ])]
514
+ };
515
+ const config = {
516
+ ...testConfig,
517
+ models: {
518
+ 'cache-model': { provider: 'test', input: 2, output: 10 }
519
+ }
520
+ };
521
+ const report = calculateCost(trace, config, 'test.json');
522
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(2, 8);
523
+ });
331
524
  it('matches original when costs.yml rate equals the event rate', () => {
332
525
  // haiku event priced at the same rate as testConfig (input 1 / output 5)
333
526
  const trace = {
@@ -362,19 +555,18 @@ describe('event-driven LLM costs (original vs adjusted)', () => {
362
555
  expect(report.llmCalls[0].originalCost).toBeCloseTo(0.35525, 5);
363
556
  expect(report.llmCalls[0].adjustedCost).toBeCloseTo(0.35525, 5);
364
557
  });
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
- ];
558
+ it('re-prices normalized cache writes at the configured input rate', () => {
370
559
  const trace = {
371
560
  kind: 'workflow',
372
561
  name: 'test',
373
- children: [llmEventNode('cw', 'claude-haiku-4-5', lines)]
562
+ children: [llmCostNode('cw', 'claude-haiku-4-5', [
563
+ { group: 'input', label: 'no_cache', amount: 1_000_000, ppm: 1, total: 1, status: 'ok' },
564
+ { group: 'input', label: 'cache_write', amount: 200_000, ppm: 1.25, total: 0.25, status: 'ok' },
565
+ { group: 'output', label: 'text', amount: 1_000_000, ppm: 5, total: 5, status: 'ok' }
566
+ ])]
374
567
  };
375
568
  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);
569
+ expect(report.llmCalls[0].adjustedCost).toBeCloseTo(6.2, 5);
378
570
  expect(report.llmCalls[0].originalCost).toBeCloseTo(6.25, 5);
379
571
  });
380
572
  it('reports inputTokens including cached tokens for event traces', () => {
@@ -3,7 +3,7 @@ provider: anthropic
3
3
  # current as of 2026-05-04 — run output-dev-model-selection for the latest
4
4
  model: claude-sonnet-4-6
5
5
  temperature: 0.3
6
- maxTokens: 4096
6
+ maxOutputTokens: 4096
7
7
  ---
8
8
 
9
9
  <system>
@@ -178,7 +178,7 @@ export const evaluateQuality = evaluator( {
178
178
 
179
179
  Create prompt files in `prompts/` following the pattern:
180
180
  - File naming: `promptName@v1.prompt`
181
- - Include YAML frontmatter with provider, model, temperature, and maxTokens
181
+ - Include YAML frontmatter with provider, model, temperature, and maxOutputTokens
182
182
  - Use LiquidJS syntax for variables: `{{ variableName }}`
183
183
  - Start the rendered body with a role tag for message mode, or with plain text for instruction mode
184
184
 
@@ -193,7 +193,7 @@ provider: anthropic
193
193
  # current as of 2026-05-04 — run output-dev-model-selection for the latest
194
194
  model: claude-sonnet-4-6
195
195
  temperature: 0.3
196
- maxTokens: 1024
196
+ maxOutputTokens: 1024
197
197
  ---
198
198
 
199
199
  <system>
@@ -3,7 +3,7 @@ provider: anthropic
3
3
  # current as of 2026-05-04 — run output-dev-model-selection for the latest
4
4
  model: claude-sonnet-4-6
5
5
  temperature: 0.3
6
- maxTokens: 4096
6
+ maxOutputTokens: 4096
7
7
  ---
8
8
 
9
9
  <system>
@@ -13,8 +13,7 @@ export interface TokenUsage {
13
13
  cachedInputTokens?: number;
14
14
  reasoningTokens?: number;
15
15
  }
16
- import type { LLMUsageEvent as LiveLLMUsageEvent } from '@outputai/llm';
17
- export type LLMUsageEvent = Omit<LiveLLMUsageEvent, 'addUsage'>;
16
+ import type { LLMGenerationCost, LLMGenerationUsage, LLMUsageEvent } from '@outputai/llm';
18
17
  export type LLMUsageLine = LLMUsageEvent['usage'][number];
19
18
  export interface HTTPCostEvent {
20
19
  type: 'http:request:cost';
@@ -29,6 +28,8 @@ export interface HTTPCountEvent {
29
28
  }
30
29
  export interface NodeAttributes {
31
30
  'llm:usage'?: LLMUsageEvent;
31
+ 'llm:generation:usage'?: LLMGenerationUsage;
32
+ 'llm:generation:cost'?: LLMGenerationCost;
32
33
  'http:request:cost'?: HTTPCostEvent;
33
34
  'http:request:count'?: HTTPCountEvent;
34
35
  }
@@ -1752,5 +1752,5 @@
1752
1752
  ]
1753
1753
  }
1754
1754
  },
1755
- "version": "0.11.1-next.b3beef1.0"
1755
+ "version": "0.11.1-next.da26845.0"
1756
1756
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/cli",
3
- "version": "0.11.1-next.b3beef1.0",
3
+ "version": "0.11.1-next.da26845.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.11.1-next.b3beef1.0",
40
- "@outputai/evals": "0.11.1-next.b3beef1.0",
41
- "@outputai/llm": "0.11.1-next.b3beef1.0"
39
+ "@outputai/credentials": "0.11.1-next.da26845.0",
40
+ "@outputai/evals": "0.11.1-next.da26845.0",
41
+ "@outputai/llm": "0.11.1-next.da26845.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/cli-progress": "3.11.6",