@outputai/llm 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/llm",
3
- "version": "0.12.0",
3
+ "version": "0.12.1-next.69255d7.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -21,7 +21,7 @@
21
21
  "gray-matter": "4.0.3",
22
22
  "liquidjs": "10.27.2",
23
23
  "undici": "8.9.0",
24
- "@outputai/core": "0.12.0"
24
+ "@outputai/core": "0.12.1-next.69255d7.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@ai-sdk/amazon-bedrock": "5.0.57",
package/src/index.d.ts CHANGED
@@ -341,9 +341,9 @@ export type ExtractedSource =
341
341
 
342
342
  export type LLMGenerationUsageStatus = 'complete' | 'incomplete';
343
343
 
344
- /** Token usage reported for one normalized LLM usage component. */
344
+ /** Token usage reported for one normalized LLM usage component. `request` counts billable requests, not tokens. */
345
345
  export interface LLMGenerationUsageItem {
346
- group: 'input' | 'output';
346
+ group: 'input' | 'output' | 'request';
347
347
  label: string | null;
348
348
  amount: number;
349
349
  }
@@ -386,7 +386,7 @@ export type LLMGenerationCostStatus = 'precise' | 'imprecise' | 'incomplete';
386
386
 
387
387
  /** Cost calculated for one normalized LLM usage item. */
388
388
  export interface LLMGenerationCostItem {
389
- group: 'input' | 'output';
389
+ group: 'input' | 'output' | 'request';
390
390
  label: string | null;
391
391
  amount: number;
392
392
  ppm: number | null;
@@ -401,6 +401,7 @@ export interface LLMGenerationCost extends BaseAttribute {
401
401
  modelId: string;
402
402
  input: number | null;
403
403
  output: number | null;
404
+ request: number | null;
404
405
  total: number | null;
405
406
  status: LLMGenerationCostStatus;
406
407
  items: LLMGenerationCostItem[];
package/src/utils/cost.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { fetchModelsPricing } from './models_pricing.js';
2
2
  import { Tracing } from '@outputai/core/sdk/runtime';
3
3
  import { LLMGenerationUsage, LLMGenerationUsageItem } from './usage.js';
4
+ import { GroundingPpmMap } from './grounding.js';
4
5
  import { Logger } from '@outputai/core';
5
6
  import Decimal from 'decimal.js';
6
7
 
@@ -41,6 +42,7 @@ export class LLMGenerationCost extends Tracing.Attribute.BaseAttribute {
41
42
  modelId;
42
43
  input = null;
43
44
  output = null;
45
+ request = null;
44
46
  total = null;
45
47
  status = LLMGenerationCost.Status.INCOMPLETE;
46
48
  items = [];
@@ -60,16 +62,15 @@ export class LLMGenerationCost extends Tracing.Attribute.BaseAttribute {
60
62
  } else {
61
63
  this.status = LLMGenerationCost.Status.PRECISE;
62
64
  }
63
- const inputItems = items.filter( p => p.group === LLMGenerationUsageItem.Group.INPUT && exists( p.total ) );
64
- if ( inputItems.length > 0 ) {
65
- this.input = inputItems.reduce( ( s, p ) => s.add( p.total ), Decimal( 0 ) ).toNumber();
66
- }
67
- const outputItems = items.filter( p => p.group === LLMGenerationUsageItem.Group.OUTPUT && exists( p.total ) );
68
- if ( outputItems.length > 0 ) {
69
- this.output = outputItems.reduce( ( s, p ) => s.add( p.total ), Decimal( 0 ) ).toNumber();
70
- }
71
- if ( exists( this.input ) || exists( this.output ) ) {
72
- this.total = Decimal( this.input ?? 0 ).add( this.output ?? 0 ).toNumber();
65
+ const sumGroup = group => {
66
+ const groupItems = items.filter( p => p.group === group && exists( p.total ) );
67
+ return groupItems.length > 0 ? groupItems.reduce( ( s, p ) => s.add( p.total ), Decimal( 0 ) ).toNumber() : null;
68
+ };
69
+ this.input = sumGroup( LLMGenerationUsageItem.Group.INPUT );
70
+ this.output = sumGroup( LLMGenerationUsageItem.Group.OUTPUT );
71
+ this.request = sumGroup( LLMGenerationUsageItem.Group.REQUEST );
72
+ if ( exists( this.input ) || exists( this.output ) || exists( this.request ) ) {
73
+ this.total = Decimal( this.input ?? 0 ).add( this.output ?? 0 ).add( this.request ?? 0 ).toNumber();
73
74
  }
74
75
  }
75
76
  }
@@ -86,6 +87,10 @@ const resolveValue = values => {
86
87
  };
87
88
 
88
89
  const resolvePrice = ( { group, label, pricing } ) => {
90
+ // Per-request charges are never token-priced: models.dev has no rate for them.
91
+ if ( group === LLMGenerationUsageItem.Group.REQUEST ) {
92
+ return resolveValue( [ GroundingPpmMap.get( label ) ] );
93
+ }
89
94
  if ( !pricing ) {
90
95
  return resolveValue( [] );
91
96
  }
@@ -133,5 +138,11 @@ export const calculateCosts = async usage => {
133
138
  return new LLMGenerationCostItem( group, label, amount, ppm, total, status );
134
139
  } );
135
140
 
141
+ const unrated = items.some( v =>
142
+ v.group === LLMGenerationUsageItem.Group.REQUEST && v.status === LLMGenerationCostItem.Status.MISSING );
143
+ if ( unrated ) {
144
+ Logger.warn( 'Grounded call with no grounding rate for model', { namespace: 'LLM', modelId, providerId } );
145
+ }
146
+
136
147
  return new LLMGenerationCost( modelId, providerId, items, usage.status );
137
148
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Grounding with Google Search is billed per request, not per token, and models.dev carries no
3
+ * per-request rate, so the table lives here. Gemini 3 bills per Grounding Query; Gemini 2.x bills
4
+ * per Grounding Prompt, once, regardless of query count. Different units, so the family decides
5
+ * both the label and the amount. Rates are price-per-million to match `ppm` semantics.
6
+ * Verified 2026-09-01: https://cloud.google.com/vertex-ai/generative-ai/pricing
7
+ */
8
+ const UNITS = [
9
+ { match: /^gemini-3/, label: 'grounding_query', ppm: 14_000, perQuery: true },
10
+ { match: /^gemini-2/, label: 'grounding_prompt', ppm: 35_000, perQuery: false }
11
+ ];
12
+
13
+ /**
14
+ * Unknown family: still record the quantity so the call is visibly unpriced rather than silently
15
+ * token-only. No rate resolves for this label, so the item lands MISSING and the cost attribute
16
+ * goes INCOMPLETE.
17
+ */
18
+ export const GROUNDING_UNKNOWN_LABEL = 'grounding';
19
+
20
+ export const GroundingPpmMap = new Map( UNITS.map( u => [ u.label, u.ppm ] ) );
21
+
22
+ /**
23
+ * Extracts the billable grounding quantity from provider metadata.
24
+ *
25
+ * Counts web-search grounding only. `groundingMetadata` also carries `imageSearchQueries` and
26
+ * `retrievalQueries`, which bill on their own terms; neither appears in current traffic. Extend
27
+ * here if image or retrieval grounding is enabled.
28
+ *
29
+ * @param {string} modelId - Id of the model that produced the response
30
+ * @param {object} [providerMetadata] - AI SDK provider metadata of a single step
31
+ * @returns {{ label: string, amount: number } | null} Grounding label and amount, or null when ungrounded
32
+ */
33
+ export const parseGroundingUsage = ( modelId, providerMetadata ) => {
34
+ const meta = providerMetadata?.vertex ?? providerMetadata?.google;
35
+ const queries = meta?.groundingMetadata?.webSearchQueries;
36
+ if ( !Array.isArray( queries ) || queries.length === 0 ) {
37
+ return null;
38
+ }
39
+
40
+ const unit = UNITS.find( u => u.match.test( modelId ) );
41
+ if ( !unit ) {
42
+ return { label: GROUNDING_UNKNOWN_LABEL, amount: queries.length };
43
+ }
44
+
45
+ return {
46
+ label: unit.label,
47
+ amount: unit.perQuery ? queries.length : 1
48
+ };
49
+ };
@@ -1,12 +1,13 @@
1
1
  import Decimal from 'decimal.js';
2
2
  import { Tracing } from '@outputai/core/sdk/runtime';
3
+ import { parseGroundingUsage } from './grounding.js';
3
4
 
4
5
  const exists = v => Number.isSafeInteger( v ) && v >= 0;
5
6
 
6
7
  const safeSum = ( ...values ) => values.filter( exists ).reduce( ( t, v ) => t + v, 0 );
7
8
 
8
9
  export class LLMGenerationUsageItem {
9
- static Group = { INPUT: 'input', OUTPUT: 'output' };
10
+ static Group = { INPUT: 'input', OUTPUT: 'output', REQUEST: 'request' };
10
11
 
11
12
  group;
12
13
  label;
@@ -65,10 +66,11 @@ export class LLMGenerationUsage extends Tracing.Attribute.BaseAttribute {
65
66
  * @param {string} args.prompt.config.provider - Id of the provider
66
67
  * @param {string} args.prompt.config.model - Id of the model
67
68
  * @param {object} args.usage - AI SDK usage with aggregate token counts and optional input/output token details
69
+ * @param {object[]} [args.steps] - AI SDK steps, each with its own `providerMetadata`, used for per-request charges
68
70
  *
69
71
  * @returns {LLMGenerationUsage | null} LLM generation usage with input, output, total and detailed breakdown
70
72
  */
71
- export const parseLLMUsage = ( { prompt, usage } ) => {
73
+ export const parseLLMUsage = ( { prompt, usage, steps } ) => {
72
74
  const { provider: providerId, model: modelId } = prompt.config;
73
75
  const { inputTokens, inputTokenDetails, outputTokens, outputTokenDetails } = usage;
74
76
  const { noCacheTokens, cacheReadTokens, cacheWriteTokens } = inputTokenDetails ?? {};
@@ -113,6 +115,18 @@ export const parseLLMUsage = ( { prompt, usage } ) => {
113
115
  }
114
116
  }
115
117
 
118
+ // Grounding is billed per step, so aggregate across every step rather than only the final one.
119
+ // Per-query families (Gemini 3) return queries.length per step; per-prompt families (Gemini 2.x)
120
+ // return 1 per grounded step. Summing yields total queries and grounded-step count respectively.
121
+ const grounding = ( steps ?? [] )
122
+ .filter( step => step?.providerMetadata )
123
+ .map( step => parseGroundingUsage( modelId, step.providerMetadata ) )
124
+ .filter( Boolean );
125
+ if ( grounding.length > 0 ) {
126
+ const amount = grounding.reduce( ( sum, g ) => sum + g.amount, 0 );
127
+ items.push( new LLMGenerationUsageItem( LLMGenerationUsageItem.Group.REQUEST, grounding[0].label, amount ) );
128
+ }
129
+
116
130
  if ( items.length === 0 ) {
117
131
  return null;
118
132
  }
package/src/utils/wrap.js CHANGED
@@ -31,8 +31,8 @@ const handleError = ( { traceId, error: originalError } ) => {
31
31
  };
32
32
 
33
33
  /** Normalize raw AI SDK usage, calculate cost, attach trace attributes, and emit metering events */
34
- const handleMetering = async ( { traceId, usage: sdkUsage, prompt } ) => {
35
- const usageAttribute = parseLLMUsage( { usage: sdkUsage, prompt } );
34
+ const handleMetering = async ( { traceId, usage: sdkUsage, prompt, steps } ) => {
35
+ const usageAttribute = parseLLMUsage( { usage: sdkUsage, prompt, steps } );
36
36
  if ( !usageAttribute ) {
37
37
  return null;
38
38
  }
@@ -74,7 +74,7 @@ export const wrapGeneration = async ( { name, prompt, fn } ) => {
74
74
  try {
75
75
  const response = await fn();
76
76
  const { usage } = response;
77
- const cost = await handleMetering( { traceId, usage, prompt } );
77
+ const cost = await handleMetering( { traceId, usage, prompt, steps: response.steps } );
78
78
 
79
79
  if ( Array.isArray( response.images ) ) {
80
80
  const { image, images, providerMetadata } = response;
@@ -149,9 +149,9 @@ export const wrapStream = ( { name, prompt, abortSignal, fn } ) => {
149
149
  const state = { proxyResponse: null };
150
150
  removeAbortListener();
151
151
  try {
152
- const { text: result, finalStep, usage } = response;
152
+ const { text: result, finalStep, usage, steps } = response;
153
153
  const { providerMetadata } = finalStep;
154
- const cost = await handleMetering( { traceId, usage, prompt } );
154
+ const cost = await handleMetering( { traceId, usage, prompt, steps } );
155
155
  const sources = extractSources( response );
156
156
  Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, sources } } );
157
157
  state.proxyResponse = createResponseProxy( { response, properties: { cost, sources, result } } );