@outputai/llm 0.13.1-next.83d37f1.0 → 0.13.1-next.a4f6bd4.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.1-next.83d37f1.0",
3
+ "version": "0.13.1-next.a4f6bd4.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.1-next.83d37f1.0"
25
+ "@outputai/core": "0.13.1-next.a4f6bd4.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
+ }