@outputai/llm 0.6.1-dev.aab2335.0 → 0.6.1-next.0d08ff5.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 -9
- package/src/agent.spec.js +295 -214
- package/src/ai_model.js +79 -36
- package/src/ai_model.spec.js +31 -13
- package/src/ai_sdk.js +55 -79
- package/src/ai_sdk.spec.js +464 -611
- package/src/ai_sdk_options.js +61 -0
- package/src/ai_sdk_options.spec.js +164 -0
- package/src/cost/index.js +1 -1
- package/src/index.d.ts +230 -175
- package/src/index.js +2 -2
- package/src/prompt/escape.js +65 -0
- package/src/prompt/escape.spec.js +159 -0
- package/src/{load_content.js → prompt/load_content.js} +1 -22
- package/src/{load_content.spec.js → prompt/load_content.spec.js} +6 -6
- package/src/prompt/loader.js +49 -0
- package/src/prompt/loader.spec.js +274 -0
- package/src/{prompt_loader_validation.spec.js → prompt/loader_validation.spec.js} +40 -7
- package/src/prompt/parser.js +19 -0
- package/src/{parser.spec.js → prompt/parser.spec.js} +74 -29
- package/src/prompt/prepare_text.js +27 -0
- package/src/prompt/prepare_text.spec.js +141 -0
- package/src/{skill.js → prompt/skill.js} +19 -0
- package/src/prompt/skill.spec.js +172 -0
- package/src/{prompt_validations.js → prompt/validations.js} +32 -6
- package/src/{prompt_validations.spec.js → prompt/validations.spec.js} +189 -1
- package/src/utils/__fixtures__/image_response.json +38 -0
- package/src/utils/__fixtures__/stream_response.json +294 -0
- package/src/utils/__fixtures__/text_response.json +201 -0
- package/src/utils/error_handler.js +65 -0
- package/src/utils/error_handler.spec.js +195 -0
- package/src/utils/image.js +10 -0
- package/src/utils/image.spec.js +20 -0
- package/src/utils/response_wrappers.js +46 -19
- package/src/utils/response_wrappers.spec.js +130 -70
- package/src/utils/source_extraction.js +17 -27
- package/src/utils/trace.js +2 -3
- package/src/utils/trace.spec.js +9 -13
- package/src/validations.js +54 -2
- package/src/validations.spec.js +166 -0
- package/src/parser.js +0 -28
- package/src/prompt_loader.js +0 -80
- package/src/prompt_loader.spec.js +0 -358
- package/src/skill.d.ts +0 -49
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
{
|
|
2
|
+
"steps": [
|
|
3
|
+
{
|
|
4
|
+
"stepNumber": 0,
|
|
5
|
+
"model": {
|
|
6
|
+
"provider": "anthropic.messages",
|
|
7
|
+
"modelId": "claude-haiku-4-5"
|
|
8
|
+
},
|
|
9
|
+
"content": [
|
|
10
|
+
{
|
|
11
|
+
"type": "text",
|
|
12
|
+
"text": "{\"result\": \"yes\"}"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"finishReason": "stop",
|
|
16
|
+
"rawFinishReason": "end_turn",
|
|
17
|
+
"usage": {
|
|
18
|
+
"inputTokens": 217,
|
|
19
|
+
"inputTokenDetails": {
|
|
20
|
+
"noCacheTokens": 217,
|
|
21
|
+
"cacheReadTokens": 0,
|
|
22
|
+
"cacheWriteTokens": 0
|
|
23
|
+
},
|
|
24
|
+
"outputTokens": 9,
|
|
25
|
+
"outputTokenDetails": {},
|
|
26
|
+
"totalTokens": 226,
|
|
27
|
+
"raw": {
|
|
28
|
+
"input_tokens": 217,
|
|
29
|
+
"output_tokens": 9,
|
|
30
|
+
"cache_creation_input_tokens": 0,
|
|
31
|
+
"cache_read_input_tokens": 0,
|
|
32
|
+
"cache_creation": {
|
|
33
|
+
"ephemeral_5m_input_tokens": 0,
|
|
34
|
+
"ephemeral_1h_input_tokens": 0
|
|
35
|
+
},
|
|
36
|
+
"service_tier": "standard",
|
|
37
|
+
"inference_geo": "not_available"
|
|
38
|
+
},
|
|
39
|
+
"cachedInputTokens": 0
|
|
40
|
+
},
|
|
41
|
+
"warnings": [],
|
|
42
|
+
"request": {
|
|
43
|
+
"body": {
|
|
44
|
+
"model": "claude-haiku-4-5",
|
|
45
|
+
"max_tokens": 64000,
|
|
46
|
+
"temperature": 0.7,
|
|
47
|
+
"output_config": {
|
|
48
|
+
"format": {
|
|
49
|
+
"type": "json_schema",
|
|
50
|
+
"schema": {
|
|
51
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"result": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"enum": [
|
|
57
|
+
"yes",
|
|
58
|
+
"no"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"required": [
|
|
63
|
+
"result"
|
|
64
|
+
],
|
|
65
|
+
"additionalProperties": false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"messages": [
|
|
70
|
+
{
|
|
71
|
+
"role": "assistant",
|
|
72
|
+
"content": [
|
|
73
|
+
{
|
|
74
|
+
"type": "text",
|
|
75
|
+
"text": "You are a concise assistant."
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"role": "user",
|
|
81
|
+
"content": [
|
|
82
|
+
{
|
|
83
|
+
"type": "text",
|
|
84
|
+
"text": "Do humans like carburetors?"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"response": {
|
|
92
|
+
"id": "msg_01VQv2YKP97yEn4kin5Xvn6v",
|
|
93
|
+
"timestamp": "2026-06-04T15:30:32.333Z",
|
|
94
|
+
"modelId": "claude-haiku-4-5-20251001",
|
|
95
|
+
"headers": {
|
|
96
|
+
"__redacted__": "<<headers were removed>>"
|
|
97
|
+
},
|
|
98
|
+
"body": {
|
|
99
|
+
"model": "claude-haiku-4-5-20251001",
|
|
100
|
+
"id": "msg_01VQv2YKP97yEn4kin5Xvn6v",
|
|
101
|
+
"type": "message",
|
|
102
|
+
"role": "assistant",
|
|
103
|
+
"content": [
|
|
104
|
+
{
|
|
105
|
+
"type": "text",
|
|
106
|
+
"text": "{\"result\": \"yes\"}"
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
"stop_reason": "end_turn",
|
|
110
|
+
"stop_sequence": null,
|
|
111
|
+
"stop_details": null,
|
|
112
|
+
"usage": {
|
|
113
|
+
"input_tokens": 217,
|
|
114
|
+
"cache_creation_input_tokens": 0,
|
|
115
|
+
"cache_read_input_tokens": 0,
|
|
116
|
+
"cache_creation": {
|
|
117
|
+
"ephemeral_5m_input_tokens": 0,
|
|
118
|
+
"ephemeral_1h_input_tokens": 0
|
|
119
|
+
},
|
|
120
|
+
"output_tokens": 9,
|
|
121
|
+
"service_tier": "standard",
|
|
122
|
+
"inference_geo": "not_available"
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"messages": [
|
|
126
|
+
{
|
|
127
|
+
"role": "assistant",
|
|
128
|
+
"content": [
|
|
129
|
+
{
|
|
130
|
+
"type": "text",
|
|
131
|
+
"text": "{\"result\": \"yes\"}"
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
},
|
|
137
|
+
"providerMetadata": {
|
|
138
|
+
"anthropic": {
|
|
139
|
+
"usage": {
|
|
140
|
+
"input_tokens": 217,
|
|
141
|
+
"output_tokens": 9,
|
|
142
|
+
"cache_creation_input_tokens": 0,
|
|
143
|
+
"cache_read_input_tokens": 0,
|
|
144
|
+
"cache_creation": {
|
|
145
|
+
"ephemeral_5m_input_tokens": 0,
|
|
146
|
+
"ephemeral_1h_input_tokens": 0
|
|
147
|
+
},
|
|
148
|
+
"service_tier": "standard",
|
|
149
|
+
"inference_geo": "not_available"
|
|
150
|
+
},
|
|
151
|
+
"cacheCreationInputTokens": 0,
|
|
152
|
+
"stopSequence": null,
|
|
153
|
+
"iterations": null,
|
|
154
|
+
"container": null,
|
|
155
|
+
"contextManagement": null
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
"toolCalls": [
|
|
159
|
+
{
|
|
160
|
+
"type": "tool-call",
|
|
161
|
+
"toolCallId": "call_fixture_web_search",
|
|
162
|
+
"toolName": "webSearch",
|
|
163
|
+
"input": {
|
|
164
|
+
"query": "carburetor reference article"
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
"toolResults": [
|
|
169
|
+
{
|
|
170
|
+
"type": "tool-result",
|
|
171
|
+
"toolCallId": "call_fixture_web_search",
|
|
172
|
+
"toolName": "webSearch",
|
|
173
|
+
"input": {
|
|
174
|
+
"query": "carburetor reference article"
|
|
175
|
+
},
|
|
176
|
+
"output": {
|
|
177
|
+
"results": [
|
|
178
|
+
{
|
|
179
|
+
"title": "Carburetor reference article",
|
|
180
|
+
"url": "https://example.com/carburetor-reference"
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
"_output": "yes",
|
|
189
|
+
"totalUsage": {
|
|
190
|
+
"inputTokens": 217,
|
|
191
|
+
"inputTokenDetails": {
|
|
192
|
+
"noCacheTokens": 217,
|
|
193
|
+
"cacheReadTokens": 0,
|
|
194
|
+
"cacheWriteTokens": 0
|
|
195
|
+
},
|
|
196
|
+
"outputTokens": 9,
|
|
197
|
+
"outputTokenDetails": {},
|
|
198
|
+
"totalTokens": 226,
|
|
199
|
+
"cachedInputTokens": 0
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APICallError,
|
|
3
|
+
InvalidArgumentError,
|
|
4
|
+
InvalidDataContentError,
|
|
5
|
+
InvalidPromptError,
|
|
6
|
+
LoadAPIKeyError,
|
|
7
|
+
LoadSettingError,
|
|
8
|
+
NoImageGeneratedError,
|
|
9
|
+
NoSuchModelError,
|
|
10
|
+
NoSuchProviderError,
|
|
11
|
+
UnsupportedFunctionalityError
|
|
12
|
+
} from 'ai';
|
|
13
|
+
import { FatalError } from '@outputai/core';
|
|
14
|
+
|
|
15
|
+
const toFatalError = ( error, extraMessage = '' ) => new FatalError(
|
|
16
|
+
`AI-SDK fatal error${extraMessage ? ` (${extraMessage})` : ''}: ${error.message}`,
|
|
17
|
+
{ cause: error }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const mapAiError = error => {
|
|
21
|
+
if ( error instanceof FatalError ) {
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
if ( APICallError.isInstance( error ) && !error.isRetryable ) {
|
|
25
|
+
// Non-retryable API failures are already classified by AI SDK as permanent provider failures.
|
|
26
|
+
return toFatalError( error, error.statusCode ? `HTTP ${error.statusCode}` : '' );
|
|
27
|
+
}
|
|
28
|
+
if ( InvalidArgumentError.isInstance( error ) ) {
|
|
29
|
+
// Invalid call settings are deterministic caller bugs, so retrying the same activity cannot fix them.
|
|
30
|
+
return toFatalError( error );
|
|
31
|
+
}
|
|
32
|
+
if ( InvalidDataContentError.isInstance( error ) ) {
|
|
33
|
+
// Invalid media content has the wrong local shape/encoding and will fail again with the same input.
|
|
34
|
+
return toFatalError( error );
|
|
35
|
+
}
|
|
36
|
+
if ( InvalidPromptError.isInstance( error ) ) {
|
|
37
|
+
// Invalid prompt structure is a deterministic request-construction error.
|
|
38
|
+
return toFatalError( error );
|
|
39
|
+
}
|
|
40
|
+
if ( LoadAPIKeyError.isInstance( error ) ) {
|
|
41
|
+
// Missing or invalid API key configuration will not change during an activity retry.
|
|
42
|
+
return toFatalError( error );
|
|
43
|
+
}
|
|
44
|
+
if ( LoadSettingError.isInstance( error ) ) {
|
|
45
|
+
// Missing or invalid provider settings are deployment/configuration problems.
|
|
46
|
+
return toFatalError( error );
|
|
47
|
+
}
|
|
48
|
+
if ( NoImageGeneratedError.isInstance( error ) ) {
|
|
49
|
+
// Image generation completed provider calls but collected zero images; repeating identical input is not useful.
|
|
50
|
+
return toFatalError( error );
|
|
51
|
+
}
|
|
52
|
+
if ( NoSuchProviderError.isInstance( error ) ) {
|
|
53
|
+
// A missing provider id is a deterministic provider registry/configuration error.
|
|
54
|
+
return toFatalError( error );
|
|
55
|
+
}
|
|
56
|
+
if ( NoSuchModelError.isInstance( error ) ) {
|
|
57
|
+
// A missing model id is a deterministic provider/model configuration error.
|
|
58
|
+
return toFatalError( error );
|
|
59
|
+
}
|
|
60
|
+
if ( UnsupportedFunctionalityError.isInstance( error ) ) {
|
|
61
|
+
// The selected model/output mode does not support the requested feature.
|
|
62
|
+
return toFatalError( error );
|
|
63
|
+
}
|
|
64
|
+
return error;
|
|
65
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
APICallError,
|
|
4
|
+
InvalidArgumentError,
|
|
5
|
+
InvalidDataContentError,
|
|
6
|
+
InvalidMessageRoleError,
|
|
7
|
+
InvalidPromptError,
|
|
8
|
+
InvalidToolApprovalError,
|
|
9
|
+
InvalidToolInputError,
|
|
10
|
+
LoadAPIKeyError,
|
|
11
|
+
LoadSettingError,
|
|
12
|
+
MessageConversionError,
|
|
13
|
+
NoImageGeneratedError,
|
|
14
|
+
NoOutputGeneratedError,
|
|
15
|
+
NoObjectGeneratedError,
|
|
16
|
+
NoSuchModelError,
|
|
17
|
+
NoSuchProviderError,
|
|
18
|
+
ToolCallNotFoundForApprovalError,
|
|
19
|
+
ToolCallRepairError,
|
|
20
|
+
UnsupportedFunctionalityError
|
|
21
|
+
} from 'ai';
|
|
22
|
+
import { FatalError } from '@outputai/core';
|
|
23
|
+
import { mapAiError } from './error_handler.js';
|
|
24
|
+
|
|
25
|
+
const makeApiCallError = ( input = {} ) => new APICallError( {
|
|
26
|
+
message: 'Provider rejected the request',
|
|
27
|
+
url: 'https://provider.test/v1/generate',
|
|
28
|
+
requestBodyValues: {},
|
|
29
|
+
responseHeaders: {},
|
|
30
|
+
responseBody: '{"error":"bad request"}',
|
|
31
|
+
...input
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
const fatalAiSdkErrors = [
|
|
35
|
+
[
|
|
36
|
+
'InvalidArgumentError',
|
|
37
|
+
() => new InvalidArgumentError( {
|
|
38
|
+
parameter: 'temperature',
|
|
39
|
+
value: 'hot',
|
|
40
|
+
message: 'temperature must be a number'
|
|
41
|
+
} )
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
'InvalidDataContentError',
|
|
45
|
+
() => new InvalidDataContentError( { content: { bad: true } } )
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
'InvalidPromptError',
|
|
49
|
+
() => new InvalidPromptError( { prompt: {}, message: 'prompt or messages must be defined' } )
|
|
50
|
+
],
|
|
51
|
+
[
|
|
52
|
+
'LoadAPIKeyError',
|
|
53
|
+
() => new LoadAPIKeyError( { message: 'Missing API key' } )
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'LoadSettingError',
|
|
57
|
+
() => new LoadSettingError( { message: 'Missing setting' } )
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
'NoImageGeneratedError',
|
|
61
|
+
() => new NoImageGeneratedError( { responses: [] } )
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
'NoSuchModelError',
|
|
65
|
+
() => new NoSuchModelError( { modelId: 'missing-model', modelType: 'languageModel' } )
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'NoSuchProviderError',
|
|
69
|
+
() => new NoSuchProviderError( {
|
|
70
|
+
modelId: 'missing-provider:model',
|
|
71
|
+
modelType: 'languageModel',
|
|
72
|
+
providerId: 'missing-provider',
|
|
73
|
+
availableProviders: [ 'openai' ]
|
|
74
|
+
} )
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
'UnsupportedFunctionalityError',
|
|
78
|
+
() => new UnsupportedFunctionalityError( { functionality: 'image masks' } )
|
|
79
|
+
]
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const preservedAiSdkErrors = [
|
|
83
|
+
[
|
|
84
|
+
'InvalidMessageRoleError',
|
|
85
|
+
() => new InvalidMessageRoleError( { role: 'critic' } )
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
'InvalidToolApprovalError',
|
|
89
|
+
() => new InvalidToolApprovalError( { approvalId: 'approval-1' } )
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
'InvalidToolInputError',
|
|
93
|
+
() => new InvalidToolInputError( {
|
|
94
|
+
toolName: 'search',
|
|
95
|
+
toolInput: '{bad json',
|
|
96
|
+
cause: new Error( 'parse failed' )
|
|
97
|
+
} )
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
'MessageConversionError',
|
|
101
|
+
() => new MessageConversionError( {
|
|
102
|
+
originalMessage: { role: 'critic', content: 'bad role' },
|
|
103
|
+
message: 'Unsupported role'
|
|
104
|
+
} )
|
|
105
|
+
],
|
|
106
|
+
[
|
|
107
|
+
'NoObjectGeneratedError',
|
|
108
|
+
() => new NoObjectGeneratedError( {
|
|
109
|
+
text: 'not json',
|
|
110
|
+
cause: new Error( 'parse failed' )
|
|
111
|
+
} )
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
'NoOutputGeneratedError',
|
|
115
|
+
() => new NoOutputGeneratedError()
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
'ToolCallNotFoundForApprovalError',
|
|
119
|
+
() => new ToolCallNotFoundForApprovalError( {
|
|
120
|
+
toolCallId: 'tool-call-1',
|
|
121
|
+
approvalId: 'approval-1'
|
|
122
|
+
} )
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
'ToolCallRepairError',
|
|
126
|
+
() => new ToolCallRepairError( {
|
|
127
|
+
cause: new Error( 'repair failed' ),
|
|
128
|
+
originalError: new Error( 'invalid tool input' )
|
|
129
|
+
} )
|
|
130
|
+
]
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
describe( 'mapAiError', () => {
|
|
134
|
+
it( 'preserves existing FatalError instances', () => {
|
|
135
|
+
const error = new FatalError( 'Already fatal' );
|
|
136
|
+
|
|
137
|
+
expect( mapAiError( error ) ).toBe( error );
|
|
138
|
+
} );
|
|
139
|
+
|
|
140
|
+
it( 'maps non-retryable APICallError instances to FatalError', () => {
|
|
141
|
+
const error = makeApiCallError( {
|
|
142
|
+
statusCode: 400,
|
|
143
|
+
isRetryable: false
|
|
144
|
+
} );
|
|
145
|
+
|
|
146
|
+
const result = mapAiError( error );
|
|
147
|
+
|
|
148
|
+
expect( result ).toBeInstanceOf( FatalError );
|
|
149
|
+
expect( result.message ).toBe( 'AI-SDK fatal error (HTTP 400): Provider rejected the request' );
|
|
150
|
+
expect( result.cause ).toBe( error );
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
it( 'maps non-retryable APICallError instances without status codes to FatalError', () => {
|
|
154
|
+
const error = makeApiCallError( {
|
|
155
|
+
isRetryable: false
|
|
156
|
+
} );
|
|
157
|
+
|
|
158
|
+
const result = mapAiError( error );
|
|
159
|
+
|
|
160
|
+
expect( result ).toBeInstanceOf( FatalError );
|
|
161
|
+
expect( result.message ).toBe( 'AI-SDK fatal error: Provider rejected the request' );
|
|
162
|
+
expect( result.cause ).toBe( error );
|
|
163
|
+
} );
|
|
164
|
+
|
|
165
|
+
it( 'preserves retryable APICallError instances', () => {
|
|
166
|
+
const error = makeApiCallError( {
|
|
167
|
+
statusCode: 429,
|
|
168
|
+
isRetryable: true
|
|
169
|
+
} );
|
|
170
|
+
|
|
171
|
+
expect( mapAiError( error ) ).toBe( error );
|
|
172
|
+
} );
|
|
173
|
+
|
|
174
|
+
it.each( fatalAiSdkErrors )( 'maps %s to FatalError', ( _name, makeError ) => {
|
|
175
|
+
const error = makeError();
|
|
176
|
+
|
|
177
|
+
const result = mapAiError( error );
|
|
178
|
+
|
|
179
|
+
expect( result ).toBeInstanceOf( FatalError );
|
|
180
|
+
expect( result.message ).toBe( `AI-SDK fatal error: ${error.message}` );
|
|
181
|
+
expect( result.cause ).toBe( error );
|
|
182
|
+
} );
|
|
183
|
+
|
|
184
|
+
it.each( preservedAiSdkErrors )( 'preserves %s for now', ( _name, makeError ) => {
|
|
185
|
+
const error = makeError();
|
|
186
|
+
|
|
187
|
+
expect( mapAiError( error ) ).toBe( error );
|
|
188
|
+
} );
|
|
189
|
+
|
|
190
|
+
it( 'preserves ordinary errors', () => {
|
|
191
|
+
const error = new Error( 'Network exploded' );
|
|
192
|
+
|
|
193
|
+
expect( mapAiError( error ) ).toBe( error );
|
|
194
|
+
} );
|
|
195
|
+
} );
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the approximate file size from a base64 string.
|
|
3
|
+
* @param {string} b64data
|
|
4
|
+
* @returns {number} Size in bytes
|
|
5
|
+
*/
|
|
6
|
+
export const calculateBase64FileSize = b64data => {
|
|
7
|
+
const baseSize = b64data.length * ( 3 / 4 );
|
|
8
|
+
const paddingSize = [ b64data.at( -2 ), b64data.at( -1 ) ].filter( v => v === '=' ).length;
|
|
9
|
+
return baseSize - paddingSize;
|
|
10
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateBase64FileSize } from './image.js';
|
|
3
|
+
|
|
4
|
+
describe( 'calculateBase64FileSize', () => {
|
|
5
|
+
it( 'calculates size for base64 without padding', () => {
|
|
6
|
+
expect( calculateBase64FileSize( 'TWFu' ) ).toBe( 3 );
|
|
7
|
+
} );
|
|
8
|
+
|
|
9
|
+
it( 'calculates size for base64 with one padding character', () => {
|
|
10
|
+
expect( calculateBase64FileSize( 'TWE=' ) ).toBe( 2 );
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
it( 'calculates size for base64 with two padding characters', () => {
|
|
14
|
+
expect( calculateBase64FileSize( 'TQ==' ) ).toBe( 1 );
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
it( 'returns zero for an empty string', () => {
|
|
18
|
+
expect( calculateBase64FileSize( '' ) ).toBe( 0 );
|
|
19
|
+
} );
|
|
20
|
+
} );
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { combineSources, extractSourcesFromSteps } from './source_extraction.js';
|
|
2
2
|
import { calculateLLMCallCost } from '../cost/index.js';
|
|
3
3
|
import { endTraceWithSuccess } from './trace.js';
|
|
4
|
+
import { calculateBase64FileSize } from './image.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Calculates the cost and wraps an AI SDK text response in a Proxy with shortcut for 'result' and 'cost'
|
|
@@ -16,10 +17,12 @@ import { endTraceWithSuccess } from './trace.js';
|
|
|
16
17
|
* @returns {object} Proxied response
|
|
17
18
|
*/
|
|
18
19
|
export const wrapTextResponse = async ( { traceId, modelId, response } ) => {
|
|
19
|
-
const
|
|
20
|
-
const cost = await calculateLLMCallCost( { usage: response.totalUsage, modelId } );
|
|
20
|
+
const { totalUsage: usage, providerMetadata, text: result, steps, sources } = response;
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
const cost = await calculateLLMCallCost( { usage, modelId } );
|
|
23
|
+
const sourcesFromTools = extractSourcesFromSteps( steps );
|
|
24
|
+
|
|
25
|
+
endTraceWithSuccess( { traceId, usage, cost, result, providerMetadata, sourcesFromTools } );
|
|
23
26
|
|
|
24
27
|
return new Proxy( response, {
|
|
25
28
|
get( target, prop, receiver ) {
|
|
@@ -30,7 +33,7 @@ export const wrapTextResponse = async ( { traceId, modelId, response } ) => {
|
|
|
30
33
|
return cost;
|
|
31
34
|
}
|
|
32
35
|
if ( prop === 'sources' && sourcesFromTools.length > 0 ) {
|
|
33
|
-
return combineSources( { sourcesFromTools, sourcesFromResponse:
|
|
36
|
+
return combineSources( { sourcesFromTools, sourcesFromResponse: sources } );
|
|
34
37
|
}
|
|
35
38
|
return Reflect.get( target, prop, receiver );
|
|
36
39
|
}
|
|
@@ -46,25 +49,49 @@ export const wrapTextResponse = async ( { traceId, modelId, response } ) => {
|
|
|
46
49
|
* @param {object} args
|
|
47
50
|
* @param {string} args.traceId - id created by the startTrace
|
|
48
51
|
* @param {string} args.modelId - id of the model used
|
|
49
|
-
* @param {Function} args.onFinish - Original callback to call with the
|
|
52
|
+
* @param {Function} args.onFinish - Original callback to call with the proxied response
|
|
50
53
|
* @returns {object} Proxied response
|
|
51
54
|
*/
|
|
52
55
|
export const wrapStreamOnFinishResponse = ( { traceId, modelId, onFinish: _onFinish } ) => ( {
|
|
53
56
|
async onFinish( response ) {
|
|
54
|
-
const
|
|
57
|
+
const proxiedResponse = await wrapTextResponse( { traceId, modelId, response } );
|
|
58
|
+
_onFinish?.( proxiedResponse );
|
|
59
|
+
}
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Calculates the cost and wraps an AI SDK image response in a Proxy with shortcut for 'result' and 'cost'
|
|
64
|
+
*
|
|
65
|
+
* Emits the `cost:llm:request` event.
|
|
66
|
+
*
|
|
67
|
+
* Also finishes the trace events.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} args
|
|
70
|
+
* @param {string} args.traceId - id created by the startTrace
|
|
71
|
+
* @param {string} args.modelId - id of the model used
|
|
72
|
+
* @param {object} args.response - AI SDK's image response
|
|
73
|
+
* @returns {object} Proxied response
|
|
74
|
+
*/
|
|
75
|
+
export const wrapImageResponse = async ( { traceId, modelId, response } ) => {
|
|
76
|
+
const { usage, providerMetadata } = response;
|
|
77
|
+
const cost = await calculateLLMCallCost( { usage, modelId } );
|
|
78
|
+
|
|
79
|
+
const result = response.images.map( ( { mediaType, base64 } ) => ( {
|
|
80
|
+
size: calculateBase64FileSize( base64 ),
|
|
81
|
+
mediaType
|
|
82
|
+
} ) );
|
|
55
83
|
|
|
56
|
-
|
|
84
|
+
endTraceWithSuccess( { traceId, usage, cost, result, providerMetadata } );
|
|
57
85
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
if ( prop === 'cost' ) {
|
|
64
|
-
return cost;
|
|
65
|
-
}
|
|
66
|
-
return Reflect.get( target, prop, receiver );
|
|
86
|
+
return new Proxy( response, {
|
|
87
|
+
get( target, prop, receiver ) {
|
|
88
|
+
if ( prop === 'result' ) {
|
|
89
|
+
return target.image;
|
|
67
90
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
91
|
+
if ( prop === 'cost' ) {
|
|
92
|
+
return cost;
|
|
93
|
+
}
|
|
94
|
+
return Reflect.get( target, prop, receiver );
|
|
95
|
+
}
|
|
96
|
+
} );
|
|
97
|
+
};
|