@outputai/llm 0.12.0 → 0.12.1-next.1243f78.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.1243f78.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -13,7 +13,8 @@
13
13
  "./src/index.d.ts",
14
14
  "./src/prompt/!(*.spec).js",
15
15
  "./src/prompt/markup/!(*.spec).js",
16
- "./src/utils/!(*.spec).js"
16
+ "./src/utils/!(*.spec).js",
17
+ "./src/utils/*.json"
17
18
  ],
18
19
  "dependencies": {
19
20
  "decimal.js": "10.6.0",
@@ -21,7 +22,7 @@
21
22
  "gray-matter": "4.0.3",
22
23
  "liquidjs": "10.27.2",
23
24
  "undici": "8.9.0",
24
- "@outputai/core": "0.12.0"
25
+ "@outputai/core": "0.12.1-next.1243f78.0"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@ai-sdk/amazon-bedrock": "5.0.57",
@@ -8,6 +8,7 @@ import { createOpenAI } from '@ai-sdk/openai';
8
8
  import { createPerplexity } from '@ai-sdk/perplexity';
9
9
  import { createVertex } from '@ai-sdk/google-vertex';
10
10
  import { deprecatedProviderAliases } from './deprecated_provider_aliases.js';
11
+ import { resolveGoogleVertexAuthOptions } from './utils/google_vertex_auth.js';
11
12
 
12
13
  /** This custom dispatcher has longer timeouts. */
13
14
  const customDispatcher = new EnvHttpProxyAgent( {
@@ -24,7 +25,7 @@ const providerInitializers = {
24
25
  'amazon-bedrock': createAmazonBedrock,
25
26
  anthropic: createAnthropic,
26
27
  azure: createAzure,
27
- 'google-vertex': createVertex,
28
+ 'google-vertex': options => createVertex( { ...options, ...resolveGoogleVertexAuthOptions() } ),
28
29
  openai: createOpenAI,
29
30
  perplexity: createPerplexity
30
31
  };
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
  }
@@ -383,10 +383,11 @@ export interface LLMUsageEvent {
383
383
 
384
384
  export type LLMGenerationCostItemStatus = 'ok' | 'fallback' | 'missing';
385
385
  export type LLMGenerationCostStatus = 'precise' | 'imprecise' | 'incomplete';
386
+ export type LLMGenerationCostPricingFreshness = 'live' | 'cached' | 'stale' | 'snapshot';
386
387
 
387
388
  /** Cost calculated for one normalized LLM usage item. */
388
389
  export interface LLMGenerationCostItem {
389
- group: 'input' | 'output';
390
+ group: 'input' | 'output' | 'request';
390
391
  label: string | null;
391
392
  amount: number;
392
393
  ppm: number | null;
@@ -401,8 +402,11 @@ export interface LLMGenerationCost extends BaseAttribute {
401
402
  modelId: string;
402
403
  input: number | null;
403
404
  output: number | null;
405
+ request: number | null;
404
406
  total: number | null;
405
407
  status: LLMGenerationCostStatus;
408
+ /** How current the rate table was. Absent on costs recorded before this field existed. */
409
+ pricingFreshness?: LLMGenerationCostPricingFreshness | null;
406
410
  items: LLMGenerationCostItem[];
407
411
  }
408
412
 
package/src/utils/cost.js CHANGED
@@ -1,6 +1,7 @@
1
- import { fetchModelsPricing } from './models_pricing.js';
1
+ import { fetchModelsPricing, Freshness } 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,35 +42,37 @@ 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;
48
+ pricingFreshness = null;
46
49
  items = [];
47
50
 
48
- constructor( modelId, providerId, items, usageStatus ) {
51
+ constructor( modelId, providerId, items, usageStatus, pricingFreshness ) {
49
52
  super( LLMGenerationCost.TYPE );
50
53
  this.modelId = modelId;
51
54
  this.providerId = providerId;
52
55
  this.items = items;
56
+ this.pricingFreshness = pricingFreshness;
53
57
 
54
58
  const meaningfulItems = items.filter( v => v.amount > 0 );
55
59
 
56
60
  if ( meaningfulItems.some( v => v.status === LLMGenerationCostItem.Status.MISSING ) || usageStatus === LLMGenerationUsage.Status.INCOMPLETE ) {
57
61
  this.status = LLMGenerationCost.Status.INCOMPLETE;
58
- } else if ( meaningfulItems.some( v => v.status === LLMGenerationCostItem.Status.FALLBACK ) ) {
62
+ } else if ( meaningfulItems.some( v => v.status === LLMGenerationCostItem.Status.FALLBACK ) || pricingFreshness === Freshness.SNAPSHOT ) {
59
63
  this.status = LLMGenerationCost.Status.IMPRECISE;
60
64
  } else {
61
65
  this.status = LLMGenerationCost.Status.PRECISE;
62
66
  }
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();
67
+ const sumGroup = group => {
68
+ const groupItems = items.filter( p => p.group === group && exists( p.total ) );
69
+ return groupItems.length > 0 ? groupItems.reduce( ( s, p ) => s.add( p.total ), Decimal( 0 ) ).toNumber() : null;
70
+ };
71
+ this.input = sumGroup( LLMGenerationUsageItem.Group.INPUT );
72
+ this.output = sumGroup( LLMGenerationUsageItem.Group.OUTPUT );
73
+ this.request = sumGroup( LLMGenerationUsageItem.Group.REQUEST );
74
+ if ( exists( this.input ) || exists( this.output ) || exists( this.request ) ) {
75
+ this.total = Decimal( this.input ?? 0 ).add( this.output ?? 0 ).add( this.request ?? 0 ).toNumber();
73
76
  }
74
77
  }
75
78
  }
@@ -86,6 +89,10 @@ const resolveValue = values => {
86
89
  };
87
90
 
88
91
  const resolvePrice = ( { group, label, pricing } ) => {
92
+ // Per-request charges are never token-priced: models.dev has no rate for them.
93
+ if ( group === LLMGenerationUsageItem.Group.REQUEST ) {
94
+ return resolveValue( [ GroundingPpmMap.get( label ) ] );
95
+ }
89
96
  if ( !pricing ) {
90
97
  return resolveValue( [] );
91
98
  }
@@ -113,7 +120,7 @@ const resolvePrice = ( { group, label, pricing } ) => {
113
120
  * @returns {Promise<LLMGenerationCost | null>} LLM generation cost with input, output, total and breakdown
114
121
  */
115
122
  export const calculateCosts = async usage => {
116
- const models = await fetchModelsPricing();
123
+ const { models, freshness: pricingFreshness } = await fetchModelsPricing();
117
124
 
118
125
  if ( !models ) {
119
126
  Logger.warn( 'Failed to fetch models pricing', { namespace: 'LLM' } );
@@ -133,5 +140,11 @@ export const calculateCosts = async usage => {
133
140
  return new LLMGenerationCostItem( group, label, amount, ppm, total, status );
134
141
  } );
135
142
 
136
- return new LLMGenerationCost( modelId, providerId, items, usage.status );
143
+ const unrated = items.some( v =>
144
+ v.group === LLMGenerationUsageItem.Group.REQUEST && v.status === LLMGenerationCostItem.Status.MISSING );
145
+ if ( unrated ) {
146
+ Logger.warn( 'Grounded call with no grounding rate for model', { namespace: 'LLM', modelId, providerId } );
147
+ }
148
+
149
+ return new LLMGenerationCost( modelId, providerId, items, usage.status, pricingFreshness );
137
150
  };
@@ -0,0 +1,31 @@
1
+ import { ValidationError } from '@outputai/core';
2
+
3
+ const envVar = 'GCP_CREDENTIALS_JSON';
4
+
5
+ const unescapeLinebreaks = v => v.replaceAll( '\\n', '\n' );
6
+
7
+ const decodeBase64 = v => Buffer.from( v, 'base64' ).toString( 'utf8' );
8
+
9
+ const parseJson = v => {
10
+ try {
11
+ return JSON.parse( v );
12
+ } catch ( cause ) {
13
+ throw new ValidationError( `Invalid ${envVar}: value is neither JSON nor base64 encoded JSON`, { cause } );
14
+ }
15
+ };
16
+
17
+ export const resolveGoogleVertexAuthOptions = () => {
18
+ const value = process.env[envVar]?.trim();
19
+
20
+ if ( !value ) {
21
+ return {};
22
+ }
23
+
24
+ const credentials = parseJson( value.startsWith( '{' ) ? value : decodeBase64( value ) );
25
+
26
+ if ( credentials.private_key ) {
27
+ credentials.private_key = unescapeLinebreaks( credentials.private_key );
28
+ }
29
+
30
+ return { googleAuthOptions: { credentials } };
31
+ };
@@ -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,9 +1,11 @@
1
1
  import { Logger } from '@outputai/core';
2
2
  import { EnvHttpProxyAgent, fetch } from 'undici';
3
+ import modelsPricingFallback from './models_pricing_fallback.json' with { type: 'json' };
3
4
 
4
5
  const logger = Logger.createLogger( 'LLM' );
5
6
  const costTableUrl = 'https://models.dev/api.json';
6
7
  const cacheTTL = 1000 * 60 * 60 * 24; // 1 day
8
+ const cooldownTTL = 1000 * 60 * 10; // 10 minutes
7
9
 
8
10
  /* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
9
11
  const dispatcher = new EnvHttpProxyAgent( { allowH2: false } );
@@ -13,23 +15,42 @@ export const cache = {
13
15
  expiresAt: 0
14
16
  };
15
17
 
18
+ export const Freshness = {
19
+ LIVE: 'live',
20
+ CACHED: 'cached',
21
+ STALE: 'stale',
22
+ SNAPSHOT: 'snapshot'
23
+ };
24
+
25
+ export const state = {
26
+ ignoreLiveRequestsUntil: 0
27
+ };
28
+
16
29
  const parseData = data => {
17
30
  const map = new Map();
18
31
  try {
19
- for ( const provider of Object.values( data ) ) {
32
+ for ( const [ providerId, provider ] of Object.entries( data ) ) {
33
+ if ( providerId === '_meta' ) {
34
+ continue;
35
+ }
20
36
  for ( const [ modelName, { cost } ] of Object.entries( provider.models ?? {} ) ) {
21
37
  if ( cost ) { // some models don't have cost
22
- map.set( `${provider.id}/${modelName}`, cost );
38
+ map.set( `${providerId}/${modelName}`, cost );
23
39
  }
24
40
  }
25
41
  }
42
+ if ( map.size === 0 ) {
43
+ throw new Error( 'Empty response' );
44
+ }
26
45
  return map;
27
46
  } catch ( error ) {
28
- logger.error( `Models pricing: Data parsing failure "${error.name}".` );
47
+ logger.error( `Models pricing: Data parsing failure "${error.message}".` );
29
48
  return null;
30
49
  }
31
50
  };
32
51
 
52
+ const fallbackTable = parseData( modelsPricingFallback );
53
+
33
54
  const fetchData = async () => {
34
55
  try {
35
56
  const res = await fetch( costTableUrl, { dispatcher } );
@@ -47,22 +68,26 @@ const fetchData = async () => {
47
68
 
48
69
  export const fetchModelsPricing = async () => {
49
70
  if ( cache.content && cache.expiresAt > Date.now() ) {
50
- return cache.content;
71
+ return { models: cache.content, freshness: Freshness.CACHED };
51
72
  }
52
73
 
53
- const table = await fetchData();
54
- const content = table ? parseData( table ) : null;
74
+ if ( state.ignoreLiveRequestsUntil < Date.now() ) {
75
+ const table = await fetchData();
76
+ const models = table ? parseData( table ) : null;
55
77
 
56
- if ( content ) {
57
- cache.content = content;
58
- cache.expiresAt = Date.now() + cacheTTL;
59
- return content;
78
+ if ( models ) {
79
+ cache.content = models;
80
+ cache.expiresAt = Date.now() + cacheTTL;
81
+ return { models, freshness: Freshness.LIVE };
82
+ } else {
83
+ state.ignoreLiveRequestsUntil = Date.now() + cooldownTTL;
84
+ }
60
85
  }
61
86
 
62
87
  if ( cache.content ) {
63
88
  logger.warn( 'Models pricing: using stale cache.' );
64
- return cache.content;
89
+ return { models: cache.content, freshness: Freshness.STALE };
65
90
  }
66
-
67
- return null;
91
+ logger.warn( 'Models pricing: using built-in fallback.' );
92
+ return { models: fallbackTable, freshness: Freshness.SNAPSHOT };
68
93
  };