@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 +4 -3
- package/src/ai_provider.js +2 -1
- package/src/index.d.ts +7 -3
- package/src/utils/cost.js +28 -15
- package/src/utils/google_vertex_auth.js +31 -0
- package/src/utils/grounding.js +49 -0
- package/src/utils/models_pricing.js +38 -13
- package/src/utils/models_pricing_fallback.json +2806 -0
- package/src/utils/usage.js +16 -2
- package/src/utils/wrap.js +5 -5
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",
|
package/src/ai_provider.js
CHANGED
|
@@ -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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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.
|
|
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( `${
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
74
|
+
if ( state.ignoreLiveRequestsUntil < Date.now() ) {
|
|
75
|
+
const table = await fetchData();
|
|
76
|
+
const models = table ? parseData( table ) : null;
|
|
55
77
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
91
|
+
logger.warn( 'Models pricing: using built-in fallback.' );
|
|
92
|
+
return { models: fallbackTable, freshness: Freshness.SNAPSHOT };
|
|
68
93
|
};
|