@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 +2 -2
- package/src/agent.js +15 -12
- package/src/generate.js +14 -8
- package/src/utils/error_handler.js +4 -0
- package/src/utils/image.js +27 -0
- package/src/utils/metering.js +125 -0
- package/src/utils/models_pricing_snapshot.json +151 -98
- package/src/utils/sources.js +35 -10
- package/src/utils/stream.js +64 -11
- package/src/utils/usage.js +35 -14
- package/src/utils/usage_tools.js +23 -0
- package/src/utils/wrap.js +189 -111
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/llm",
|
|
3
|
-
"version": "0.13.1-next.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
|
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( '
|
|
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 =>
|
|
128
|
-
namespace: 'LLM',
|
|
129
|
-
|
|
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 {
|
|
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
|
|
14
|
+
return wrapTextGeneration( {
|
|
15
15
|
name: 'generateText',
|
|
16
16
|
prompt,
|
|
17
|
-
fn: () => AI.generateText(
|
|
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
|
|
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
|
|
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
|
|
83
|
+
return wrapImageGeneration( {
|
|
78
84
|
name: 'generateImage',
|
|
79
85
|
prompt,
|
|
80
86
|
fn: () => AI.generateImage( loadAiSdkImageOptions( { prompt, ...aiOptions } ) )
|
package/src/utils/image.js
CHANGED
|
@@ -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
|
+
}
|