@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.
Files changed (62) hide show
  1. package/package.json +58 -6
  2. package/src/helpers/fetch.js +21 -9
  3. package/src/helpers/path.js +36 -0
  4. package/src/helpers/redact.js +2 -1
  5. package/src/http/config.js +10 -0
  6. package/src/http/cost.d.ts +20 -0
  7. package/src/http/cost.js +41 -0
  8. package/src/http/fetch/events.js +10 -0
  9. package/src/http/fetch/index.d.ts +26 -0
  10. package/src/http/fetch/index.js +78 -0
  11. package/src/http/fetch/logger.d.ts +10 -0
  12. package/src/http/fetch/logger.js +56 -0
  13. package/src/http/index.d.ts +27 -0
  14. package/src/http/index.js +9 -0
  15. package/src/http/ky/index.d.ts +25 -0
  16. package/src/http/ky/index.js +10 -0
  17. package/src/http/request_tag.js +30 -0
  18. package/src/llm/agent.js +141 -0
  19. package/src/llm/ai_provider.js +131 -0
  20. package/src/llm/ai_sdk_options.js +126 -0
  21. package/src/llm/consts.js +6 -0
  22. package/src/llm/deprecated_provider_aliases.js +8 -0
  23. package/src/llm/generate.js +88 -0
  24. package/src/llm/index.d.ts +631 -0
  25. package/src/llm/index.js +5 -0
  26. package/src/llm/prompt/content.js +70 -0
  27. package/src/llm/prompt/interpolations.js +58 -0
  28. package/src/llm/prompt/loader.js +67 -0
  29. package/src/llm/prompt/markup/attributes.js +52 -0
  30. package/src/llm/prompt/markup/nodes.js +71 -0
  31. package/src/llm/prompt/markup/tokenizer.js +3 -0
  32. package/src/llm/prompt/markup/tokens.js +26 -0
  33. package/src/llm/prompt/validations.js +129 -0
  34. package/src/llm/utils/cost.js +150 -0
  35. package/src/llm/utils/error_handler.js +53 -0
  36. package/src/llm/utils/file.js +42 -0
  37. package/src/llm/utils/google_vertex_auth.js +31 -0
  38. package/src/llm/utils/grounding.js +49 -0
  39. package/src/llm/utils/image.js +37 -0
  40. package/src/llm/utils/legacy_cost_attribute.js +75 -0
  41. package/src/llm/utils/metering.js +126 -0
  42. package/src/llm/utils/models.js +15 -0
  43. package/src/llm/utils/models_pricing.js +93 -0
  44. package/src/llm/utils/models_pricing_snapshot.json +4166 -0
  45. package/src/llm/utils/skills.js +74 -0
  46. package/src/llm/utils/sources.js +73 -0
  47. package/src/llm/utils/stream.js +75 -0
  48. package/src/llm/utils/tools.js +48 -0
  49. package/src/llm/utils/usage.js +156 -0
  50. package/src/llm/utils/usage_tools.js +23 -0
  51. package/src/llm/utils/wrap.js +277 -0
  52. package/src/llm/validations.js +234 -0
  53. package/src/sdk/helpers/path.d.ts +2 -0
  54. package/src/sdk/helpers/path.js +1 -0
  55. package/src/tracing/trace_attribute.d.ts +0 -17
  56. package/src/tracing/trace_attribute.js +1 -29
  57. package/src/worker/configs.js +23 -4
  58. package/src/worker/index.js +16 -47
  59. package/src/worker/interceptors/activity.js +0 -1
  60. package/src/worker/interceptors/workflow.js +0 -1
  61. package/src/worker/telemetry.js +122 -2
  62. 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
+ };