@outputai/llm 0.13.0 → 0.13.1-next.318b2e6.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.13.0",
3
+ "version": "0.13.1-next.318b2e6.0",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -22,7 +22,7 @@
22
22
  "gray-matter": "4.0.3",
23
23
  "liquidjs": "10.27.2",
24
24
  "undici": "8.9.0",
25
- "@outputai/core": "0.13.0"
25
+ "@outputai/core": "0.13.1-next.318b2e6.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@ai-sdk/amazon-bedrock": "5.0.57",
package/src/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ToolLoopAgent as AIToolLoopAgent } from 'ai';
2
2
  import { loadAiSdkTextOptions } from './ai_sdk_options.js';
3
- import { wrapGeneration, wrapStream } from './utils/wrap.js';
3
+ import { wrapTextGeneration, wrapStream } from './utils/wrap.js';
4
4
  import { Role } from './consts.js';
5
5
  import { drainStream } from './utils/stream.js';
6
6
  import { loadPrompt } from './prompt/loader.js';
@@ -48,15 +48,16 @@ export class Agent {
48
48
  const { messages, abortSignal, toolChoice } = Validator.parseAgentGenerateArgs( args );
49
49
  const combinedMessages = await this.#combineWithPreviousMessages( messages );
50
50
 
51
- return wrapGeneration( {
51
+ return wrapTextGeneration( {
52
52
  name: 'Agent.generate',
53
53
  prompt: this.#prompt,
54
- fn: async () => {
54
+ fn: async ( { onStepEndHook } ) => {
55
55
  const response = await this.#agent.generate( {
56
56
  messages: combinedMessages,
57
57
  allowSystemInMessages: true,
58
58
  ...( abortSignal && { abortSignal } ),
59
- ...( toolChoice && { toolChoice } )
59
+ ...( toolChoice && { toolChoice } ),
60
+ onStepEnd: onStepEndHook
60
61
  } );
61
62
  if ( response.finishReason !== 'error' ) {
62
63
  await this.#storeMessages( messages.concat( response.responseMessages ?? [] ) );
@@ -73,10 +74,10 @@ export class Agent {
73
74
  const { messages, abortSignal, toolChoice, onChunk } = Validator.parseAgentGenerateWithStreamingArgs( args );
74
75
  const combinedMessages = await this.#combineWithPreviousMessages( messages );
75
76
 
76
- return wrapGeneration( {
77
+ return wrapTextGeneration( {
77
78
  name: 'Agent.generateWithStreaming',
78
79
  prompt: this.#prompt,
79
- fn: async () => {
80
+ fn: async ( { onStepEndHook } ) => {
80
81
  const state = { response: null };
81
82
  const stream = await this.#agent.stream( {
82
83
  messages: combinedMessages,
@@ -84,6 +85,7 @@ export class Agent {
84
85
  ...( onChunk && { onChunk } ),
85
86
  ...( abortSignal && { abortSignal } ),
86
87
  ...( toolChoice && { toolChoice } ),
88
+ onStepEnd: onStepEndHook,
87
89
  onEnd: res => {
88
90
  state.response = res;
89
91
  },
@@ -93,7 +95,7 @@ export class Agent {
93
95
  await drainStream( stream, abortSignal );
94
96
 
95
97
  if ( !state.response ) {
96
- throw new Error( 'Agent streaming generation completed without a response.' );
98
+ throw new Error( 'Streaming completed without a response.' );
97
99
  }
98
100
 
99
101
  state.response.output = await stream.output;
@@ -114,20 +116,21 @@ export class Agent {
114
116
  name: 'Agent.stream',
115
117
  prompt: this.#prompt,
116
118
  abortSignal,
117
- fn: ( { onEndHook, onErrorHook } ) => this.#agent.stream( {
119
+ fn: ( { onEndHook, onErrorHook, onStepEndHook, onAbortHook } ) => this.#agent.stream( {
118
120
  messages: combinedMessages,
119
121
  allowSystemInMessages: true,
120
122
  ...( onChunk && { onChunk } ),
121
123
  ...( abortSignal && { abortSignal } ),
122
124
  ...( toolChoice && { toolChoice } ),
125
+ onStepEnd: onStepEndHook,
126
+ onAbort: onAbortHook,
123
127
  onEnd: response =>
124
128
  onEndHook( response, async parsedResponse => {
125
129
  if ( response.finishReason !== 'error' ) {
126
130
  await this.#storeMessages( messages.concat( response.responseMessages ?? [] ) )
127
- .catch( error => Logger.error( 'Agent.stream message store persistence failed', {
128
- namespace: 'LLM',
129
- error: error instanceof Error ? error.message : String( error )
130
- } ) );
131
+ .catch( error =>
132
+ Logger.error( 'Message store persistence failed', { namespace: 'LLM', error: error?.message || String( error ) } )
133
+ );
131
134
  }
132
135
  await onEnd?.( parsedResponse );
133
136
  } ),
package/src/generate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as AI from 'ai';
2
2
  import { loadPrompt } from './prompt/loader.js';
3
- import { wrapGeneration, wrapStream } from './utils/wrap.js';
3
+ import { wrapImageGeneration, wrapStream, wrapTextGeneration } from './utils/wrap.js';
4
4
  import { loadAiSdkTextOptions, loadAiSdkImageOptions } from './ai_sdk_options.js';
5
5
  import { drainStream } from './utils/stream.js';
6
6
  import { loadSkills } from './utils/skills.js';
@@ -11,10 +11,13 @@ export const generateText = async args => {
11
11
  const prompt = promptObject ?? loadPrompt( promptFile, variables, promptDir );
12
12
  const skills = loadSkills( prompt );
13
13
 
14
- return wrapGeneration( {
14
+ return wrapTextGeneration( {
15
15
  name: 'generateText',
16
16
  prompt,
17
- fn: () => AI.generateText( loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ) )
17
+ fn: ( { onStepEndHook } ) => AI.generateText( {
18
+ ...loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ),
19
+ onStepEnd: onStepEndHook
20
+ } )
18
21
  } );
19
22
  };
20
23
 
@@ -27,9 +30,11 @@ export const streamText = args => {
27
30
  name: 'streamText',
28
31
  prompt,
29
32
  abortSignal: aiOptions.abortSignal,
30
- fn: ( { onEndHook, onErrorHook } ) => AI.streamText( {
33
+ fn: ( { onEndHook, onErrorHook, onStepEndHook, onAbortHook } ) => AI.streamText( {
31
34
  ...loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ),
32
35
  ...( onChunk && { onChunk } ),
36
+ onStepEnd: onStepEndHook,
37
+ onAbort: onAbortHook,
33
38
  onEnd: response => onEndHook( response, onEnd ),
34
39
  onError: event => onErrorHook( event, error => onError?.( { ...event, error } ) )
35
40
  } )
@@ -44,14 +49,15 @@ export const generateTextWithStreaming = async args => {
44
49
  const prompt = promptObject ?? loadPrompt( promptFile, variables, promptDir );
45
50
  const skills = loadSkills( prompt );
46
51
 
47
- return wrapGeneration( {
52
+ return wrapTextGeneration( {
48
53
  name: 'generateTextWithStreaming',
49
54
  prompt,
50
- fn: async () => {
55
+ fn: async ( { onStepEndHook } ) => {
51
56
  const state = { response: null };
52
57
  const stream = AI.streamText( {
53
58
  ...loadAiSdkTextOptions( { prompt, skills, ...aiOptions } ),
54
59
  ...( onChunk && { onChunk } ),
60
+ onStepEnd: onStepEndHook,
55
61
  onEnd: res => {
56
62
  state.response = res;
57
63
  },
@@ -61,7 +67,7 @@ export const generateTextWithStreaming = async args => {
61
67
  await drainStream( stream, aiOptions.abortSignal );
62
68
 
63
69
  if ( !state.response ) {
64
- throw new Error( 'Streaming generation completed without a response.' );
70
+ throw new Error( 'Streaming completed without a response.' );
65
71
  }
66
72
 
67
73
  state.response.output = await stream.output;
@@ -74,7 +80,7 @@ export const generateImage = async args => {
74
80
  const { promptFile, promptObject, promptDir, variables, ...aiOptions } = Validator.parseGenerateImageArgs( args );
75
81
  const prompt = promptObject ?? loadPrompt( promptFile, variables, promptDir );
76
82
 
77
- return wrapGeneration( {
83
+ return wrapImageGeneration( {
78
84
  name: 'generateImage',
79
85
  prompt,
80
86
  fn: () => AI.generateImage( loadAiSdkImageOptions( { prompt, ...aiOptions } ) )
@@ -25,6 +25,10 @@ const nonRetryableAiSdkErrorTypes = [
25
25
  * @returns {object} A new Error
26
26
  */
27
27
  export const mapAiError = error => {
28
+ if ( !( error instanceof Error ) ) {
29
+ return error;
30
+ }
31
+
28
32
  if ( error instanceof FatalError ) {
29
33
  return error;
30
34
  }
@@ -1,10 +1,37 @@
1
+ import { Logger } from '@outputai/core';
2
+
1
3
  /**
2
4
  * Get the approximate file size from a base64 string.
3
5
  * @param {string} b64data
4
6
  * @returns {number} Size in bytes
5
7
  */
6
8
  export const calculateBase64FileSize = b64data => {
9
+ if ( typeof b64data !== 'string' ) {
10
+ return null;
11
+ }
7
12
  const baseSize = b64data.length * ( 3 / 4 );
8
13
  const paddingSize = [ b64data.at( -2 ), b64data.at( -1 ) ].filter( v => v === '=' ).length;
9
14
  return baseSize - paddingSize;
10
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,125 @@
1
+ import { parseLLMUsage } from './usage.js';
2
+ import { calculateCosts } from './cost.js';
3
+ import { Tracing, Event } from '@outputai/core/sdk/runtime';
4
+ import { Logger } from '@outputai/core';
5
+ import { convertCostToLegacy } from './legacy_cost_attribute.js';
6
+
7
+ /**
8
+ * Collects the usage of a single LLM call and bills it once.
9
+ *
10
+ * Steps are recorded as the SDK lifecycle reports them, and the response is handed to `bill()` by
11
+ * the paths that have one, so a call that throws after the model ran is still billed for what it
12
+ * spent. `bill()` parses the usage, costs it, attaches both as trace attributes and emits the
13
+ * metering events; it never throws.
14
+ */
15
+ export class Metering {
16
+ #recordedSteps = [];
17
+ #billed = false;
18
+ #prompt;
19
+ #traceId;
20
+ #attributes = {
21
+ usage: null,
22
+ cost: null,
23
+ legacy: null
24
+ };
25
+
26
+ async #createAttributes( { usage, steps } ) {
27
+ this.#attributes.usage = parseLLMUsage( { prompt: this.#prompt, usage, steps } );
28
+ if ( this.#attributes.usage ) {
29
+ this.#attributes.cost = await calculateCosts( this.#attributes.usage );
30
+ if ( this.#attributes.cost ) {
31
+ // @TEMP Preserve the deprecated event and trace attribute for legacy consumers.
32
+ this.#attributes.legacy = convertCostToLegacy( this.#attributes.cost );
33
+ }
34
+ }
35
+ }
36
+
37
+ #attachAttributes() {
38
+ if ( this.#attributes.usage ) {
39
+ Tracing.addEventAttribute( { eventId: this.#traceId, attribute: this.#attributes.usage } );
40
+ }
41
+ if ( this.#attributes.cost ) {
42
+ Tracing.addEventAttribute( { eventId: this.#traceId, attribute: this.#attributes.cost } );
43
+ }
44
+ if ( this.#attributes.legacy ) {
45
+ Tracing.addEventAttribute( { eventId: this.#traceId, attribute: this.#attributes.legacy } );
46
+ }
47
+ }
48
+
49
+ #emitEvents() {
50
+ if ( this.#attributes.usage ) {
51
+ Event.emit( 'llm:generation:metering', structuredClone( { cost: this.#attributes.cost, usage: this.#attributes.usage } ) );
52
+ }
53
+ if ( this.#attributes.legacy ) {
54
+ Event.emit( 'cost:llm:request', structuredClone( this.#attributes.legacy ) );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {object} args
60
+ * @param {string} args.traceId - Trace event the usage and cost attributes are attached to
61
+ * @param {object} args.prompt - Loaded prompt (`config.provider` / `config.model` used for cost)
62
+ */
63
+ constructor( { traceId, prompt } ) {
64
+ this.#traceId = traceId;
65
+ this.#prompt = prompt;
66
+ }
67
+
68
+ /**
69
+ * Records a completed step, used as the usage source when the call ends without a response.
70
+ *
71
+ * @param {object} step - AI SDK step
72
+ */
73
+ recordStep = step => {
74
+ if ( !this.#billed ) {
75
+ this.#recordedSteps.push( step );
76
+ }
77
+ };
78
+
79
+ /**
80
+ * Bills the recorded usage: parses it, costs it, attaches the trace attributes and emits the
81
+ * metering events. Later calls and later records are ignored, so it is safe to call from every
82
+ * path that can end the call. Read the result from `attributes`.
83
+ *
84
+ * An empty collector is not a final answer: the SDK reports a stream error before the steps that
85
+ * preceded it, so billing stays open until there is usage or a step to report.
86
+ *
87
+ * @param {object} [response] - AI SDK response, whose aggregate usage and steps take precedence
88
+ * over the recorded steps; omitted by the paths that end without one
89
+ * @returns {Promise<void>}
90
+ */
91
+ bill = async ( response = null ) => {
92
+ if ( this.#billed ) {
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const responseSteps = response?.steps;
98
+ const steps = Array.isArray( responseSteps ) && responseSteps.length > 0 ? responseSteps : this.#recordedSteps;
99
+ const usage = response?.usage;
100
+
101
+ if ( !usage && steps.length === 0 ) {
102
+ return;
103
+ }
104
+
105
+ this.#billed = true;
106
+
107
+ await this.#createAttributes( { usage, steps } );
108
+ this.#attachAttributes();
109
+ this.#emitEvents();
110
+
111
+ } catch ( error ) {
112
+ Logger.error( 'Metering failed', { namespace: 'LLM', error: error?.message || String( error ) } );
113
+ }
114
+ };
115
+
116
+ /**
117
+ * Attributes produced by `bill()`; each one is null until billed, and stays null when there is
118
+ * nothing to report.
119
+ *
120
+ * @returns {{ usage: object|null, cost: object|null, legacy: object|null }}
121
+ */
122
+ get attributes() {
123
+ return this.#attributes;
124
+ }
125
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { Logger } from '@outputai/core';
2
3
 
3
4
  /** Builds the final source shape */
4
5
  const buildSource = ( { url, title } ) => {
@@ -15,34 +16,58 @@ const buildSource = ( { url, title } ) => {
15
16
  /** Return value it is array, otherwise return [] */
16
17
  const asArray = v => Array.isArray( v ) ? v : [];
17
18
 
19
+ const isNonBlankUrl = url => typeof url === 'string' && url.trim().length > 0;
20
+
18
21
  /**
19
- * Extracts source URLs from search tool results embedded in AI SDK step data.
22
+ * Extracts source from search tool results embedded in AI SDK step data.
20
23
  *
21
24
  * Detects any tool result containing a `results[]` array whose items have a `url` string field.
22
25
  * This covers perplexitySearch, tavilySearch, exaSearch, and any future tool with the same shape.
26
+ * Deduplicate key is "url"
23
27
  *
24
28
  * @param {Array} steps - AI SDK response steps (response.steps)
25
- * @returns {Array<{ type: string, sourceType: string, id: string, url: string, title: string }>}
29
+ * @returns {Array<{ key: string, source: object }>}
26
30
  */
27
31
  const extractSourcesFromSteps = steps =>
28
32
  asArray( steps )
29
33
  .flatMap( step => asArray( step?.toolResults ) )
30
34
  .flatMap( toolResult => asArray( toolResult?.output?.results ) )
31
- .filter( item => typeof item?.url === 'string' && !!item.url.trim() )
32
- .map( v => buildSource( v ) );
35
+ .filter( item => isNonBlankUrl( item?.url ) )
36
+ .map( item => {
37
+ const source = buildSource( item );
38
+ return { key: source.url, source };
39
+ } );
33
40
 
34
41
  /**
35
- * Extract sources from tools usage and final response, deduplicate, and return.
42
+ * Extract only the valid sources from the response (has url or id).
43
+ * Deduplicate key is `url` when set, otherwise `id` (document sources have `id` and no `url`).
36
44
  *
37
- * Dedup key is `url` when set, otherwise `id` (document sources have `id` and no `url`).
45
+ * @param {Array} sources
46
+ * @returns {Array<{ key: string, source: object }>}
47
+ */
48
+ const extractValidSourcesFromResponse = sources =>
49
+ asArray( sources )
50
+ .filter( item => item !== null && typeof item === 'object' && ( isNonBlankUrl( item.url ) || typeof item.id === 'string' ) )
51
+ .map( item => ( { key: isNonBlankUrl( item.url ) ? item.url.trim() : item.id, source: item } ) );
52
+
53
+ /** Deduplicate wrapped sources by their key */
54
+ const deduplicateSources = sources => new Map( sources.map( item => [ item.key, item.source ] ) ).values().toArray();
55
+
56
+ /**
57
+ * Extract sources from tools usage and final response, deduplicate, and return.
38
58
  * Response sources are preferred over tools when the key matches.
39
59
  *
40
60
  * @param {object} response AI SDK response
41
61
  * @returns {object[]} Merged sources
42
62
  */
43
63
  export const extractSources = response => {
44
- const { steps, sources: sourcesFromResponse } = response;
45
- const sourcesFromTools = extractSourcesFromSteps( steps );
46
- const allSources = sourcesFromTools.concat( asArray( sourcesFromResponse ) );
47
- return new Map( allSources.map( s => [ s.url ?? s.id, s ] ) ).values().toArray();
64
+ try {
65
+ const { steps, sources: sourcesFromResponse } = response ?? {};
66
+ const sourcesFromTools = extractSourcesFromSteps( asArray( steps ) );
67
+ const validSourcesFromResponse = extractValidSourcesFromResponse( sourcesFromResponse );
68
+ return deduplicateSources( sourcesFromTools.concat( validSourcesFromResponse ) );
69
+ } catch ( error ) {
70
+ Logger.error( 'Sources extraction failed', { namespace: 'LLM', error: error?.message || String( error ) } );
71
+ return [];
72
+ }
48
73
  };
@@ -1,22 +1,75 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+
3
+ /** How long a failed stream may go quiet before the drain gives up on its remaining parts */
4
+ const FAILURE_DRAIN_TIMEOUT_MS = 250;
5
+
6
+ const TIMED_OUT = Symbol( 'drain-timed-out' );
7
+
8
+ /** Extracts the error an abort or error part should surface, or null for every other part */
9
+ const extractError = ( part, abortSignal ) => {
10
+ if ( part?.type === 'abort' ) {
11
+ const reason = abortSignal?.reason;
12
+ return reason instanceof Error ?
13
+ reason :
14
+ new Error( part.reason ?? 'Streaming aborted.', { cause: reason } );
15
+ }
16
+
17
+ if ( part?.type === 'error' ) {
18
+ return part.error instanceof Error ?
19
+ part.error :
20
+ new Error( part.error ? String( part.error ) : 'Streaming failed.', { cause: part.error } );
21
+ }
22
+
23
+ if ( abortSignal?.aborted ) {
24
+ return abortSignal.reason instanceof Error ?
25
+ abortSignal.reason :
26
+ new Error( 'Streaming aborted.', { cause: abortSignal.reason } );
27
+ }
28
+
29
+ return null;
30
+ };
31
+
32
+ /** Reads the next part, resolving with `TIMED_OUT` when the stream goes quiet for longer than `timeoutMs` */
33
+ const nextWithin = async ( iterator, timeoutMs ) =>
34
+ Promise.race( [ iterator.next(), delay( timeoutMs, TIMED_OUT, { ref: false } ) ] );
35
+
1
36
  /**
2
- * Consumes a streaming result until it completes. Throws on abort or error parts.
37
+ * Consumes a streaming result until it completes, then throws the first abort or error part it saw.
3
38
  * Callers must not throw from the AI SDK `onError` callback; that errors the stream before these parts are delivered.
4
39
  *
40
+ * The draining continues past a failure on purpose: the AI SDK fires its lifecycle callbacks as parts flow through the
41
+ * stream, so `onStepEnd` and `onEnd` - and with them the usage of everything already spent - only arrive if something
42
+ * keeps reading. A stalled provider can leave the stream open forever, so once a failure is captured every read waits
43
+ * at most `FAILURE_DRAIN_TIMEOUT_MS` for the next part, and the drain gives up and throws anyway when none arrives.
44
+ * A healthy stream ends immediately after a failure, so the timeout is not part of the normal path; a successful
45
+ * stream is never timed out.
46
+ *
5
47
  * @param {object} stream - AI SDK stream result with `stream`
6
48
  * @param {AbortSignal} [abortSignal] - Used to recover the original abort reason
7
49
  */
8
50
  export const drainStream = async ( stream, abortSignal ) => {
9
- for await ( const part of stream.stream ) {
10
- if ( part.type === 'abort' ) {
11
- const reason = abortSignal?.reason;
12
- throw reason instanceof Error ?
13
- reason :
14
- new Error( part.reason ?? 'Streaming generation aborted.', { cause: reason } );
51
+ const iterator = stream.stream[Symbol.asyncIterator]();
52
+ const state = { error: null };
53
+
54
+ while ( true ) {
55
+ const hasDeadline = state.error || abortSignal?.aborted;
56
+ const result = await ( hasDeadline ? nextWithin( iterator, FAILURE_DRAIN_TIMEOUT_MS ) : iterator.next() );
57
+
58
+ if ( result.done ) {
59
+ break;
60
+ }
61
+
62
+ if ( result?.value || abortSignal?.aborted ) {
63
+ state.error ??= extractError( result.value, abortSignal );
15
64
  }
16
- if ( part.type === 'error' ) {
17
- throw part.error instanceof Error ?
18
- part.error :
19
- new Error( part.error ? String( part.error ) : 'Streaming generation failed.', { cause: part.error } );
65
+
66
+ if ( result === TIMED_OUT ) {
67
+ iterator.return?.()?.catch( () => {} );
68
+ break;
20
69
  }
21
70
  }
71
+
72
+ if ( state.error ) {
73
+ throw state.error;
74
+ }
22
75
  };
@@ -1,6 +1,7 @@
1
1
  import Decimal from 'decimal.js';
2
2
  import { Tracing } from '@outputai/core/sdk/runtime';
3
3
  import { parseGroundingUsage } from './grounding.js';
4
+ import { extractUsageFromSteps } from './usage_tools.js';
4
5
 
5
6
  const exists = v => Number.isSafeInteger( v ) && v >= 0;
6
7
 
@@ -58,19 +59,37 @@ export class LLMGenerationUsage extends Tracing.Attribute.BaseAttribute {
58
59
  }
59
60
 
60
61
  /**
61
- * Converts raw AI SDK usage to LLMGenerationUsage data
62
+ * Returns usage from response or reconstruct it from steps.
63
+ *
64
+ * The token check is load-bearing: aborted and truncated calls still report an aggregate, but with
65
+ * every token count undefined, and the steps are then the only record of what was already spent.
66
+ */
67
+ const resolveUsage = ( { usage, steps } ) => {
68
+ if ( usage && ( exists( usage.inputTokens ) || exists( usage.outputTokens ) ) ) {
69
+ return usage;
70
+ }
71
+ return Array.isArray( steps ) && steps.length > 0 ? extractUsageFromSteps( steps ) : null;
72
+ };
73
+
74
+ /**
75
+ * Converts raw AI SDK usage to LLMGenerationUsage data.
76
+ *
77
+ * Reads `response.usage`, falling back to the sum of `response.steps` usage when the aggregate is
78
+ * missing tokens (truncated or failed streams). Grounding is aggregated from `response.steps`, so a
79
+ * partial `{ steps }` response is accepted when only the steps are known.
62
80
  *
63
81
  * @param {object} args
64
82
  * @param {object} args.prompt - Output prompt with model configuration
65
- * @param {object} args.prompt.config - Prompt model configuration
66
- * @param {string} args.prompt.config.provider - Id of the provider
67
- * @param {string} args.prompt.config.model - Id of the model
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
83
+ * @param {object} [args.usage] - AI SDK response usage
84
+ * @param {object} [args.steps] - AI SDK response steps
70
85
  *
71
86
  * @returns {LLMGenerationUsage | null} LLM generation usage with input, output, total and detailed breakdown
72
87
  */
73
- export const parseLLMUsage = ( { prompt, usage, steps } ) => {
88
+ export const parseLLMUsage = ( { prompt, usage: usageArg = null, steps = [] } ) => {
89
+ const usage = resolveUsage( { usage: usageArg, steps } );
90
+ if ( !usage ) {
91
+ return null;
92
+ }
74
93
  const { provider: providerId, model: modelId } = prompt.config;
75
94
  const { inputTokens, inputTokenDetails, outputTokens, outputTokenDetails } = usage;
76
95
  const { noCacheTokens, cacheReadTokens, cacheWriteTokens } = inputTokenDetails ?? {};
@@ -118,13 +137,15 @@ export const parseLLMUsage = ( { prompt, usage, steps } ) => {
118
137
  // Grounding is billed per step, so aggregate across every step rather than only the final one.
119
138
  // Per-query families (Gemini 3) return queries.length per step; per-prompt families (Gemini 2.x)
120
139
  // 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.TOOLS, grounding[0].label, amount ) );
140
+ if ( Array.isArray( steps ) ) {
141
+ const grounding = steps
142
+ .filter( step => step?.providerMetadata )
143
+ .map( step => parseGroundingUsage( modelId, step.providerMetadata ) )
144
+ .filter( Boolean );
145
+ if ( grounding.length > 0 ) {
146
+ const amount = grounding.reduce( ( sum, g ) => sum + g.amount, 0 );
147
+ items.push( new LLMGenerationUsageItem( LLMGenerationUsageItem.Group.TOOLS, grounding[0].label, amount ) );
148
+ }
128
149
  }
129
150
 
130
151
  if ( items.length === 0 ) {
@@ -0,0 +1,23 @@
1
+ const sum = ( a, b ) => {
2
+ if ( Number.isFinite( a ) ) {
3
+ return a + ( Number.isFinite( b ) ? b : 0 );
4
+ } else {
5
+ return Number.isFinite( b ) ? b : undefined;
6
+ }
7
+ };
8
+
9
+ const isPlainObject = value => value !== null && typeof value === 'object';
10
+
11
+ /** Deep numeric merge of two AI SDK usage objects; `raw` is provider-shaped and never summed */
12
+ const mergeUsage = ( total, usage ) => Object.entries( usage )
13
+ .filter( ( [ key ] ) => key !== 'raw' )
14
+ .reduce( ( merged, [ key, value ] ) => ( {
15
+ ...merged,
16
+ [key]: isPlainObject( value ) ? mergeUsage( merged[key] ?? {}, value ) : sum( merged[key], value )
17
+ } ), total );
18
+
19
+ /** Sums per-step AI SDK usage */
20
+ export const extractUsageFromSteps = steps => steps
21
+ .map( step => step?.usage )
22
+ .filter( Boolean )
23
+ .reduce( ( total, usage ) => mergeUsage( total, usage ), {} );
package/src/utils/wrap.js CHANGED
@@ -1,12 +1,10 @@
1
1
  import { extractSources } from './sources.js';
2
- import { parseLLMUsage } from './usage.js';
3
- import { calculateCosts } from './cost.js';
4
- import { calculateBase64FileSize } from './image.js';
5
- import { Tracing, Event } from '@outputai/core/sdk/runtime';
2
+ import { serializeImagesFromResponse } from './image.js';
3
+ import { Tracing } from '@outputai/core/sdk/runtime';
6
4
  import { mapAiError } from './error_handler.js';
7
5
  import { isPromise } from 'node:util/types';
8
- import { Logger } from '@outputai/core';
9
- import { convertCostToLegacy } from './legacy_cost_attribute.js';
6
+ import { FatalError, Logger } from '@outputai/core';
7
+ import { Metering } from './metering.js';
10
8
  import { randomBytes } from 'node:crypto';
11
9
 
12
10
  /** Creates a proxy of the AI SDK response and attach virtual getters to it based on an object map */
@@ -23,44 +21,93 @@ const startTrace = ( { name, prompt } ) => {
23
21
  return traceId;
24
22
  };
25
23
 
26
- /** Map AI Error, add an error trace entry and return the new error */
27
- const handleError = ( { traceId, error: originalError } ) => {
24
+ /** Handle AI SDK errors: map them, add an error trace entry and return the new error */
25
+ const handleAiSdkError = ( { traceId, error: originalError } ) => {
28
26
  const error = mapAiError( originalError );
29
27
  Tracing.addEventError( { id: traceId, details: error } );
30
28
  return error;
31
29
  };
32
30
 
33
- /** Normalize raw AI SDK usage, calculate cost, attach trace attributes, and emit metering events */
34
- const handleMetering = async ( { traceId, usage: sdkUsage, prompt, steps } ) => {
35
- const usageAttribute = parseLLMUsage( { usage: sdkUsage, prompt, steps } );
36
- if ( !usageAttribute ) {
37
- return null;
31
+ /**
32
+ * Handle a failure of our own response handling: wrap it, add an error trace entry and return it.
33
+ * Fatal on purpose - such a failure is deterministic, so a retry pays for the model call again and
34
+ * fails the same way.
35
+ */
36
+ const handleResponseError = ( { traceId, error: cause } ) => {
37
+ const error = new FatalError( 'AI SDK response handling failed.', { cause } );
38
+ Tracing.addEventError( { id: traceId, details: error } );
39
+ return error;
40
+ };
41
+
42
+ /** Invokes an async function, wrapped in a try/catch, return { result, error } */
43
+ const inlineTryAsync = async fn => {
44
+ try {
45
+ return { result: await fn(), error: null };
46
+ } catch ( error ) {
47
+ return { result: null, error };
38
48
  }
49
+ };
39
50
 
40
- const costAttribute = await calculateCosts( usageAttribute );
41
- if ( costAttribute ) {
42
- Tracing.addEventAttribute( { eventId: traceId, attribute: costAttribute } );
43
- // @TEMP Preserve the deprecated event and trace attribute for legacy consumers.
44
- const legacyPayload = convertCostToLegacy( costAttribute );
45
- if ( legacyPayload ) {
46
- Event.emit( 'cost:llm:request', structuredClone( legacyPayload ) );
47
- Tracing.addEventAttribute( { eventId: traceId, attribute: legacyPayload } );
48
- }
51
+ /**
52
+ * Runs a completing AI SDK text call (`generateText`, `generateTextWithStreaming`,
53
+ * `Agent.generate`, `Agent.generateWithStreaming`): starts the LLM trace, runs `fn`, ends the trace
54
+ * and returns the response proxied with `result` (`text`), `cost` and merged `sources`.
55
+ *
56
+ * `fn` receives `onStepEndHook` to wire into the SDK `onStepEnd`, so the usage of every completed
57
+ * step is collected as the call progresses and still lands when `fn` throws after the model ran.
58
+ * The response `fn` returns is billed directly, and its aggregate usage takes precedence over the
59
+ * collected steps. Trace output keeps raw `response.usage`; normalized usage and cost are trace
60
+ * attributes.
61
+ *
62
+ * Reading the billed response is guarded: a throw from `fn` is mapped as an SDK error, while a
63
+ * failure of our own handling becomes a `FatalError` recorded on the trace. The proxy is built
64
+ * before the trace ends, so a call that fails there never leaves an `end` entry next to an `error`.
65
+ *
66
+ * @param {object} args
67
+ * @param {string} args.name - Trace event name
68
+ * @param {object} args.prompt - Loaded prompt (`config.provider` / `config.model` used for cost)
69
+ * @param {( wiring: { onStepEndHook: Function } ) => Promise<object>} args.fn - AI SDK call; wire
70
+ * `onStepEndHook` into the call `onStepEnd` and return the SDK response
71
+ * @returns {Promise<object>} Proxied SDK response
72
+ */
73
+ export const wrapTextGeneration = async ( { name, prompt, fn } ) => {
74
+ const traceId = startTrace( { name, prompt } );
75
+
76
+ const metering = new Metering( { traceId, prompt } );
77
+
78
+ const onStepEndHook = metering.recordStep;
79
+
80
+ const { result: response, error } = await inlineTryAsync( () => fn( { onStepEndHook } ) );
81
+
82
+ await metering.bill( response );
83
+
84
+ if ( error ) {
85
+ throw handleAiSdkError( { traceId, error } );
49
86
  }
50
87
 
51
- Tracing.addEventAttribute( { eventId: traceId, attribute: usageAttribute } );
52
- Event.emit( 'llm:generation:metering', structuredClone( { cost: costAttribute, usage: usageAttribute } ) );
53
- return costAttribute;
88
+ try {
89
+ const { text: result, finalStep, usage } = response;
90
+ const providerMetadata = finalStep?.providerMetadata;
91
+ const sources = extractSources( response );
92
+
93
+ // Create proxy first, as this could theoretically fail
94
+ const responseProxy = createResponseProxy( { response, properties: { cost: metering.attributes.cost, sources, result } } );
95
+ Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, sources } } );
96
+
97
+ return responseProxy;
98
+ } catch ( error ) {
99
+ throw handleResponseError( { traceId, error } );
100
+ }
54
101
  };
55
102
 
56
103
  /**
57
- * Runs a completing AI SDK call (`generateText`, `generateImage`, `generateTextWithStreaming`,
58
- * `Agent.generate`, `Agent.generateWithStreaming`): start the LLM trace, run `fn`, attach cost
59
- * (and sources on text), end the trace, and return a proxied response.
104
+ * Runs `generateImage`: starts the LLM trace, meters the returned usage, ends the trace and returns
105
+ * the response proxied with `result` (the first `image`) and `cost`.
60
106
  *
61
- * Text responses get `result` (`text`), `cost`, and merged `sources`. Image responses get
62
- * `result` (`image`) and `cost`. Trace output keeps raw `response.usage`; normalized usage and
63
- * cost are trace attributes.
107
+ * Image calls expose no step lifecycle, so usage is read from the response. With no
108
+ * step loop and no output parsing, a throw from `fn` happens before any usage exists, so it is
109
+ * mapped and recorded without metering. Reading the billed response is guarded the same way
110
+ * `wrapTextGeneration` guards it: a failure there is a `FatalError` recorded after billing.
64
111
  *
65
112
  * @param {object} args
66
113
  * @param {string} args.name - Trace event name
@@ -68,131 +115,162 @@ const handleMetering = async ( { traceId, usage: sdkUsage, prompt, steps } ) =>
68
115
  * @param {() => Promise<object>} args.fn - AI SDK call; must return the SDK response
69
116
  * @returns {Promise<object>} Proxied SDK response
70
117
  */
71
- export const wrapGeneration = async ( { name, prompt, fn } ) => {
118
+ export const wrapImageGeneration = async ( { name, prompt, fn } ) => {
72
119
  const traceId = startTrace( { name, prompt } );
73
120
 
74
- try {
75
- const response = await fn();
76
- const { usage } = response;
77
- const cost = await handleMetering( { traceId, usage, prompt, steps: response.steps } );
121
+ const metering = new Metering( { traceId, prompt } );
78
122
 
79
- if ( Array.isArray( response.images ) ) {
80
- const { image, images, providerMetadata } = response;
81
- const mappedImages = images.map( ( { mediaType, base64 } ) => ( { size: calculateBase64FileSize( base64 ), mediaType } ) );
123
+ const { result: response, error } = await inlineTryAsync( fn );
82
124
 
83
- Tracing.addEventEnd( { id: traceId, details: { result: mappedImages, usage, providerMetadata } } );
84
- return createResponseProxy( { response, properties: { cost, result: image } } );
85
- } else {
86
- const { text: result, finalStep } = response;
87
- const { providerMetadata } = finalStep;
88
- const sources = extractSources( response );
125
+ if ( error ) {
126
+ throw handleAiSdkError( { traceId, error } );
127
+ }
89
128
 
90
- Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, sources } } );
91
- return createResponseProxy( { response, properties: { cost, sources, result } } );
92
- }
129
+ await metering.bill( response );
130
+
131
+ try {
132
+ const { usage, image: result, providerMetadata } = response;
133
+ const serializeImages = serializeImagesFromResponse( response );
93
134
 
135
+ // Create proxy first, as this could theoretically fail
136
+ const responseProxy = createResponseProxy( { response, properties: { cost: metering.attributes.cost, result } } );
137
+
138
+ Tracing.addEventEnd( { id: traceId, details: { result: serializeImages, usage, providerMetadata } } );
139
+ return responseProxy;
94
140
  } catch ( error ) {
95
- throw handleError( { traceId, error } );
141
+ throw handleResponseError( { traceId, error } );
142
+ }
143
+ };
144
+
145
+ /** Awaits a consumer callback, logging and swallowing its failures: the hooks are fire and forget */
146
+ const invokeCallback = async ( cb, args, cbName ) => {
147
+ if ( typeof cb === 'function' ) {
148
+ try {
149
+ await cb( ...args );
150
+ } catch ( e ) {
151
+ Logger.error( `Stream ${cbName}() callback failed`, { namespace: 'LLM', error: e?.message || String( e ) } );
152
+ }
96
153
  }
97
154
  };
98
155
 
99
156
  /**
100
157
  * Starts an LLM trace around a live AI SDK stream (`streamText`, `Agent.stream`).
101
158
  *
102
- * `fn` receives `onEndHook(response, callback)` and `onErrorHook(event, callback)`. Call
103
- * `onEndHook` from the SDK `onEnd`: it wraps the response, ends the trace, then invokes
104
- * `callback` without rethrowing. Call `onErrorHook` from SDK `onError`: it maps the error,
105
- * records it, and invokes `callback` without rethrowing. Callback failures are logged.
159
+ * `fn` receives `onEndHook(response, callback)` and `onErrorHook(event, callback)` to wire into the
160
+ * SDK `onEnd` / `onError`, plus `onStepEndHook` and `onAbortHook` for `onStepEnd` / `onAbort`. Both
161
+ * terminal hooks bill, end or record the trace, then invoke `callback`; a `callback` that is not a
162
+ * function is skipped, and callback failures are logged, never rethrown.
163
+ *
164
+ * Neither hook throws, because the SDK invokes them through `notify`, which swallows callback
165
+ * failures and discards their return: a failure while reading the response would vanish, so it is
166
+ * logged and recorded as a trace error here instead. An `onError`
167
+ * event carrying no `Error` is normalized to `Streaming failed.` with the original value as `cause`,
168
+ * so the trace always closes and the consumer callback always runs.
169
+ *
170
+ * Metering is fed by `onStepEndHook`, which collects step usage so the tokens survive the exits
171
+ * that report no response: `NoOutputGeneratedError` skips `onEnd`, and an abort fires neither
172
+ * terminal hook. `onAbortHook` bills that case, and the SDK awaits it before enqueueing the abort
173
+ * part, so billing from there always lands before the consumer observes the cancellation.
106
174
  *
107
- * A throw or rejected Promise from `fn` (stream creation / Agent setup) is mapped and recorded
108
- * on the LLM trace. A non-Promise return (the `streamText` stream) is returned immediately.
109
- * When `abortSignal` aborts, its reason is recorded as an LLM trace error. The listener is removed
110
- * when the stream ends, reports an error, or fails during setup.
175
+ * A throw or rejected Promise from `fn` (stream creation / Agent setup) is mapped and recorded on
176
+ * the trace; a non-Promise return (the `streamText` stream) is returned as is, with a rejection of
177
+ * its `output` promise recorded without being swallowed from the consumer. An abort on
178
+ * `abortSignal` records its reason as a trace error, and the listener is removed when the stream
179
+ * ends, reports an error, or fails during setup. An abort the SDK raises on its own timeout leaves
180
+ * `abortSignal` untouched, so `onAbort` records that one instead.
111
181
  *
112
182
  * @param {object} args
113
183
  * @param {string} args.name - Trace event name
114
184
  * @param {object} args.prompt - Loaded prompt (`config.provider` / `config.model` used for cost)
115
185
  * @param {AbortSignal} [args.abortSignal] - Optional signal used to trace stream cancellation
116
- * @param {(hooks: { onEndHook: Function, onErrorHook: Function }) => object | Promise<object>} args.fn -
117
- * Return the SDK stream, or a Promise of that stream (`Agent.stream`); wire the hooks into SDK
118
- * `onEnd` / `onError`
186
+ * @param {( wiring: { onEndHook: Function, onErrorHook: Function, onStepEndHook: Function,
187
+ * onAbortHook: Function } ) => object | Promise<object>} args.fn - Wire the hooks into the call
188
+ * and return the SDK stream, or a Promise of that stream (`Agent.stream`)
119
189
  * @returns {object | Promise<object>} Value returned by `fn`; a rejected Promise is remapped
120
190
  */
121
191
  export const wrapStream = ( { name, prompt, abortSignal, fn } ) => {
122
192
  const traceId = startTrace( { name, prompt } );
123
-
124
- const onAbortHook = reason =>
125
- handleError( {
126
- traceId,
127
- error: reason instanceof Error ? reason : new Error( 'Streaming aborted.', { cause: reason } )
128
- } );
193
+ const metering = new Metering( { traceId, prompt } );
129
194
 
130
195
  const handleAbort = () => {
131
- if ( !abortSignal ) {
132
- return () => {};
133
- }
196
+ const error = abortSignal?.reason instanceof Error ? abortSignal.reason : new Error( 'Streaming aborted.', { cause: abortSignal?.reason } );
197
+ handleAiSdkError( { traceId, error } );
198
+ };
134
199
 
200
+ if ( abortSignal ) {
135
201
  if ( abortSignal.aborted ) {
136
- onAbortHook( abortSignal.reason );
137
- return () => {};
202
+ handleAbort();
203
+ } else {
204
+ abortSignal.addEventListener( 'abort', handleAbort, { once: true } );
138
205
  }
206
+ }
207
+ const removeAbortListener = () => abortSignal?.removeEventListener( 'abort', handleAbort );
139
208
 
140
- const listener = () => onAbortHook( abortSignal.reason );
141
- abortSignal.addEventListener( 'abort', listener, { once: true } );
209
+ const onStepEndHook = metering.recordStep;
142
210
 
143
- return () => abortSignal.removeEventListener( 'abort', listener );
144
- };
211
+ const onAbortHook = async () => {
212
+ removeAbortListener();
145
213
 
146
- const removeAbortListener = handleAbort();
214
+ await metering.bill();
215
+ // abortion was caused by timeout and not signal
216
+ if ( !abortSignal?.aborted ) {
217
+ handleAiSdkError( { traceId, error: new Error( 'Streaming timed out.' ) } );
218
+ }
219
+ };
147
220
 
148
221
  const onEndHook = async ( response, callback ) => {
149
- const state = { proxyResponse: null };
150
222
  removeAbortListener();
223
+
224
+ await metering.bill( response );
225
+
151
226
  try {
152
- const { text: result, finalStep, usage, steps } = response;
153
- const { providerMetadata } = finalStep;
154
- const cost = await handleMetering( { traceId, usage, prompt, steps } );
227
+ const { text: result, finalStep, usage } = response;
228
+ const providerMetadata = finalStep?.providerMetadata;
155
229
  const sources = extractSources( response );
230
+
231
+ const proxyResponse = createResponseProxy( { response, properties: { cost: metering.attributes.cost, sources, result } } );
156
232
  Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata, sources } } );
157
- state.proxyResponse = createResponseProxy( { response, properties: { cost, sources, result } } );
158
- } catch ( error ) {
159
- Logger.error( 'Stream onEnd() handler failed', { namespace: 'LLM', error: error?.message ?? String( error ) } );
160
- throw handleError( { traceId, error } );
161
- }
162
233
 
163
- try {
164
- await callback?.( state.proxyResponse );
165
- } catch ( callbackError ) {
166
- // ignore these as this callback is fire and forget
167
- Logger.error( 'Stream onEnd() callback failed', {
168
- namespace: 'LLM',
169
- error: callbackError instanceof Error ? callbackError.message : String( callbackError )
170
- } );
234
+ await invokeCallback( callback, [ proxyResponse ], 'onEnd' );
235
+ } catch ( error ) {
236
+ Logger.error( 'AI SDK response handling failed', { namespace: 'LLM', error: error?.message || String( error ) } );
237
+ handleResponseError( { traceId, error } );
171
238
  }
172
239
  };
173
240
 
174
241
  const onErrorHook = async ( event, callback ) => {
175
- const error = handleError( { traceId, error: event.error } );
176
242
  removeAbortListener();
243
+ await metering.bill();
244
+
245
+ const error = event?.error instanceof Error ? event.error : new Error( 'Streaming failed.', { cause: event?.error } );
246
+ const mappedError = handleAiSdkError( { traceId, error } );
247
+ await invokeCallback( callback, [ mappedError ], 'onError' );
248
+ };
249
+
250
+ const handleStreamError = error => {
251
+ removeAbortListener();
252
+ return handleAiSdkError( { traceId, error } );
253
+ };
254
+
255
+ const addStreamOutputErrorHandler = stream => {
256
+ stream.output?.catch( error => handleAiSdkError( { traceId, error } ) );
257
+ return stream;
258
+ };
259
+
260
+ const createStream = () => {
177
261
  try {
178
- await callback?.( error );
179
- } catch ( callbackError ) {
180
- // ignore these as this callback is fire and forget
181
- Logger.error( 'Stream onError() callback failed', {
182
- namespace: 'LLM',
183
- error: callbackError instanceof Error ? callbackError.message : String( callbackError )
184
- } );
262
+ return fn( { onEndHook, onErrorHook, onStepEndHook, onAbortHook } );
263
+ } catch ( error ) {
264
+ throw handleStreamError( error );
185
265
  }
186
266
  };
187
267
 
188
- try {
189
- const stream = fn( { onEndHook, onErrorHook } );
190
- return isPromise( stream ) ? stream.catch( error => {
191
- removeAbortListener();
192
- throw handleError( { traceId, error } );
193
- } ) : stream;
194
- } catch ( error ) {
195
- removeAbortListener();
196
- throw handleError( { traceId, error } );
197
- }
268
+ const stream = createStream();
269
+
270
+ /** Streams can be a promise or not, for both cases add the handler to catch .output errors, and if it is a promise add a .catch handler to observe errors there as well */
271
+ return isPromise( stream ) ?
272
+ stream.then( addStreamOutputErrorHandler ).catch( e => {
273
+ throw handleStreamError( e );
274
+ } ) :
275
+ addStreamOutputErrorHandler( stream );
198
276
  };