@outputai/core 0.15.1-next.4a437bf.0 → 0.15.1-next.8ef04e2.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 +58 -6
- package/src/helpers/fetch.js +21 -9
- package/src/helpers/path.js +36 -0
- package/src/helpers/redact.js +2 -1
- package/src/http/config.js +10 -0
- package/src/http/cost.d.ts +20 -0
- package/src/http/cost.js +41 -0
- package/src/http/fetch/events.js +10 -0
- package/src/http/fetch/index.d.ts +26 -0
- package/src/http/fetch/index.js +78 -0
- package/src/http/fetch/logger.d.ts +10 -0
- package/src/http/fetch/logger.js +56 -0
- package/src/http/index.d.ts +27 -0
- package/src/http/index.js +9 -0
- package/src/http/ky/index.d.ts +25 -0
- package/src/http/ky/index.js +10 -0
- package/src/http/request_tag.js +30 -0
- package/src/llm/agent.js +141 -0
- package/src/llm/ai_provider.js +131 -0
- package/src/llm/ai_sdk_options.js +126 -0
- package/src/llm/consts.js +6 -0
- package/src/llm/deprecated_provider_aliases.js +8 -0
- package/src/llm/generate.js +88 -0
- package/src/llm/index.d.ts +631 -0
- package/src/llm/index.js +5 -0
- package/src/llm/prompt/content.js +70 -0
- package/src/llm/prompt/interpolations.js +58 -0
- package/src/llm/prompt/loader.js +67 -0
- package/src/llm/prompt/markup/attributes.js +52 -0
- package/src/llm/prompt/markup/nodes.js +71 -0
- package/src/llm/prompt/markup/tokenizer.js +3 -0
- package/src/llm/prompt/markup/tokens.js +26 -0
- package/src/llm/prompt/validations.js +129 -0
- package/src/llm/utils/cost.js +150 -0
- package/src/llm/utils/error_handler.js +53 -0
- package/src/llm/utils/file.js +42 -0
- package/src/llm/utils/google_vertex_auth.js +31 -0
- package/src/llm/utils/grounding.js +49 -0
- package/src/llm/utils/image.js +37 -0
- package/src/llm/utils/legacy_cost_attribute.js +75 -0
- package/src/llm/utils/metering.js +126 -0
- package/src/llm/utils/models.js +15 -0
- package/src/llm/utils/models_pricing.js +93 -0
- package/src/llm/utils/models_pricing_snapshot.json +4166 -0
- package/src/llm/utils/skills.js +74 -0
- package/src/llm/utils/sources.js +73 -0
- package/src/llm/utils/stream.js +75 -0
- package/src/llm/utils/tools.js +48 -0
- package/src/llm/utils/usage.js +156 -0
- package/src/llm/utils/usage_tools.js +23 -0
- package/src/llm/utils/wrap.js +277 -0
- package/src/llm/validations.js +234 -0
- package/src/sdk/helpers/path.d.ts +2 -0
- package/src/sdk/helpers/path.js +1 -0
- package/src/tracing/trace_attribute.d.ts +0 -17
- package/src/tracing/trace_attribute.js +1 -29
- package/src/worker/configs.js +23 -4
- package/src/worker/index.js +16 -47
- package/src/worker/interceptors/activity.js +0 -1
- package/src/worker/interceptors/workflow.js +0 -1
- package/src/worker/telemetry.js +122 -2
- package/src/worker/worker_options.js +43 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ValidationError } from '#errors';
|
|
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
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Logger } from '#runtime_logger';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the approximate file size from a base64 string.
|
|
5
|
+
* @param {string} b64data
|
|
6
|
+
* @returns {number} Size in bytes
|
|
7
|
+
*/
|
|
8
|
+
export const calculateBase64FileSize = b64data => {
|
|
9
|
+
if ( typeof b64data !== 'string' ) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const baseSize = b64data.length * ( 3 / 4 );
|
|
13
|
+
const paddingSize = [ b64data.at( -2 ), b64data.at( -1 ) ].filter( v => v === '=' ).length;
|
|
14
|
+
return baseSize - paddingSize;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Return a serialized version of the images from the AI SDK response.
|
|
19
|
+
* It contains only the file size and the media-type
|
|
20
|
+
*
|
|
21
|
+
* @param {object} response
|
|
22
|
+
* @returns {Array<{size: number, mediaType: string}>}
|
|
23
|
+
*/
|
|
24
|
+
export const serializeImagesFromResponse = response => {
|
|
25
|
+
try {
|
|
26
|
+
if ( !Array.isArray( response?.images ) ) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return response.images
|
|
30
|
+
.filter( image => image !== null && typeof image === 'object' )
|
|
31
|
+
.map( ( { base64, mediaType } ) => ( { size: calculateBase64FileSize( base64 ), mediaType } ) );
|
|
32
|
+
|
|
33
|
+
} catch ( error ) {
|
|
34
|
+
Logger.error( 'Image serialization failed', { namespace: 'LLM', error: error?.message || String( error ) } );
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BaseAttribute } from '#trace_attribute';
|
|
2
|
+
import Decimal from 'decimal.js';
|
|
3
|
+
|
|
4
|
+
const exists = v => Number.isFinite( v );
|
|
5
|
+
|
|
6
|
+
/** Legacy priced usage attribute retained for trace compatibility. */
|
|
7
|
+
export class LLMUsageLegacy extends BaseAttribute {
|
|
8
|
+
static TYPE = 'llm:usage';
|
|
9
|
+
modelId;
|
|
10
|
+
usage = [];
|
|
11
|
+
total;
|
|
12
|
+
tokensUsed;
|
|
13
|
+
|
|
14
|
+
constructor( modelId, usage ) {
|
|
15
|
+
super( LLMUsageLegacy.TYPE );
|
|
16
|
+
this.modelId = modelId;
|
|
17
|
+
this.usage = usage;
|
|
18
|
+
this.total = Decimal( 0 );
|
|
19
|
+
this.tokensUsed = Decimal( 0 );
|
|
20
|
+
|
|
21
|
+
for ( const item of usage ) {
|
|
22
|
+
item.total = Decimal( item.amount ).div( 1_000_000 ).mul( item.ppm ).toNumber();
|
|
23
|
+
this.total = this.total.add( item.total );
|
|
24
|
+
this.tokensUsed = this.tokensUsed.add( item.amount );
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.total = this.total.toNumber();
|
|
28
|
+
this.tokensUsed = this.tokensUsed.toNumber();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const convertCostToLegacy = cost => {
|
|
33
|
+
if ( !cost || cost.items.every( v => v.status === 'missing' ) ) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const usage = [];
|
|
38
|
+
|
|
39
|
+
// items
|
|
40
|
+
const inputItem = cost.items.find( ( { group, label, total } ) => group === 'input' && !label && exists( total ) );
|
|
41
|
+
const noCacheItem = cost.items.find( ( { label, total } ) => label === 'no_cache' && exists( total ) );
|
|
42
|
+
const cacheReadItem = cost.items.find( ( { label } ) => label === 'cache_read' ); // even when it doesn't have total
|
|
43
|
+
const cacheWriteItem = cost.items.find( ( { label, total } ) => label === 'cache_write' && exists( total ) );
|
|
44
|
+
const outputItem = cost.items.find( ( { group, label, total } ) => group === 'output' && !label && exists( total ) );
|
|
45
|
+
const textItem = cost.items.find( ( { label, total } ) => label === 'text' && exists( total ) );
|
|
46
|
+
const reasoningItem = cost.items.find( ( { label, total } ) => label === 'reasoning' && exists( total ) );
|
|
47
|
+
|
|
48
|
+
if ( inputItem ) {
|
|
49
|
+
usage.push( { type: 'input', ppm: inputItem.ppm, amount: inputItem.amount } );
|
|
50
|
+
} else {
|
|
51
|
+
if ( noCacheItem || cacheWriteItem ) {
|
|
52
|
+
const totalInput = ( noCacheItem?.amount ?? 0 ) + ( cacheWriteItem?.amount ?? 0 );
|
|
53
|
+
const ppm = noCacheItem?.ppm ?? cacheWriteItem?.ppm;
|
|
54
|
+
usage.push( { type: 'input', ppm, amount: totalInput } );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ( cacheReadItem ) {
|
|
58
|
+
const ppm = [ 'fallback', 'missing' ].includes( cacheReadItem.status ) ? 0 : cacheReadItem.ppm;
|
|
59
|
+
usage.push( { type: 'input_cached', ppm, amount: cacheReadItem.amount } );
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ( outputItem ) {
|
|
64
|
+
usage.push( { type: 'output', ppm: outputItem.ppm, amount: outputItem.amount } );
|
|
65
|
+
} else {
|
|
66
|
+
if ( textItem ) {
|
|
67
|
+
usage.push( { type: 'output', ppm: textItem.ppm, amount: textItem.amount } );
|
|
68
|
+
}
|
|
69
|
+
if ( reasoningItem ) {
|
|
70
|
+
usage.push( { type: 'reasoning', ppm: reasoningItem.ppm, amount: reasoningItem.amount } );
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return new LLMUsageLegacy( cost.modelId, usage );
|
|
75
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { parseLLMUsage } from './usage.js';
|
|
2
|
+
import { calculateCosts } from './cost.js';
|
|
3
|
+
import { stepEventBus } from '#bus';
|
|
4
|
+
import { addEventActionWithContext, EventAction } from '#tracing';
|
|
5
|
+
import { Logger } from '#runtime_logger';
|
|
6
|
+
import { convertCostToLegacy } from './legacy_cost_attribute.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Collects the usage of a single LLM call and bills it once.
|
|
10
|
+
*
|
|
11
|
+
* Steps are recorded as the SDK lifecycle reports them, and the response is handed to `bill()` by
|
|
12
|
+
* the paths that have one, so a call that throws after the model ran is still billed for what it
|
|
13
|
+
* spent. `bill()` parses the usage, costs it, attaches both as trace attributes and emits the
|
|
14
|
+
* metering events; it never throws.
|
|
15
|
+
*/
|
|
16
|
+
export class Metering {
|
|
17
|
+
#recordedSteps = [];
|
|
18
|
+
#billed = false;
|
|
19
|
+
#prompt;
|
|
20
|
+
#traceId;
|
|
21
|
+
#attributes = {
|
|
22
|
+
usage: null,
|
|
23
|
+
cost: null,
|
|
24
|
+
legacy: null
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async #createAttributes( { usage, steps } ) {
|
|
28
|
+
this.#attributes.usage = parseLLMUsage( { prompt: this.#prompt, usage, steps } );
|
|
29
|
+
if ( this.#attributes.usage ) {
|
|
30
|
+
this.#attributes.cost = await calculateCosts( this.#attributes.usage );
|
|
31
|
+
if ( this.#attributes.cost ) {
|
|
32
|
+
// @TEMP Preserve the deprecated event and trace attribute for legacy consumers.
|
|
33
|
+
this.#attributes.legacy = convertCostToLegacy( this.#attributes.cost );
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#attachAttributes() {
|
|
39
|
+
if ( this.#attributes.usage ) {
|
|
40
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: this.#traceId, details: this.#attributes.usage } );
|
|
41
|
+
}
|
|
42
|
+
if ( this.#attributes.cost ) {
|
|
43
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: this.#traceId, details: this.#attributes.cost } );
|
|
44
|
+
}
|
|
45
|
+
if ( this.#attributes.legacy ) {
|
|
46
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: this.#traceId, details: this.#attributes.legacy } );
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#emitEvents() {
|
|
51
|
+
if ( this.#attributes.usage ) {
|
|
52
|
+
stepEventBus.emit( 'sdk:llm:generation:metering', structuredClone( { cost: this.#attributes.cost, usage: this.#attributes.usage } ) );
|
|
53
|
+
}
|
|
54
|
+
if ( this.#attributes.legacy ) {
|
|
55
|
+
stepEventBus.emit( 'sdk:cost:llm:request', structuredClone( this.#attributes.legacy ) );
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} args
|
|
61
|
+
* @param {string} args.traceId - Trace event the usage and cost attributes are attached to
|
|
62
|
+
* @param {object} args.prompt - Loaded prompt (`config.provider` / `config.model` used for cost)
|
|
63
|
+
*/
|
|
64
|
+
constructor( { traceId, prompt } ) {
|
|
65
|
+
this.#traceId = traceId;
|
|
66
|
+
this.#prompt = prompt;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Records a completed step, used as the usage source when the call ends without a response.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} step - AI SDK step
|
|
73
|
+
*/
|
|
74
|
+
recordStep = step => {
|
|
75
|
+
if ( !this.#billed ) {
|
|
76
|
+
this.#recordedSteps.push( step );
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bills the recorded usage: parses it, costs it, attaches the trace attributes and emits the
|
|
82
|
+
* metering events. Later calls and later records are ignored, so it is safe to call from every
|
|
83
|
+
* path that can end the call. Read the result from `attributes`.
|
|
84
|
+
*
|
|
85
|
+
* An empty collector is not a final answer: the SDK reports a stream error before the steps that
|
|
86
|
+
* preceded it, so billing stays open until there is usage or a step to report.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} [response] - AI SDK response, whose aggregate usage and steps take precedence
|
|
89
|
+
* over the recorded steps; omitted by the paths that end without one
|
|
90
|
+
* @returns {Promise<void>}
|
|
91
|
+
*/
|
|
92
|
+
bill = async ( response = null ) => {
|
|
93
|
+
if ( this.#billed ) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const responseSteps = response?.steps;
|
|
99
|
+
const steps = Array.isArray( responseSteps ) && responseSteps.length > 0 ? responseSteps : this.#recordedSteps;
|
|
100
|
+
const usage = response?.usage;
|
|
101
|
+
|
|
102
|
+
if ( !usage && steps.length === 0 ) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.#billed = true;
|
|
107
|
+
|
|
108
|
+
await this.#createAttributes( { usage, steps } );
|
|
109
|
+
this.#attachAttributes();
|
|
110
|
+
this.#emitEvents();
|
|
111
|
+
|
|
112
|
+
} catch ( error ) {
|
|
113
|
+
Logger.error( 'Metering failed', { namespace: 'LLM', error: error?.message || String( error ) } );
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Attributes produced by `bill()`; each one is null until billed, and stays null when there is
|
|
119
|
+
* nothing to report.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{ usage: object|null, cost: object|null, legacy: object|null }}
|
|
122
|
+
*/
|
|
123
|
+
get attributes() {
|
|
124
|
+
return this.#attributes;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ValidationError } from '#errors';
|
|
2
|
+
import { getProvider } from '../ai_provider.js';
|
|
3
|
+
|
|
4
|
+
/** Load a model from a prompt */
|
|
5
|
+
export const loadTextModel = prompt => getProvider( prompt.config.provider )( prompt.config.model );
|
|
6
|
+
|
|
7
|
+
/** Load an image model from a prompt. */
|
|
8
|
+
export const loadImageModel = prompt => {
|
|
9
|
+
const provider = getProvider( prompt.config.provider );
|
|
10
|
+
const imageModelFactory = provider.image ?? provider.imageModel;
|
|
11
|
+
if ( typeof imageModelFactory !== 'function' ) {
|
|
12
|
+
throw new ValidationError( `Provider "${prompt.config.provider}" used by prompt "${prompt.name}" does not support image models.` );
|
|
13
|
+
}
|
|
14
|
+
return imageModelFactory( prompt.config.model );
|
|
15
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Logger } from '#runtime_logger';
|
|
2
|
+
import { EnvHttpProxyAgent, fetch } from 'undici';
|
|
3
|
+
import modelsPricingSnapshot from './models_pricing_snapshot.json' with { type: 'json' };
|
|
4
|
+
|
|
5
|
+
const logger = Logger.createLogger( 'LLM' );
|
|
6
|
+
const costTableUrl = 'https://models.dev/api.json';
|
|
7
|
+
const cacheTTL = 1000 * 60 * 60 * 24; // 1 day
|
|
8
|
+
const cooldownTTL = 1000 * 60 * 10; // 10 minutes
|
|
9
|
+
|
|
10
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
11
|
+
const dispatcher = new EnvHttpProxyAgent( { allowH2: false } );
|
|
12
|
+
|
|
13
|
+
export const cache = {
|
|
14
|
+
content: null,
|
|
15
|
+
expiresAt: 0
|
|
16
|
+
};
|
|
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
|
+
|
|
29
|
+
const parseData = data => {
|
|
30
|
+
const map = new Map();
|
|
31
|
+
try {
|
|
32
|
+
for ( const [ providerId, provider ] of Object.entries( data ) ) {
|
|
33
|
+
if ( providerId === '_meta' ) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for ( const [ modelName, { cost } ] of Object.entries( provider.models ?? {} ) ) {
|
|
37
|
+
if ( cost ) { // some models don't have cost
|
|
38
|
+
map.set( `${providerId}/${modelName}`, cost );
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if ( map.size === 0 ) {
|
|
43
|
+
throw new Error( 'Empty response' );
|
|
44
|
+
}
|
|
45
|
+
return map;
|
|
46
|
+
} catch ( error ) {
|
|
47
|
+
logger.error( `Models pricing: Data parsing failure "${error.message}".` );
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const fallbackTable = parseData( modelsPricingSnapshot );
|
|
53
|
+
|
|
54
|
+
const fetchData = async () => {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch( costTableUrl, { dispatcher } );
|
|
57
|
+
if ( res.ok ) {
|
|
58
|
+
return await res.json();
|
|
59
|
+
} else {
|
|
60
|
+
logger.error( `Models pricing: Data fetch HTTP error ${res.status}.` );
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
} catch ( error ) {
|
|
64
|
+
logger.error( `Models pricing: Data fetch failure "${error.code ?? error.name}".` );
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const fetchModelsPricing = async () => {
|
|
70
|
+
if ( cache.content && cache.expiresAt > Date.now() ) {
|
|
71
|
+
return { models: cache.content, freshness: Freshness.CACHED };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if ( state.ignoreLiveRequestsUntil < Date.now() ) {
|
|
75
|
+
const table = await fetchData();
|
|
76
|
+
const models = table ? parseData( table ) : null;
|
|
77
|
+
|
|
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if ( cache.content ) {
|
|
88
|
+
logger.warn( 'Models pricing: using stale cache.' );
|
|
89
|
+
return { models: cache.content, freshness: Freshness.STALE };
|
|
90
|
+
}
|
|
91
|
+
logger.warn( 'Models pricing: using built-in fallback.' );
|
|
92
|
+
return { models: fallbackTable, freshness: Freshness.SNAPSHOT };
|
|
93
|
+
};
|