@outputai/llm 0.6.1-next.fc6a93e.0 → 0.7.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.
@@ -0,0 +1,86 @@
1
+ import { FatalError, ValidationError, z } from '@outputai/core';
2
+ import { Agent, fetch } from 'undici';
3
+ // providers
4
+ import { createAnthropic } from '@ai-sdk/anthropic';
5
+ import { createAzure } from '@ai-sdk/azure';
6
+ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
7
+ import { createOpenAI } from '@ai-sdk/openai';
8
+ import { createPerplexity } from '@ai-sdk/perplexity';
9
+ import { createVertex } from '@ai-sdk/google-vertex';
10
+
11
+ /** This custom dispatcher has longer timeouts */
12
+ const customDispatcher = new Agent( {
13
+ headersTimeout: 15 * 60 * 1000, // 15 min
14
+ bodyTimeout: 15 * 60 * 1000
15
+ } );
16
+
17
+ /** This custom fetch instance uses the custom dispatcher */
18
+ const customFetch = ( input, init ) => fetch( input, { dispatcher: customDispatcher, ...init } );
19
+
20
+ /** Available provider to initialize. */
21
+ const providerInitializers = {
22
+ anthropic: createAnthropic,
23
+ azure: createAzure,
24
+ bedrock: createAmazonBedrock,
25
+ openai: createOpenAI,
26
+ perplexity: createPerplexity,
27
+ vertex: createVertex
28
+ };
29
+
30
+ /** Providers already initialized due usage */
31
+ const initializedProviders = {};
32
+
33
+ /** Providers registered by the user */
34
+ const registeredProviders = {};
35
+
36
+ /**
37
+ * Get all available provider names, including shipped and registered.
38
+ * @returns {string[]} Provider names
39
+ */
40
+ export const getProviderNames = () =>
41
+ new Set( Object.keys( providerInitializers ).concat( Object.keys( registeredProviders ) ) ).values().toArray();
42
+
43
+ const registerProviderSchema = z.object( {
44
+ name: z.string().min( 1, 'Provider name must be a non-empty string' ),
45
+ providerFn: z.function()
46
+ } );
47
+
48
+ /**
49
+ * Register or override an AI SDK provider factory by name.
50
+ * @param {string} name - Provider name used in prompt frontmatter
51
+ * @param {Function} providerFn - Factory function that receives a model id
52
+ * @returns {void}
53
+ */
54
+ export function registerProvider( name, providerFn ) {
55
+ const result = registerProviderSchema.safeParse( { name, providerFn } );
56
+ if ( !result.success ) {
57
+ throw new ValidationError( `Invalid provider registration: ${z.prettifyError( result.error )}` );
58
+ }
59
+ registeredProviders[name] = providerFn;
60
+ }
61
+
62
+ /**
63
+ * Return a provider by its name.
64
+ * Look for registered providers first.
65
+ * If none, looks for initialized providers.
66
+ * Finally, looks for available provider initializers, and if found, init it.
67
+ *
68
+ * @param {string} name
69
+ * @returns {object} provider
70
+ */
71
+ export const getProvider = name => {
72
+ const provider = registeredProviders[name] ?? initializedProviders[name];
73
+
74
+ if ( provider ) {
75
+ return provider;
76
+ }
77
+ if ( providerInitializers[name] ) {
78
+ try {
79
+ return initializedProviders[name] = providerInitializers[name]( { fetch: customFetch } );
80
+ } catch ( error ) {
81
+ throw new FatalError( `Failed to initialize provider "${name}": ${error.message}`, { cause: error } );
82
+ }
83
+ }
84
+
85
+ throw new FatalError( `Unsupported provider "${name}"` );
86
+ };
@@ -0,0 +1,147 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const SHIPPED_PROVIDERS = [
4
+ { name: 'anthropic', pkg: '@ai-sdk/anthropic', exportName: 'createAnthropic' },
5
+ { name: 'azure', pkg: '@ai-sdk/azure', exportName: 'createAzure' },
6
+ { name: 'bedrock', pkg: '@ai-sdk/amazon-bedrock', exportName: 'createAmazonBedrock' },
7
+ { name: 'openai', pkg: '@ai-sdk/openai', exportName: 'createOpenAI' },
8
+ { name: 'perplexity', pkg: '@ai-sdk/perplexity', exportName: 'createPerplexity' },
9
+ { name: 'vertex', pkg: '@ai-sdk/google-vertex', exportName: 'createVertex' }
10
+ ];
11
+
12
+ const makeProviderModules = () => Object.fromEntries(
13
+ SHIPPED_PROVIDERS.map( ( { name, pkg, exportName } ) => [
14
+ pkg,
15
+ {
16
+ [exportName]: vi.fn( options => ( { name, options } ) )
17
+ }
18
+ ] )
19
+ );
20
+
21
+ const importWithMockedProviders = async ( modules = makeProviderModules() ) => {
22
+ await vi.resetModules();
23
+
24
+ for ( const { pkg, exportName } of SHIPPED_PROVIDERS ) {
25
+ vi.doMock( pkg, () => ( {
26
+ [exportName]: modules[pkg][exportName]
27
+ } ) );
28
+ }
29
+
30
+ return {
31
+ modules,
32
+ ...( await import( './ai_provider.js' ) )
33
+ };
34
+ };
35
+
36
+ afterEach( () => {
37
+ for ( const { pkg } of SHIPPED_PROVIDERS ) {
38
+ vi.doUnmock( pkg );
39
+ }
40
+ vi.resetModules();
41
+ vi.restoreAllMocks();
42
+ } );
43
+
44
+ describe( 'getProvider', () => {
45
+ it( 'does not initialize shipped providers on import', async () => {
46
+ const { modules } = await importWithMockedProviders();
47
+
48
+ for ( const { pkg, exportName } of SHIPPED_PROVIDERS ) {
49
+ expect( modules[pkg][exportName] ).not.toHaveBeenCalled();
50
+ }
51
+ } );
52
+
53
+ it( 'initializes each shipped provider with custom fetch when requested', async () => {
54
+ const { modules, getProvider } = await importWithMockedProviders();
55
+
56
+ for ( const { name, pkg, exportName } of SHIPPED_PROVIDERS ) {
57
+ const provider = getProvider( name );
58
+
59
+ expect( provider ).toMatchObject( { name, options: { fetch: expect.any( Function ) } } );
60
+ expect( modules[pkg][exportName] ).toHaveBeenCalledWith( { fetch: expect.any( Function ) } );
61
+ }
62
+ } );
63
+
64
+ it( 'can import and initialize all installed shipped providers', async () => {
65
+ const { getProvider } = await import( './ai_provider.js' );
66
+
67
+ for ( const { name } of SHIPPED_PROVIDERS ) {
68
+ expect( getProvider( name ) ).toEqual( expect.any( Function ) );
69
+ }
70
+ } );
71
+
72
+ it( 'caches initialized providers', async () => {
73
+ const { modules, getProvider } = await importWithMockedProviders();
74
+
75
+ const first = getProvider( 'openai' );
76
+ const second = getProvider( 'openai' );
77
+
78
+ expect( second ).toBe( first );
79
+ expect( modules['@ai-sdk/openai'].createOpenAI ).toHaveBeenCalledTimes( 1 );
80
+ } );
81
+
82
+ it( 'uses registered providers before shipped providers', async () => {
83
+ const { modules, getProvider, registerProvider } = await importWithMockedProviders();
84
+ const customProvider = vi.fn( model => ( { provider: 'custom', model } ) );
85
+
86
+ registerProvider( 'openai', customProvider );
87
+
88
+ expect( getProvider( 'openai' ) ).toBe( customProvider );
89
+ expect( modules['@ai-sdk/openai'].createOpenAI ).not.toHaveBeenCalled();
90
+ } );
91
+
92
+ it( 'throws FatalError for unsupported providers', async () => {
93
+ const { getProvider } = await importWithMockedProviders();
94
+
95
+ expect( () => getProvider( 'not-real' ) ).toThrow( 'Unsupported provider "not-real"' );
96
+ } );
97
+
98
+ it( 'throws a friendly error when provider initialization fails', async () => {
99
+ const modules = makeProviderModules();
100
+ modules['@ai-sdk/openai'].createOpenAI.mockImplementation( () => {
101
+ throw new Error( 'Missing OpenAI API key' );
102
+ } );
103
+ const { getProvider } = await importWithMockedProviders( modules );
104
+
105
+ expect( () => getProvider( 'openai' ) ).toThrow(
106
+ 'Failed to initialize provider "openai": Missing OpenAI API key'
107
+ );
108
+ } );
109
+ } );
110
+
111
+ describe( 'registerProvider', () => {
112
+ it( 'registers custom providers', async () => {
113
+ const { getProvider, getProviderNames, registerProvider } = await importWithMockedProviders();
114
+ const customProvider = vi.fn();
115
+
116
+ registerProvider( 'custom', customProvider );
117
+
118
+ expect( getProvider( 'custom' ) ).toBe( customProvider );
119
+ expect( getProviderNames() ).toContain( 'custom' );
120
+ } );
121
+
122
+ it( 'validates provider registration arguments', async () => {
123
+ const { registerProvider } = await importWithMockedProviders();
124
+
125
+ expect( () => registerProvider( '', vi.fn() ) ).toThrow( 'Provider name must be a non-empty string' );
126
+ expect( () => registerProvider( 'custom', 'not-a-function' ) ).toThrow( 'expected function, received string' );
127
+ } );
128
+ } );
129
+
130
+ describe( 'getProviderNames', () => {
131
+ it( 'returns shipped and registered provider names without duplicates', async () => {
132
+ const { getProviderNames, registerProvider } = await importWithMockedProviders();
133
+
134
+ registerProvider( 'custom', vi.fn() );
135
+ registerProvider( 'openai', vi.fn() );
136
+
137
+ expect( getProviderNames() ).toEqual( [
138
+ 'anthropic',
139
+ 'azure',
140
+ 'bedrock',
141
+ 'openai',
142
+ 'perplexity',
143
+ 'vertex',
144
+ 'custom'
145
+ ] );
146
+ } );
147
+ } );
package/src/index.d.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  StreamTextResult as AIStreamTextResult,
7
7
  ToolLoopAgent as AIToolLoopAgent,
8
8
  ToolSet,
9
+ ModelMessage,
9
10
  StreamTextOnFinishCallback,
10
11
  generateText as aiGenerateText,
11
12
  streamText as aiStreamText,
@@ -40,11 +41,6 @@ export type {
40
41
  // Re-export the tool helper function, Output, smoothStream, stop condition helpers, and jsonSchema
41
42
  export { tool, Output, smoothStream, stepCountIs, hasToolCall, jsonSchema } from 'ai';
42
43
 
43
- // Web search tool factories
44
- export { tavilySearch, tavilyExtract, tavilyCrawl, tavilyMap } from '@tavily/ai-sdk';
45
- export { webSearch as exaSearch } from '@exalabs/ai-sdk';
46
- export { perplexitySearch } from '@perplexity-ai/ai-sdk';
47
-
48
44
  /**
49
45
  * Represents a single message in a prompt conversation.
50
46
  *
@@ -171,11 +167,40 @@ export type SkillsArg<Input = unknown> = Skill[] |
171
167
  ( ( input: Input ) => Skill[] | Promise<Skill[]> );
172
168
 
173
169
  /** Prompt-owned AI SDK fields supplied by Output prompt files. */
174
- type PromptOwnedTextOptions = 'model' | 'messages' | 'prompt';
170
+ type PromptOwnedTextOptions = 'model' | 'messages' | 'prompt' | 'tools';
175
171
  type AnyAiOutput = AIOutputNamespace.Output<unknown, unknown, unknown>;
172
+ type CompatibleToolFunction = ( ...args: never[] ) => unknown | PromiseLike<unknown>;
173
+ type CompatibleApprovalFunction = ( ...args: never[] ) => boolean | PromiseLike<boolean>;
174
+
175
+ /**
176
+ * Structurally-compatible AI SDK tool shape.
177
+ *
178
+ * This intentionally avoids referencing AI SDK's concrete `Tool` schema types so tools
179
+ * from packages resolved with a different Zod peer instance remain assignable.
180
+ */
181
+ export type CompatibleTool = {
182
+ description?: string;
183
+ title?: string;
184
+ providerOptions?: Record<string, unknown>;
185
+ inputSchema?: unknown;
186
+ parameters?: unknown;
187
+ execute?: CompatibleToolFunction;
188
+ onInputStart?: CompatibleToolFunction;
189
+ onInputDelta?: CompatibleToolFunction;
190
+ onInputAvailable?: CompatibleToolFunction;
191
+ needsApproval?: boolean | CompatibleApprovalFunction;
192
+ } & (
193
+ { inputSchema: unknown } |
194
+ { parameters: unknown } |
195
+ { execute: CompatibleToolFunction }
196
+ );
197
+
198
+ /** AI SDK tools accepted by Output APIs without requiring one exact Zod peer instance. */
199
+ export type CompatibleToolSet = Record<string, CompatibleTool>;
176
200
 
177
201
  /**
178
202
  * AI SDK options accepted by generateText, with prompt-owned fields supplied by Output prompt files.
203
+ * `tools` is accepted separately as {@link CompatibleToolSet} to support third-party tool packages.
179
204
  *
180
205
  * @typeParam Tools - The tools available for the model to call
181
206
  */
@@ -186,6 +211,7 @@ export type GenerateTextAiSdkOptions<
186
211
 
187
212
  /**
188
213
  * AI SDK options accepted by streamText, with prompt-owned fields supplied by Output prompt files.
214
+ * `tools` is accepted separately as {@link CompatibleToolSet} to support third-party tool packages.
189
215
  *
190
216
  * @typeParam Tools - The tools available for the model to call
191
217
  */
@@ -205,7 +231,8 @@ type GenerateImagePromptWithImages = Exclude<GenerateImagePrompt, string>;
205
231
  type GenerateImageInput = GenerateImagePromptWithImages['images'][number];
206
232
 
207
233
  /** Agent {@link Agent.stream} options: same as AI SDK plus wrapped `onFinish` (adds `cost`). */
208
- export type OutputAgentStreamParameters = Omit<AgentStreamParameters<never, ToolSet>, 'onFinish'> & {
234
+ export type OutputAgentStreamParameters = Omit<AgentStreamParameters<never, ToolSet>, 'onFinish' | 'tools'> & {
235
+ tools?: CompatibleToolSet;
209
236
  onFinish?: WrappedStreamTextOnFinishCallback<ToolSet>;
210
237
  };
211
238
 
@@ -224,7 +251,7 @@ export type OutputAgentConstructorParameters<
224
251
  /** Static skill packages made available to the LLM */
225
252
  skills?: Skill[];
226
253
  /** AI SDK tools available during the reasoning loop */
227
- tools?: ConstructorParameters<typeof AIToolLoopAgent>[0]['tools'];
254
+ tools?: CompatibleToolSet;
228
255
  /** Maximum tool-loop iterations when stopWhen is not specified (default: 10) */
229
256
  maxSteps?: number;
230
257
  /** Pluggable conversation store — opt-in, stateless by default */
@@ -232,7 +259,9 @@ export type OutputAgentConstructorParameters<
232
259
  };
233
260
 
234
261
  /** Agent generate options accepted by the underlying AI SDK agent. */
235
- export type OutputAgentGenerateParameters = AgentCallParameters<never, ToolSet>;
262
+ export type OutputAgentGenerateParameters = Omit<AgentCallParameters<never, ToolSet>, 'tools'> & {
263
+ tools?: CompatibleToolSet;
264
+ };
236
265
 
237
266
  /** Parameters accepted by {@link generateText}. */
238
267
  export type GenerateTextParameters<
@@ -249,6 +278,8 @@ export type GenerateTextParameters<
249
278
  skills?: SkillsArg<Record<string, string | number | boolean> | undefined>;
250
279
  /** Used to create a default `stepCountIs(maxSteps)` when tools are present and `stopWhen` is omitted */
251
280
  maxSteps?: number;
281
+ /** AI SDK tools, accepted structurally to tolerate different Zod peer versions. */
282
+ tools?: CompatibleToolSet;
252
283
  } & GenerateTextAiSdkOptions<Tools, OutputSpec>;
253
284
 
254
285
  /** Parameters accepted by {@link streamText}. */
@@ -266,6 +297,8 @@ export type StreamTextParameters<
266
297
  skills?: Skill[] | ( ( input: Record<string, string | number | boolean> | undefined ) => Skill[] );
267
298
  /** Used to create a default `stepCountIs(maxSteps)` when tools are present and `stopWhen` is omitted */
268
299
  maxSteps?: number;
300
+ /** AI SDK tools, accepted structurally to tolerate different Zod peer versions. */
301
+ tools?: CompatibleToolSet;
269
302
  /** Callback when stream finishes. Receives the wrapped event with optional `cost`. */
270
303
  onFinish?: WrappedStreamTextOnFinishCallback<Tools>;
271
304
  } & Omit<StreamTextAiSdkOptions<Tools, OutputSpec>, 'onFinish'>;
@@ -391,15 +424,16 @@ export function registerProvider(
391
424
  *
392
425
  * @returns Array of provider name strings
393
426
  */
394
- export function getRegisteredProviders(): string[];
427
+ export function getProviderNames(): string[];
395
428
 
396
429
  /**
397
430
  * Use an LLM model to generate text.
398
431
  *
399
432
  * This function is a wrapper over the AI SDK's `generateText`.
400
433
  * The prompt file sets `model`, `messages`, `temperature`, `maxTokens`, and `providerOptions`.
401
- * All other AI SDK `generateText` options are accepted via {@link GenerateTextAiSdkOptions}, including
402
- * tools, tool choice, structured output, callbacks, retries, and sampling settings.
434
+ * AI SDK-compatible `tools` are accepted structurally via {@link CompatibleToolSet}. Other AI SDK
435
+ * `generateText` options are accepted via {@link GenerateTextAiSdkOptions}, including tool choice,
436
+ * structured output, callbacks, retries, and sampling settings.
403
437
  *
404
438
  * @param args - Generation arguments. See {@link GenerateTextParameters}.
405
439
  * @returns AI SDK response with text and metadata.
@@ -416,8 +450,9 @@ export function generateText<
416
450
  *
417
451
  * This function is a wrapper over the AI SDK's `streamText`.
418
452
  * The prompt file sets `model`, `messages`, `temperature`, `maxTokens`, and `providerOptions`.
419
- * All other AI SDK `streamText` options are accepted via {@link StreamTextAiSdkOptions}, except
420
- * `onFinish`, which Output wraps to add optional cost data.
453
+ * AI SDK-compatible `tools` are accepted structurally via {@link CompatibleToolSet}. Other AI SDK
454
+ * `streamText` options are accepted via {@link StreamTextAiSdkOptions}, except `onFinish`, which
455
+ * Output wraps to add optional cost data.
421
456
  *
422
457
  * @param args - Streaming arguments. See {@link StreamTextParameters}.
423
458
  * @returns AI SDK stream result with textStream, fullStream, and metadata promises.
@@ -463,8 +498,8 @@ export function skill( params: {
463
498
 
464
499
  /** Pluggable conversation store for multi-turn Agent interactions. */
465
500
  export interface ConversationStore {
466
- getMessages(): import( 'ai' ).ModelMessage[] | Promise<import( 'ai' ).ModelMessage[]>;
467
- addMessages( messages: import( 'ai' ).ModelMessage[] ): void | Promise<void>;
501
+ getMessages(): ModelMessage[] | Promise<ModelMessage[]>;
502
+ addMessages( messages: ModelMessage[] ): void | Promise<void>;
468
503
  }
469
504
 
470
505
  /** Create an in-memory conversation store backed by a closure array. */
package/src/index.js CHANGED
@@ -1,9 +1,6 @@
1
1
  export { generateText, streamText, generateImage } from './ai_sdk.js';
2
2
  export { Agent, createMemoryConversationStore, skill } from './agent.js';
3
3
  export { loadPrompt } from './prompt/loader.js';
4
- export { registerProvider, getRegisteredProviders } from './ai_model.js';
5
- export { tavilySearch, tavilyExtract, tavilyCrawl, tavilyMap } from '@tavily/ai-sdk';
6
- export { webSearch as exaSearch } from '@exalabs/ai-sdk';
7
- export { perplexitySearch } from '@perplexity-ai/ai-sdk';
4
+ export { registerProvider, getProviderNames } from './ai_provider.js';
8
5
  export { tool, Output, smoothStream, stepCountIs, hasToolCall, jsonSchema } from 'ai';
9
6
  export * as ai from 'ai';
@@ -1,5 +1,8 @@
1
1
  import { ValidationError, z } from '@outputai/core';
2
2
 
3
+ const toolConfigSchema = z.record( z.string(), z.unknown() );
4
+ const toolsConfigSchema = z.record( z.string(), toolConfigSchema );
5
+
3
6
  export const promptSchema = z.object( {
4
7
  name: z.string(),
5
8
  config: z.object( {
@@ -13,7 +16,7 @@ export const promptSchema = z.object( {
13
16
  aspectRatio: z.string().regex( /^\d+:\d+$/ ).optional(),
14
17
  seed: z.number().int().optional(),
15
18
  skills: z.union( [ z.string().min( 1 ), z.array( z.string().min( 1 ) ) ] ).optional(),
16
- tools: z.record( z.string(), z.object( {} ).loose() ).optional(),
19
+ tools: toolsConfigSchema.optional(),
17
20
  providerOptions: z.object( {
18
21
  thinking: z.object( {
19
22
  type: z.enum( [ 'enabled', 'disabled' ] ),
@@ -384,6 +384,71 @@ describe( 'validatePrompt', () => {
384
384
  expect( () => validatePrompt( promptWithSkill ) ).not.toThrow();
385
385
  } );
386
386
 
387
+ it( 'should validate provider tool config records', () => {
388
+ const promptWithTools = {
389
+ name: 'tools-prompt',
390
+ config: {
391
+ provider: 'vertex',
392
+ model: 'gemini-2.0-flash',
393
+ tools: {
394
+ googleSearch: {
395
+ mode: 'MODE_DYNAMIC',
396
+ dynamicThreshold: 0.8
397
+ },
398
+ urlContext: {}
399
+ }
400
+ },
401
+ messages: [
402
+ {
403
+ role: 'user',
404
+ content: 'Research this.'
405
+ }
406
+ ]
407
+ };
408
+
409
+ expect( () => validatePrompt( promptWithTools ) ).not.toThrow();
410
+ } );
411
+
412
+ it( 'should throw ValidationError when tools config is not a record', () => {
413
+ const invalidToolsPrompt = {
414
+ name: 'invalid-tools-prompt',
415
+ config: {
416
+ provider: 'vertex',
417
+ model: 'gemini-2.0-flash',
418
+ tools: [ 'googleSearch' ]
419
+ },
420
+ messages: [
421
+ {
422
+ role: 'user',
423
+ content: 'Research this.'
424
+ }
425
+ ]
426
+ };
427
+
428
+ expect( () => validatePrompt( invalidToolsPrompt ) ).toThrow( ValidationError );
429
+ } );
430
+
431
+ it( 'should throw ValidationError when a tool config is not a record', () => {
432
+ const invalidToolConfigPrompt = {
433
+ name: 'invalid-tool-config-prompt',
434
+ config: {
435
+ provider: 'vertex',
436
+ model: 'gemini-2.0-flash',
437
+ tools: {
438
+ googleSearch: 'MODE_DYNAMIC'
439
+ }
440
+ },
441
+ messages: [
442
+ {
443
+ role: 'user',
444
+ content: 'Research this.'
445
+ }
446
+ ]
447
+ };
448
+
449
+ expect( () => validatePrompt( invalidToolConfigPrompt ) ).toThrow( ValidationError );
450
+ } );
451
+
387
452
  it( 'should throw ValidationError for invalid skill path config', () => {
388
453
  const invalidSkillsPrompt = {
389
454
  name: 'invalid-skills-prompt',
@@ -13,6 +13,9 @@ import {
13
13
  } from 'ai';
14
14
  import { FatalError } from '@outputai/core';
15
15
 
16
+ // AI SDK does not expose a dedicated schema-mismatch discriminator for NoObjectGeneratedError.
17
+ const NO_OBJECT_SCHEMA_MISMATCH_MESSAGE = 'No object generated: response did not match schema.';
18
+
16
19
  /**
17
20
  * Recursively search an error cause chain until finds an error which is instance of given prototype.
18
21
  *
@@ -25,7 +28,7 @@ export const findInstanceInCauseChain = ( error, _class, depth = 0 ) => {
25
28
  if ( !error || typeof error !== 'object' ) {
26
29
  return null;
27
30
  }
28
- if ( typeof _class === 'string' && error.constructor.name === _class ) {
31
+ if ( typeof _class === 'string' && error.constructor?.name === _class ) {
29
32
  return error;
30
33
  }
31
34
  if ( typeof _class === 'function' && error instanceof _class ) {
@@ -42,20 +45,34 @@ const toFatalError = ( error, extraMessage = '' ) => new FatalError(
42
45
  { cause: error }
43
46
  );
44
47
 
48
+ /**
49
+ * Map an AI SDK error to a framework specific error:
50
+ *
51
+ * - AI SDK Unrecoverable errors become FatalErrors, check code to see options.
52
+ * - NoObjectGeneratedError from invalid schema are reinitialized with a better message.
53
+ * - Other errors are preserved.
54
+ * @param {object} error - Original Error
55
+ * @returns {object} A new Error
56
+ */
45
57
  export const mapAiError = error => {
46
58
  if ( error instanceof FatalError ) {
47
59
  return error;
48
60
  }
49
61
 
50
- // NoObjectGeneratedError can be thrown when the response doesn't match the schema
51
- // This adds a wrapper to that error serializing the first zod validation in the message, to make it easier to debug.
52
- if ( NoObjectGeneratedError.isInstance( error ) && error.message.includes( 'No object generated: response did not match schema.' ) ) {
62
+ // NoObjectGeneratedError can be thrown when the response doesn't match the schema.
63
+ // This re-creates the error with a better message, making it easier to debug.
64
+ if ( NoObjectGeneratedError.isInstance( error ) && error.message.includes( NO_OBJECT_SCHEMA_MISMATCH_MESSAGE ) ) {
53
65
  const zodError = findInstanceInCauseChain( error, 'ZodError' );
54
66
  if ( zodError && zodError.issues?.length > 0 ) {
55
- const { path, message } = zodError.issues[0];
56
- const wrapper = new Error( `${error.message} First issue is "${message}" at path [${path.join( ', ' )}].`, { cause: error } );
57
- wrapper.name = 'NoObjectGeneratedError';
58
- return wrapper;
67
+ const [ { path, message } ] = zodError.issues;
68
+ return new NoObjectGeneratedError( {
69
+ message: `${error.message} First issue is "${message}" at path [${path.join( ', ' )}].`,
70
+ cause: error.cause,
71
+ text: error.text,
72
+ response: error.response,
73
+ usage: error.usage,
74
+ finishReason: error.finishReason
75
+ } );
59
76
  }
60
77
  return error;
61
78
  }
@@ -171,6 +171,13 @@ describe( 'findInstanceInCauseChain', () => {
171
171
  expect( findInstanceInCauseChain( 'not an error', Error ) ).toBeNull();
172
172
  } );
173
173
 
174
+ it( 'returns null for object causes without constructors', () => {
175
+ const cause = Object.create( null );
176
+ const error = new FirstCustomError( 'first', { cause } );
177
+
178
+ expect( findInstanceInCauseChain( error, 'SecondCustomError' ) ).toBeNull();
179
+ } );
180
+
174
181
  it( 'stops searching after the depth limit', () => {
175
182
  const makeErrorChain = depth => depth === 0 ?
176
183
  new SecondCustomError( 'target' ) :
@@ -204,17 +211,25 @@ describe( 'mapAiError', () => {
204
211
  const error = new NoObjectGeneratedError( {
205
212
  message: 'No object generated: response did not match schema.',
206
213
  text: '{"items":[{}]}',
214
+ response: { id: 'response-1' },
215
+ usage: { totalTokens: 10 },
216
+ finishReason: 'stop',
207
217
  cause: validationError
208
218
  } );
209
219
 
210
220
  const result = mapAiError( error );
211
221
 
212
222
  expect( result ).not.toBe( error );
213
- expect( result.name ).toBe( 'NoObjectGeneratedError' );
223
+ expect( NoObjectGeneratedError.isInstance( result ) ).toBe( true );
224
+ expect( result.name ).toBe( 'AI_NoObjectGeneratedError' );
214
225
  expect( result.message ).toBe(
215
226
  'No object generated: response did not match schema. First issue is "Expected string" at path [items, 0, title].'
216
227
  );
217
- expect( result.cause ).toBe( error );
228
+ expect( result.cause ).toBe( validationError );
229
+ expect( result.text ).toBe( error.text );
230
+ expect( result.response ).toBe( error.response );
231
+ expect( result.usage ).toBe( error.usage );
232
+ expect( result.finishReason ).toBe( error.finishReason );
218
233
  } );
219
234
 
220
235
  it( 'preserves NoObjectGeneratedError schema mismatches when no schema issue is available', () => {