@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.
- package/package.json +20 -12
- package/src/agent.js +5 -4
- package/src/agent.spec.js +62 -19
- package/src/ai_model.js +25 -146
- package/src/ai_model.spec.js +185 -762
- package/src/ai_provider.js +86 -0
- package/src/ai_provider.spec.js +147 -0
- package/src/ai_sdk_options.js +2 -1
- package/src/ai_sdk_options.spec.js +28 -0
- package/src/cost/index.js +7 -2
- package/src/cost/index.spec.js +5 -2
- package/src/index.d.ts +64 -16
- package/src/index.js +1 -4
- package/src/prompt/block_options.js +58 -0
- package/src/prompt/block_options.spec.js +71 -0
- package/src/prompt/blocks.js +47 -0
- package/src/prompt/blocks.spec.js +63 -0
- package/src/prompt/parser.js +2 -5
- package/src/prompt/parser.spec.js +19 -0
- package/src/prompt/validations.js +12 -3
- package/src/prompt/validations.spec.js +114 -0
- package/src/utils/error_handler.js +25 -8
- package/src/utils/error_handler.spec.js +17 -2
|
@@ -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/ai_sdk_options.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
36
|
-
|
|
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 } );
|
package/src/cost/index.spec.js
CHANGED
|
@@ -132,7 +132,7 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
132
132
|
} );
|
|
133
133
|
} );
|
|
134
134
|
|
|
135
|
-
it( '
|
|
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:
|
|
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?:
|
|
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
|
|
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
|
-
*
|
|
402
|
-
*
|
|
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
|
-
*
|
|
420
|
-
* `
|
|
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():
|
|
467
|
-
addMessages( messages:
|
|
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,
|
|
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
|
+
} );
|