@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 +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/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.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 {
|
|
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
|
+
}
|
package/src/utils/sources.js
CHANGED
|
@@ -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
|
|
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<{
|
|
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 =>
|
|
32
|
-
.map(
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
};
|
package/src/utils/stream.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
};
|
package/src/utils/usage.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
66
|
-
* @param {
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
27
|
-
const
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
58
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
118
|
+
export const wrapImageGeneration = async ( { name, prompt, fn } ) => {
|
|
72
119
|
const traceId = startTrace( { name, prompt } );
|
|
73
120
|
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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)
|
|
103
|
-
* `
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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 {(
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
202
|
+
handleAbort();
|
|
203
|
+
} else {
|
|
204
|
+
abortSignal.addEventListener( 'abort', handleAbort, { once: true } );
|
|
138
205
|
}
|
|
206
|
+
}
|
|
207
|
+
const removeAbortListener = () => abortSignal?.removeEventListener( 'abort', handleAbort );
|
|
139
208
|
|
|
140
|
-
|
|
141
|
-
abortSignal.addEventListener( 'abort', listener, { once: true } );
|
|
209
|
+
const onStepEndHook = metering.recordStep;
|
|
142
210
|
|
|
143
|
-
|
|
144
|
-
|
|
211
|
+
const onAbortHook = async () => {
|
|
212
|
+
removeAbortListener();
|
|
145
213
|
|
|
146
|
-
|
|
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
|
|
153
|
-
const
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
179
|
-
} catch (
|
|
180
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
};
|