@outputai/llm 0.6.1-next.fc6a93e.0 → 0.7.1-dev.144d64f.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
+ } );
@@ -1,4 +1,5 @@
1
1
  import { loadImageModel, loadTextModel, loadTools } from './ai_model.js';
2
+ import { resolveMessageProviderOptions } from './prompt/block_options.js';
2
3
  import { FatalError } from '@outputai/core';
3
4
 
4
5
  /**
@@ -13,7 +14,7 @@ export const loadAiSdkTextOptions = prompt => {
13
14
  }
14
15
  const options = {
15
16
  model: loadTextModel( prompt ),
16
- messages: prompt.messages,
17
+ messages: resolveMessageProviderOptions( prompt ),
17
18
  providerOptions: prompt.config.providerOptions
18
19
  };
19
20
 
@@ -161,4 +161,32 @@ describe( 'ai_sdk_options', () => {
161
161
  );
162
162
  expect( loadImageModelImpl ).not.toHaveBeenCalled();
163
163
  } );
164
+
165
+ it( 'resolves block attributes into per-message providerOptions', async () => {
166
+ const prompt = {
167
+ name: 'cache@v1',
168
+ config: {
169
+ provider: 'anthropic',
170
+ model: 'claude-sonnet-4-5',
171
+ messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } } }
172
+ },
173
+ messages: [
174
+ { role: 'system', content: 'Static', attributes: { options: 'cached' } },
175
+ { role: 'user', content: 'Hello' }
176
+ ],
177
+ instructions: null
178
+ };
179
+
180
+ const { loadAiSdkTextOptions } = await importSut();
181
+ const result = loadAiSdkTextOptions( prompt );
182
+
183
+ expect( result.messages ).toEqual( [
184
+ {
185
+ role: 'system',
186
+ content: 'Static',
187
+ providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } }
188
+ },
189
+ { role: 'user', content: 'Hello' }
190
+ ] );
191
+ } );
164
192
  } );
package/src/cost/index.js CHANGED
@@ -32,8 +32,13 @@ export const calculateLLMCallCost = async ( { modelId, usage } ) => {
32
32
  if ( Number.isFinite( pricing.input ) && Number.isFinite( nonCachedTokens ) ) {
33
33
  llmUsage.addUsage( { type: 'input', ppm: pricing.input, amount: nonCachedTokens } );
34
34
  }
35
- if ( Number.isFinite( pricing.cache_read ) && Number.isFinite( cachedInputTokens ) ) {
36
- llmUsage.addUsage( { type: 'input_cached', ppm: pricing.cache_read, amount: cachedInputTokens } );
35
+ // Surface cached input tokens whenever the provider reports them, even if the model's
36
+ // pricing lacks a cache_read rate otherwise caching savings vanish from the token
37
+ // aggregation (these tokens are already excluded from the input line above). Price at
38
+ // cache_read when available, otherwise at 0.
39
+ if ( Number.isFinite( cachedInputTokens ) ) {
40
+ const cacheReadPpm = Number.isFinite( pricing.cache_read ) ? pricing.cache_read : 0;
41
+ llmUsage.addUsage( { type: 'input_cached', ppm: cacheReadPpm, amount: cachedInputTokens } );
37
42
  }
38
43
  if ( Number.isFinite( pricing.output ) && Number.isFinite( outputTokens ) ) {
39
44
  llmUsage.addUsage( { type: 'output', ppm: pricing.output, amount: outputTokens } );
@@ -132,7 +132,7 @@ describe( 'calculateLLMCallCost', () => {
132
132
  } );
133
133
  } );
134
134
 
135
- it( 'omits cached usage when model has no cache_read rate', async () => {
135
+ it( 'still counts cached tokens when the model has no cache_read rate', async () => {
136
136
  mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-cache', { input: 2, output: 10 } ] ] ) );
137
137
 
138
138
  const result = await calculateLLMCallCost( {
@@ -140,14 +140,17 @@ describe( 'calculateLLMCallCost', () => {
140
140
  usage: { inputTokens: 1_000_000, cachedInputTokens: 200_000, outputTokens: 0 }
141
141
  } );
142
142
 
143
+ // Cached tokens are surfaced (priced at 0 without a cache_read rate) so caching is
144
+ // visible in the aggregation; cost is unchanged since they are excluded from `input`.
143
145
  expectLLMUsage( result, {
144
146
  modelId: 'no-cache',
145
147
  usage: [
146
148
  { type: 'input', ppm: 2, amount: 800_000, total: 1.6 },
149
+ { type: 'input_cached', ppm: 0, amount: 200_000, total: 0 },
147
150
  { type: 'output', ppm: 10, amount: 0, total: 0 }
148
151
  ],
149
152
  total: 1.6,
150
- tokensUsed: 800_000
153
+ tokensUsed: 1_000_000
151
154
  } );
152
155
  } );
153
156
 
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
  *
@@ -61,6 +57,12 @@ export type PromptMessage = {
61
57
  role: string;
62
58
  /** The content of the message */
63
59
  content: string;
60
+ /**
61
+ * Parsed opening-tag attributes for the block. Currently `options` — a space-separated list of
62
+ * frontmatter `messageOptions` set names — which is resolved into per-message `providerOptions`
63
+ * at call time and stripped before the request is sent. Authored as `<system options="set_a set_b">`.
64
+ */
65
+ attributes?: Record<string, string | true>;
64
66
  };
65
67
 
66
68
  /**
@@ -143,6 +145,13 @@ export type Prompt = {
143
145
 
144
146
  /** Provider-specific options */
145
147
  providerOptions?: Record<string, unknown>;
148
+
149
+ /**
150
+ * Named, reusable per-message `providerOptions` sets, referenced from message blocks via the
151
+ * `options="<name>"` attribute. Each value is a provider-namespaced options object, e.g.
152
+ * `{ anthropic: { cacheControl: { type: 'ephemeral' } } }`.
153
+ */
154
+ messageOptions?: Record<string, Record<string, Record<string, unknown>>>;
146
155
  };
147
156
 
148
157
  /** Array of messages in the conversation */
@@ -171,11 +180,40 @@ export type SkillsArg<Input = unknown> = Skill[] |
171
180
  ( ( input: Input ) => Skill[] | Promise<Skill[]> );
172
181
 
173
182
  /** Prompt-owned AI SDK fields supplied by Output prompt files. */
174
- type PromptOwnedTextOptions = 'model' | 'messages' | 'prompt';
183
+ type PromptOwnedTextOptions = 'model' | 'messages' | 'prompt' | 'tools';
175
184
  type AnyAiOutput = AIOutputNamespace.Output<unknown, unknown, unknown>;
185
+ type CompatibleToolFunction = ( ...args: never[] ) => unknown | PromiseLike<unknown>;
186
+ type CompatibleApprovalFunction = ( ...args: never[] ) => boolean | PromiseLike<boolean>;
187
+
188
+ /**
189
+ * Structurally-compatible AI SDK tool shape.
190
+ *
191
+ * This intentionally avoids referencing AI SDK's concrete `Tool` schema types so tools
192
+ * from packages resolved with a different Zod peer instance remain assignable.
193
+ */
194
+ export type CompatibleTool = {
195
+ description?: string;
196
+ title?: string;
197
+ providerOptions?: Record<string, unknown>;
198
+ inputSchema?: unknown;
199
+ parameters?: unknown;
200
+ execute?: CompatibleToolFunction;
201
+ onInputStart?: CompatibleToolFunction;
202
+ onInputDelta?: CompatibleToolFunction;
203
+ onInputAvailable?: CompatibleToolFunction;
204
+ needsApproval?: boolean | CompatibleApprovalFunction;
205
+ } & (
206
+ { inputSchema: unknown } |
207
+ { parameters: unknown } |
208
+ { execute: CompatibleToolFunction }
209
+ );
210
+
211
+ /** AI SDK tools accepted by Output APIs without requiring one exact Zod peer instance. */
212
+ export type CompatibleToolSet = Record<string, CompatibleTool>;
176
213
 
177
214
  /**
178
215
  * AI SDK options accepted by generateText, with prompt-owned fields supplied by Output prompt files.
216
+ * `tools` is accepted separately as {@link CompatibleToolSet} to support third-party tool packages.
179
217
  *
180
218
  * @typeParam Tools - The tools available for the model to call
181
219
  */
@@ -186,6 +224,7 @@ export type GenerateTextAiSdkOptions<
186
224
 
187
225
  /**
188
226
  * AI SDK options accepted by streamText, with prompt-owned fields supplied by Output prompt files.
227
+ * `tools` is accepted separately as {@link CompatibleToolSet} to support third-party tool packages.
189
228
  *
190
229
  * @typeParam Tools - The tools available for the model to call
191
230
  */
@@ -205,7 +244,8 @@ type GenerateImagePromptWithImages = Exclude<GenerateImagePrompt, string>;
205
244
  type GenerateImageInput = GenerateImagePromptWithImages['images'][number];
206
245
 
207
246
  /** Agent {@link Agent.stream} options: same as AI SDK plus wrapped `onFinish` (adds `cost`). */
208
- export type OutputAgentStreamParameters = Omit<AgentStreamParameters<never, ToolSet>, 'onFinish'> & {
247
+ export type OutputAgentStreamParameters = Omit<AgentStreamParameters<never, ToolSet>, 'onFinish' | 'tools'> & {
248
+ tools?: CompatibleToolSet;
209
249
  onFinish?: WrappedStreamTextOnFinishCallback<ToolSet>;
210
250
  };
211
251
 
@@ -224,7 +264,7 @@ export type OutputAgentConstructorParameters<
224
264
  /** Static skill packages made available to the LLM */
225
265
  skills?: Skill[];
226
266
  /** AI SDK tools available during the reasoning loop */
227
- tools?: ConstructorParameters<typeof AIToolLoopAgent>[0]['tools'];
267
+ tools?: CompatibleToolSet;
228
268
  /** Maximum tool-loop iterations when stopWhen is not specified (default: 10) */
229
269
  maxSteps?: number;
230
270
  /** Pluggable conversation store — opt-in, stateless by default */
@@ -232,7 +272,9 @@ export type OutputAgentConstructorParameters<
232
272
  };
233
273
 
234
274
  /** Agent generate options accepted by the underlying AI SDK agent. */
235
- export type OutputAgentGenerateParameters = AgentCallParameters<never, ToolSet>;
275
+ export type OutputAgentGenerateParameters = Omit<AgentCallParameters<never, ToolSet>, 'tools'> & {
276
+ tools?: CompatibleToolSet;
277
+ };
236
278
 
237
279
  /** Parameters accepted by {@link generateText}. */
238
280
  export type GenerateTextParameters<
@@ -249,6 +291,8 @@ export type GenerateTextParameters<
249
291
  skills?: SkillsArg<Record<string, string | number | boolean> | undefined>;
250
292
  /** Used to create a default `stepCountIs(maxSteps)` when tools are present and `stopWhen` is omitted */
251
293
  maxSteps?: number;
294
+ /** AI SDK tools, accepted structurally to tolerate different Zod peer versions. */
295
+ tools?: CompatibleToolSet;
252
296
  } & GenerateTextAiSdkOptions<Tools, OutputSpec>;
253
297
 
254
298
  /** Parameters accepted by {@link streamText}. */
@@ -266,6 +310,8 @@ export type StreamTextParameters<
266
310
  skills?: Skill[] | ( ( input: Record<string, string | number | boolean> | undefined ) => Skill[] );
267
311
  /** Used to create a default `stepCountIs(maxSteps)` when tools are present and `stopWhen` is omitted */
268
312
  maxSteps?: number;
313
+ /** AI SDK tools, accepted structurally to tolerate different Zod peer versions. */
314
+ tools?: CompatibleToolSet;
269
315
  /** Callback when stream finishes. Receives the wrapped event with optional `cost`. */
270
316
  onFinish?: WrappedStreamTextOnFinishCallback<Tools>;
271
317
  } & Omit<StreamTextAiSdkOptions<Tools, OutputSpec>, 'onFinish'>;
@@ -391,15 +437,16 @@ export function registerProvider(
391
437
  *
392
438
  * @returns Array of provider name strings
393
439
  */
394
- export function getRegisteredProviders(): string[];
440
+ export function getProviderNames(): string[];
395
441
 
396
442
  /**
397
443
  * Use an LLM model to generate text.
398
444
  *
399
445
  * This function is a wrapper over the AI SDK's `generateText`.
400
446
  * 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.
447
+ * AI SDK-compatible `tools` are accepted structurally via {@link CompatibleToolSet}. Other AI SDK
448
+ * `generateText` options are accepted via {@link GenerateTextAiSdkOptions}, including tool choice,
449
+ * structured output, callbacks, retries, and sampling settings.
403
450
  *
404
451
  * @param args - Generation arguments. See {@link GenerateTextParameters}.
405
452
  * @returns AI SDK response with text and metadata.
@@ -416,8 +463,9 @@ export function generateText<
416
463
  *
417
464
  * This function is a wrapper over the AI SDK's `streamText`.
418
465
  * 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.
466
+ * AI SDK-compatible `tools` are accepted structurally via {@link CompatibleToolSet}. Other AI SDK
467
+ * `streamText` options are accepted via {@link StreamTextAiSdkOptions}, except `onFinish`, which
468
+ * Output wraps to add optional cost data.
421
469
  *
422
470
  * @param args - Streaming arguments. See {@link StreamTextParameters}.
423
471
  * @returns AI SDK stream result with textStream, fullStream, and metadata promises.
@@ -463,8 +511,8 @@ export function skill( params: {
463
511
 
464
512
  /** Pluggable conversation store for multi-turn Agent interactions. */
465
513
  export interface ConversationStore {
466
- getMessages(): import( 'ai' ).ModelMessage[] | Promise<import( 'ai' ).ModelMessage[]>;
467
- addMessages( messages: import( 'ai' ).ModelMessage[] ): void | Promise<void>;
514
+ getMessages(): ModelMessage[] | Promise<ModelMessage[]>;
515
+ addMessages( messages: ModelMessage[] ): void | Promise<void>;
468
516
  }
469
517
 
470
518
  /** 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';
@@ -0,0 +1,58 @@
1
+ import { FatalError, z } from '@outputai/core';
2
+
3
+ /** Shallow-merge two providerOptions objects, combining keys within each provider namespace. */
4
+ const mergeProviderOptions = ( base = {}, extra = {} ) => {
5
+ const merged = { ...base };
6
+ for ( const [ namespace, options ] of Object.entries( extra ) ) {
7
+ merged[namespace] = { ...merged[namespace], ...options };
8
+ }
9
+ return merged;
10
+ };
11
+
12
+ /** Merge the named `messageOptions` sets referenced by a block's `options` attribute. */
13
+ const resolveOptions = ( value, { name, config } ) => {
14
+ const sets = config.messageOptions ?? {};
15
+ return value.trim().split( /\s+/ ).reduce( ( acc, setName ) => {
16
+ if ( !sets[setName] ) {
17
+ throw new FatalError( `Prompt "${name}" references unknown messageOptions set "${setName}"` );
18
+ }
19
+ return mergeProviderOptions( acc, sets[setName] );
20
+ }, {} );
21
+ };
22
+
23
+ /**
24
+ * Registry of supported block attributes. Each entry declares how the attribute is validated
25
+ * (`schema`) and how it contributes to a message's per-message `providerOptions` (`resolve`).
26
+ * Add an entry to support a new block option — validation ({@link attributesSchema}) and
27
+ * resolution ({@link resolveMessageProviderOptions}) both derive from this table.
28
+ */
29
+ const BLOCK_OPTIONS = {
30
+ options: {
31
+ schema: z.string().min( 1 ),
32
+ resolve: resolveOptions
33
+ }
34
+ };
35
+
36
+ /** Zod schema for a block's `attributes` object, derived from the option registry. */
37
+ export const attributesSchema = z.object(
38
+ Object.fromEntries(
39
+ Object.entries( BLOCK_OPTIONS ).map( ( [ name, def ] ) => [ name, def.schema.optional() ] )
40
+ )
41
+ ).strict();
42
+
43
+ /**
44
+ * Resolve each message's authoring `attributes` into AI SDK per-message `providerOptions`,
45
+ * returning clean messages with the `attributes` helper stripped.
46
+ *
47
+ * @param {object} prompt - Loaded prompt object (`{ name, config, messages }`)
48
+ * @returns {Array<object>} Messages with resolved `providerOptions`
49
+ */
50
+ export const resolveMessageProviderOptions = ( { name, config, messages } ) =>
51
+ messages.map( ( { attributes, providerOptions, ...message } ) => {
52
+ const resolved = Object.entries( attributes ?? {} ).reduce( ( acc, [ key, value ] ) => {
53
+ const option = BLOCK_OPTIONS[key];
54
+ return option ? mergeProviderOptions( acc, option.resolve( value, { name, config } ) ) : acc;
55
+ }, providerOptions ?? {} );
56
+
57
+ return Object.keys( resolved ).length > 0 ? { ...message, providerOptions: resolved } : message;
58
+ } );
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FatalError } from '@outputai/core';
3
+ import { attributesSchema, resolveMessageProviderOptions } from './block_options.js';
4
+
5
+ const textPrompt = ( { config = {}, messages } ) => ( {
6
+ name: 'test@v1',
7
+ config: { provider: 'anthropic', model: 'claude-sonnet-4-5', ...config },
8
+ messages
9
+ } );
10
+
11
+ describe( 'attributesSchema', () => {
12
+ it( 'accepts the options attribute', () => {
13
+ expect( attributesSchema.safeParse( { options: 'cached' } ).success ).toBe( true );
14
+ expect( attributesSchema.safeParse( { options: 'cached fast' } ).success ).toBe( true );
15
+ expect( attributesSchema.safeParse( {} ).success ).toBe( true );
16
+ } );
17
+
18
+ it( 'rejects unknown attributes, including the removed cache shorthand', () => {
19
+ expect( attributesSchema.safeParse( { cache: true } ).success ).toBe( false );
20
+ expect( attributesSchema.safeParse( { unknown: 'x' } ).success ).toBe( false );
21
+ } );
22
+ } );
23
+
24
+ describe( 'resolveMessageProviderOptions', () => {
25
+ it( 'merges a referenced messageOptions set into per-message providerOptions', () => {
26
+ const result = resolveMessageProviderOptions( textPrompt( {
27
+ config: { messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral' } } } } },
28
+ messages: [
29
+ { role: 'system', content: 'Docs', attributes: { options: 'cached' } },
30
+ { role: 'user', content: 'Hello' }
31
+ ]
32
+ } ) );
33
+
34
+ expect( result ).toEqual( [
35
+ {
36
+ role: 'system',
37
+ content: 'Docs',
38
+ providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
39
+ },
40
+ { role: 'user', content: 'Hello' }
41
+ ] );
42
+ } );
43
+
44
+ it( 'merges multiple referenced sets onto one block', () => {
45
+ const [ system ] = resolveMessageProviderOptions( textPrompt( {
46
+ config: {
47
+ messageOptions: {
48
+ cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } },
49
+ openaiKey: { openai: { promptCacheKey: 'enrich-v1' } }
50
+ }
51
+ },
52
+ messages: [ { role: 'system', content: 'Docs', attributes: { options: 'cached openaiKey' } } ]
53
+ } ) );
54
+
55
+ expect( system.providerOptions ).toEqual( {
56
+ anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
57
+ openai: { promptCacheKey: 'enrich-v1' }
58
+ } );
59
+ } );
60
+
61
+ it( 'throws when the options attribute references an unknown set', () => {
62
+ expect( () => resolveMessageProviderOptions( textPrompt( {
63
+ messages: [ { role: 'user', content: 'Hello', attributes: { options: 'missing' } } ]
64
+ } ) ) ).toThrow( FatalError );
65
+ } );
66
+
67
+ it( 'leaves messages without attributes unchanged', () => {
68
+ const messages = [ { role: 'user', content: 'Hello' } ];
69
+ expect( resolveMessageProviderOptions( textPrompt( { messages } ) ) ).toEqual( messages );
70
+ } );
71
+ } );