@outputai/llm 0.1.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/LICENSE +201 -0
- package/README.md +11 -0
- package/package.json +31 -0
- package/src/ai_model.js +130 -0
- package/src/ai_model.spec.js +779 -0
- package/src/ai_sdk.js +145 -0
- package/src/ai_sdk.spec.js +633 -0
- package/src/cost/fetch_models_pricing.js +38 -0
- package/src/cost/fetch_models_pricing.spec.js +121 -0
- package/src/cost/fixtures/models_api_light.json +661 -0
- package/src/cost/index.js +73 -0
- package/src/cost/index.spec.js +162 -0
- package/src/index.d.ts +309 -0
- package/src/index.js +8 -0
- package/src/load_content.js +43 -0
- package/src/load_content.spec.js +81 -0
- package/src/parser.js +28 -0
- package/src/parser.spec.js +122 -0
- package/src/prompt_loader.js +45 -0
- package/src/prompt_loader.spec.js +179 -0
- package/src/prompt_loader_validation.spec.js +95 -0
- package/src/prompt_validations.js +60 -0
- package/src/prompt_validations.spec.js +346 -0
- package/src/source_extraction.js +49 -0
- package/src/source_extraction.spec.js +169 -0
- package/src/validations.js +21 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { fetchModelsPricing } from './fetch_models_pricing.js';
|
|
2
|
+
import Decimal from 'decimal.js';
|
|
3
|
+
|
|
4
|
+
const M = 1_000_000;
|
|
5
|
+
const calcCost = ( tokens, ppm ) => Decimal( tokens ?? 0 ).div( M ).mul( ppm ).toNumber();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calculates the input cost based on the input value
|
|
9
|
+
*/
|
|
10
|
+
const calculateInput = ( { tokens, cost } ) =>
|
|
11
|
+
!Number.isFinite( cost.input ) ? { value: null, message: 'Missing input cost' } : { value: calcCost( tokens, cost.input ) };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calculates the input cost based on the cache_read
|
|
15
|
+
*/
|
|
16
|
+
const calculateCachedInput = ( { tokens, cost } ) =>
|
|
17
|
+
!Number.isFinite( cost.cache_read ) ? { value: null, message: 'Missing cache input cost' } : { value: calcCost( tokens, cost.cache_read ) };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calculates the output cost based on the output value
|
|
21
|
+
*/
|
|
22
|
+
const calculateOutput = ( { tokens, cost } ) =>
|
|
23
|
+
!Number.isFinite( cost.output ) ? { value: null, message: 'Missing output' } : { value: calcCost( tokens, cost.output ) };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Calculates the reasoning cost based on the reasoning token's
|
|
27
|
+
* If there isn't reasoning costs, this means this providers doesn't differentiate reasoning vs output,
|
|
28
|
+
* so don't calculate it as the price is included in output
|
|
29
|
+
*/
|
|
30
|
+
const calculateReasoning = ( { tokens, cost } ) =>
|
|
31
|
+
Number.isFinite( cost.reasoning ) ? { value: calcCost( tokens, cost.reasoning ) } : undefined;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Calculates the total cost based on the components
|
|
35
|
+
*/
|
|
36
|
+
const calculateTotal = components => Object.values( components ).reduce( ( v, e ) => v.plus( e?.value ? e.value : 0 ), Decimal( 0 ) ).toNumber();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculates the cost of an llm call based on the model and usage.
|
|
40
|
+
* @param {object} args
|
|
41
|
+
* @param {string} args.modelId - Name of the mode, provider prefix is optional
|
|
42
|
+
* @param {object} args.usage - Usage, as returned from AI SDK
|
|
43
|
+
* @returns {object} The cost with total value and components
|
|
44
|
+
*/
|
|
45
|
+
export const calculateLLMCallCost = async ( { modelId, usage } ) => {
|
|
46
|
+
try {
|
|
47
|
+
const models = await fetchModelsPricing();
|
|
48
|
+
if ( !models ) {
|
|
49
|
+
return { total: null, message: 'Failed to fetch models pricing' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cost = models.get( modelId );
|
|
53
|
+
if ( !cost ) {
|
|
54
|
+
return { total: null, message: 'Missing cost reference for model' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { inputTokens, cachedInputTokens, outputTokens, reasoningTokens } = usage;
|
|
58
|
+
|
|
59
|
+
const nonCachedTokens = inputTokens - ( cachedInputTokens ?? 0 );
|
|
60
|
+
|
|
61
|
+
const components = {
|
|
62
|
+
input: calculateInput( { tokens: nonCachedTokens, cost } ),
|
|
63
|
+
cachedInput: calculateCachedInput( { tokens: cachedInputTokens, cost } ),
|
|
64
|
+
output: calculateOutput( { tokens: outputTokens, cost } ),
|
|
65
|
+
reasoning: calculateReasoning( { tokens: reasoningTokens ?? 0, cost } )
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return { total: calculateTotal( components ), components };
|
|
69
|
+
} catch ( error ) {
|
|
70
|
+
console.error( 'Error calculating LLM call costs', error );
|
|
71
|
+
return { total: null, message: `Error calculating LLM call costs: ${error.constructor.name} - ${error.message}` };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockFetchModelsPricing = vi.fn();
|
|
4
|
+
vi.mock( './fetch_models_pricing.js', () => ( {
|
|
5
|
+
fetchModelsPricing: ( ...args ) => mockFetchModelsPricing( ...args )
|
|
6
|
+
} ) );
|
|
7
|
+
|
|
8
|
+
import { calculateLLMCallCost } from './index.js';
|
|
9
|
+
|
|
10
|
+
describe( 'calculateLLMCallCost', () => {
|
|
11
|
+
beforeEach( () => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
} );
|
|
14
|
+
|
|
15
|
+
it( 'returns total null and message when fetchModelsPricing returns null', async () => {
|
|
16
|
+
mockFetchModelsPricing.mockResolvedValue( null );
|
|
17
|
+
|
|
18
|
+
const result = await calculateLLMCallCost( {
|
|
19
|
+
modelId: 'gpt-4o',
|
|
20
|
+
usage: { inputTokens: 100, outputTokens: 50 }
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
expect( result ).toEqual( { total: null, message: 'Failed to fetch models pricing' } );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
it( 'returns total null and message when model is missing from cost table', async () => {
|
|
27
|
+
mockFetchModelsPricing.mockResolvedValue( new Map() );
|
|
28
|
+
|
|
29
|
+
const result = await calculateLLMCallCost( {
|
|
30
|
+
modelId: 'unknown-model',
|
|
31
|
+
usage: { inputTokens: 100, outputTokens: 50 }
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
expect( result ).toEqual( {
|
|
35
|
+
total: null,
|
|
36
|
+
message: 'Missing cost reference for model'
|
|
37
|
+
} );
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( 'calculates input and output cost from mock model', async () => {
|
|
41
|
+
const cost = { input: 2, output: 10, cache_read: 1 };
|
|
42
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'gpt-4o', cost ] ] ) );
|
|
43
|
+
|
|
44
|
+
const result = await calculateLLMCallCost( {
|
|
45
|
+
modelId: 'gpt-4o',
|
|
46
|
+
usage: { inputTokens: 1_000_000, outputTokens: 500_000 }
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
expect( result.total ).toBe( 7 );
|
|
50
|
+
expect( result.components.input ).toEqual( { value: 2 } );
|
|
51
|
+
expect( result.components.cachedInput ).toEqual( { value: 0 } );
|
|
52
|
+
expect( result.components.output ).toEqual( { value: 5 } );
|
|
53
|
+
expect( result.components.reasoning ).toBeUndefined();
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
it( 'splits input into non-cached and cached at respective rates', async () => {
|
|
57
|
+
const cost = { input: 4, cache_read: 1, output: 10 };
|
|
58
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'cached-model', cost ] ] ) );
|
|
59
|
+
|
|
60
|
+
const result = await calculateLLMCallCost( {
|
|
61
|
+
modelId: 'cached-model',
|
|
62
|
+
usage: { inputTokens: 1_000_000, cachedInputTokens: 500_000, outputTokens: 100_000 }
|
|
63
|
+
} );
|
|
64
|
+
|
|
65
|
+
expect( result.components.input ).toEqual( { value: 2 } );
|
|
66
|
+
expect( result.components.cachedInput ).toEqual( { value: 0.5 } );
|
|
67
|
+
expect( result.components.output ).toEqual( { value: 1 } );
|
|
68
|
+
expect( result.total ).toBeCloseTo( 3.5 );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'sets cachedInput to null when model has no cache_read', async () => {
|
|
72
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-cache', { input: 2, output: 10 } ] ] ) );
|
|
73
|
+
|
|
74
|
+
const result = await calculateLLMCallCost( {
|
|
75
|
+
modelId: 'no-cache',
|
|
76
|
+
usage: { inputTokens: 1_000_000, cachedInputTokens: 200_000, outputTokens: 0 }
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
expect( result.components.input ).toEqual( { value: 1.6 } );
|
|
80
|
+
expect( result.components.cachedInput ).toEqual( { value: null, message: 'Missing cache input cost' } );
|
|
81
|
+
expect( result.total ).toBe( 1.6 );
|
|
82
|
+
} );
|
|
83
|
+
|
|
84
|
+
it( 'sets input to null and message when pricing has no input', async () => {
|
|
85
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'out-only', { output: 10 } ] ] ) );
|
|
86
|
+
|
|
87
|
+
const result = await calculateLLMCallCost( {
|
|
88
|
+
modelId: 'out-only',
|
|
89
|
+
usage: { inputTokens: 100, outputTokens: 50 }
|
|
90
|
+
} );
|
|
91
|
+
|
|
92
|
+
expect( result.total ).toBe( 0.0005 );
|
|
93
|
+
expect( result.components.input ).toEqual( { value: null, message: 'Missing input cost' } );
|
|
94
|
+
expect( result.components.output ).toEqual( { value: 0.0005 } );
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it( 'sets output to null and message when pricing has no output', async () => {
|
|
98
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'in-only', { input: 1 } ] ] ) );
|
|
99
|
+
|
|
100
|
+
const result = await calculateLLMCallCost( {
|
|
101
|
+
modelId: 'in-only',
|
|
102
|
+
usage: { inputTokens: 100, outputTokens: 50 }
|
|
103
|
+
} );
|
|
104
|
+
|
|
105
|
+
expect( result.total ).toBe( 0.0001 );
|
|
106
|
+
expect( result.components.input ).toEqual( { value: 0.0001 } );
|
|
107
|
+
expect( result.components.output ).toEqual( { value: null, message: 'Missing output' } );
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'uses reasoning cost when present', async () => {
|
|
111
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [
|
|
112
|
+
'with-reasoning',
|
|
113
|
+
{ input: 1, output: 10, reasoning: 60 }
|
|
114
|
+
] ] ) );
|
|
115
|
+
|
|
116
|
+
const result = await calculateLLMCallCost( {
|
|
117
|
+
modelId: 'with-reasoning',
|
|
118
|
+
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
expect( result.total ).toBeCloseTo( 0.0033 );
|
|
122
|
+
expect( result.components.reasoning ).toEqual( { value: 0.003 } );
|
|
123
|
+
} );
|
|
124
|
+
|
|
125
|
+
it( 'omits reasoning component when reasoning cost missing (included in output)', async () => {
|
|
126
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-reasoning', { input: 1, output: 10 } ] ] ) );
|
|
127
|
+
|
|
128
|
+
const result = await calculateLLMCallCost( {
|
|
129
|
+
modelId: 'no-reasoning',
|
|
130
|
+
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 50 }
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
expect( result.total ).toBeCloseTo( 0.0003 );
|
|
134
|
+
expect( result.components.reasoning ).toBeUndefined();
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'Calculate reasoning component when reasoningTokens is zero', async () => {
|
|
138
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [
|
|
139
|
+
'full',
|
|
140
|
+
{ input: 2, output: 8, reasoning: 60 }
|
|
141
|
+
] ] ) );
|
|
142
|
+
|
|
143
|
+
const result = await calculateLLMCallCost( {
|
|
144
|
+
modelId: 'full',
|
|
145
|
+
usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 0 }
|
|
146
|
+
} );
|
|
147
|
+
|
|
148
|
+
expect( result.components.reasoning ).toEqual( { value: 0 } );
|
|
149
|
+
expect( result.total ).toBeCloseTo( 0.0006 );
|
|
150
|
+
} );
|
|
151
|
+
|
|
152
|
+
it( 'treats null/undefined token counts as 0', async () => {
|
|
153
|
+
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'm', { input: 1, output: 2 } ] ] ) );
|
|
154
|
+
|
|
155
|
+
const result = await calculateLLMCallCost( {
|
|
156
|
+
modelId: 'm',
|
|
157
|
+
usage: { inputTokens: null, outputTokens: undefined }
|
|
158
|
+
} );
|
|
159
|
+
|
|
160
|
+
expect( result.total ).toBe( 0 );
|
|
161
|
+
} );
|
|
162
|
+
} );
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GenerateTextResult as AIGenerateTextResult,
|
|
3
|
+
StreamTextResult as AIStreamTextResult,
|
|
4
|
+
CallSettings,
|
|
5
|
+
ToolSet,
|
|
6
|
+
ToolChoice,
|
|
7
|
+
StopCondition,
|
|
8
|
+
GenerateTextOnStepFinishCallback,
|
|
9
|
+
StreamTextOnStepFinishCallback,
|
|
10
|
+
StreamTextTransform,
|
|
11
|
+
PrepareStepFunction,
|
|
12
|
+
StreamTextOnChunkCallback,
|
|
13
|
+
StreamTextOnFinishCallback,
|
|
14
|
+
StreamTextOnErrorCallback
|
|
15
|
+
} from 'ai';
|
|
16
|
+
import type { Output as AIOutput } from 'ai';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Represents a single message in a prompt conversation.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const msg: PromptMessage = {
|
|
24
|
+
* role: 'user',
|
|
25
|
+
* content: 'Hello, Claude!'
|
|
26
|
+
* };
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export type PromptMessage = {
|
|
30
|
+
/** The role of the message. Examples include 'system', 'user', and 'assistant'. */
|
|
31
|
+
role: string;
|
|
32
|
+
/** The content of the message */
|
|
33
|
+
content: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration for LLM prompt generation.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const prompt: Prompt = {
|
|
42
|
+
* name: 'summarizePrompt',
|
|
43
|
+
* config: {
|
|
44
|
+
* provider: 'anthropic',
|
|
45
|
+
* model: 'claude-opus-4-1',
|
|
46
|
+
* temperature: 0.7,
|
|
47
|
+
* maxTokens: 2048
|
|
48
|
+
* },
|
|
49
|
+
* messages: [...]
|
|
50
|
+
* };
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export type Prompt = {
|
|
54
|
+
/** Name of the prompt file */
|
|
55
|
+
name: string;
|
|
56
|
+
|
|
57
|
+
/** General configuration for the LLM */
|
|
58
|
+
config: {
|
|
59
|
+
/** LLM provider (built-in: 'anthropic', 'openai', 'azure', 'vertex', 'bedrock', 'perplexity'; or any registered custom provider) */
|
|
60
|
+
provider: string;
|
|
61
|
+
|
|
62
|
+
/** Model name/identifier */
|
|
63
|
+
model: string;
|
|
64
|
+
|
|
65
|
+
/** Generation temperature (0-2). Lower = more deterministic */
|
|
66
|
+
temperature?: number;
|
|
67
|
+
|
|
68
|
+
/** Maximum number of tokens in the response */
|
|
69
|
+
maxTokens?: number;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Provider-specific tools with configuration.
|
|
73
|
+
*
|
|
74
|
+
* @example Vertex googleSearch with config
|
|
75
|
+
* ```yaml
|
|
76
|
+
* tools:
|
|
77
|
+
* googleSearch:
|
|
78
|
+
* mode: MODE_DYNAMIC
|
|
79
|
+
* dynamicThreshold: 0.8
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @example OpenAI webSearch with filters
|
|
83
|
+
* ```yaml
|
|
84
|
+
* tools:
|
|
85
|
+
* webSearch:
|
|
86
|
+
* searchContextSize: high
|
|
87
|
+
* filters:
|
|
88
|
+
* allowedDomains: [wikipedia.org]
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
tools?: Record<string, Record<string, unknown>>;
|
|
92
|
+
|
|
93
|
+
/** Provider-specific options */
|
|
94
|
+
providerOptions?: Record<string, unknown>;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Array of messages in the conversation */
|
|
98
|
+
messages: PromptMessage[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Re-export AI SDK types directly (auto-synced with AI SDK updates)
|
|
102
|
+
export type {
|
|
103
|
+
LanguageModelUsage,
|
|
104
|
+
FinishReason,
|
|
105
|
+
LanguageModelResponseMetadata,
|
|
106
|
+
ProviderMetadata,
|
|
107
|
+
CallWarning,
|
|
108
|
+
Warning,
|
|
109
|
+
CallSettings,
|
|
110
|
+
ToolSet,
|
|
111
|
+
ToolChoice,
|
|
112
|
+
Tool,
|
|
113
|
+
StopCondition,
|
|
114
|
+
StepResult,
|
|
115
|
+
GenerateTextOnStepFinishCallback,
|
|
116
|
+
PrepareStepFunction,
|
|
117
|
+
PrepareStepResult,
|
|
118
|
+
StreamTextOnChunkCallback,
|
|
119
|
+
StreamTextOnFinishCallback,
|
|
120
|
+
StreamTextOnErrorCallback,
|
|
121
|
+
StreamTextTransform,
|
|
122
|
+
TextStreamPart
|
|
123
|
+
} from 'ai';
|
|
124
|
+
|
|
125
|
+
// Re-export the tool helper function, Output, smoothStream, and stop condition helpers
|
|
126
|
+
export { tool, Output, smoothStream, stepCountIs, hasToolCall } from 'ai';
|
|
127
|
+
|
|
128
|
+
// Web search tool factories
|
|
129
|
+
export { tavilySearch, tavilyExtract, tavilyCrawl, tavilyMap } from '@tavily/ai-sdk';
|
|
130
|
+
export { webSearch as exaSearch } from '@exalabs/ai-sdk';
|
|
131
|
+
export { perplexitySearch } from '@perplexity-ai/ai-sdk';
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Common AI SDK options that can be passed through to all generate functions.
|
|
135
|
+
* These options are passed directly to the underlying AI SDK call.
|
|
136
|
+
*/
|
|
137
|
+
type AiSdkOptions = Partial<Omit<CallSettings, 'maxOutputTokens'>>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* AI SDK options specific to generateText, including tool calling and multi-step support.
|
|
141
|
+
* @typeParam Tools - The tools available for the model to call
|
|
142
|
+
*/
|
|
143
|
+
type GenerateTextAiSdkOptions<
|
|
144
|
+
Tools extends ToolSet = ToolSet,
|
|
145
|
+
Output extends AIOutput<unknown, unknown> = AIOutput<unknown, unknown>
|
|
146
|
+
> = AiSdkOptions & {
|
|
147
|
+
/** Tools the model can call */
|
|
148
|
+
tools?: Tools;
|
|
149
|
+
/** Tool choice strategy: 'auto', 'none', 'required', or specific tool */
|
|
150
|
+
toolChoice?: ToolChoice<Tools>;
|
|
151
|
+
/** Limit which tools are active without changing types */
|
|
152
|
+
activeTools?: Array<keyof Tools>;
|
|
153
|
+
/** Maximum number of automatic tool execution rounds (multi-step) */
|
|
154
|
+
maxSteps?: number;
|
|
155
|
+
/** Custom stop conditions for multi-step execution */
|
|
156
|
+
stopWhen?: StopCondition<Tools> | StopCondition<Tools>[];
|
|
157
|
+
/** Callback after each step completes */
|
|
158
|
+
onStepFinish?: GenerateTextOnStepFinishCallback<Tools>;
|
|
159
|
+
/** Customize each step before execution */
|
|
160
|
+
prepareStep?: PrepareStepFunction<Tools>;
|
|
161
|
+
/** Structured output specification (e.g., Output.object({ schema })) */
|
|
162
|
+
output?: Output;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* AI SDK options specific to streamText, including tool calling, multi-step, and streaming callbacks.
|
|
167
|
+
* @typeParam Tools - The tools available for the model to call
|
|
168
|
+
*/
|
|
169
|
+
type StreamTextAiSdkOptions<
|
|
170
|
+
Tools extends ToolSet = ToolSet,
|
|
171
|
+
Output extends AIOutput<unknown, unknown> = AIOutput<unknown, unknown>
|
|
172
|
+
> = AiSdkOptions & {
|
|
173
|
+
/** Tools the model can call */
|
|
174
|
+
tools?: Tools;
|
|
175
|
+
/** Tool choice strategy: 'auto', 'none', 'required', or specific tool */
|
|
176
|
+
toolChoice?: ToolChoice<Tools>;
|
|
177
|
+
/** Limit which tools are active without changing types */
|
|
178
|
+
activeTools?: Array<keyof Tools>;
|
|
179
|
+
/** Maximum number of automatic tool execution rounds (multi-step) */
|
|
180
|
+
maxSteps?: number;
|
|
181
|
+
/** Custom stop conditions for multi-step execution */
|
|
182
|
+
stopWhen?: StopCondition<Tools> | StopCondition<Tools>[];
|
|
183
|
+
/** Callback after each step completes */
|
|
184
|
+
onStepFinish?: StreamTextOnStepFinishCallback<Tools>;
|
|
185
|
+
/** Customize each step before execution */
|
|
186
|
+
prepareStep?: PrepareStepFunction<Tools>;
|
|
187
|
+
/** Structured output specification (e.g., Output.object({ schema })) */
|
|
188
|
+
output?: Output;
|
|
189
|
+
/** Callback for each stream chunk */
|
|
190
|
+
onChunk?: StreamTextOnChunkCallback<Tools>;
|
|
191
|
+
/** Callback when stream finishes. Called after internal tracing records the result. */
|
|
192
|
+
onFinish?: StreamTextOnFinishCallback<Tools>;
|
|
193
|
+
/** Callback when stream errors. Called after internal tracing records the error. */
|
|
194
|
+
onError?: StreamTextOnErrorCallback;
|
|
195
|
+
/** Stream transformation (e.g., smoothStream()) */
|
|
196
|
+
experimental_transform?: StreamTextTransform<Tools> | Array<StreamTextTransform<Tools>>;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** A source extracted from search tool results during multi-step LLM execution. */
|
|
200
|
+
export type ExtractedSource = {
|
|
201
|
+
type: 'source';
|
|
202
|
+
sourceType: 'url';
|
|
203
|
+
id: string;
|
|
204
|
+
url: string;
|
|
205
|
+
title: string;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Result from generateText including full AI SDK response metadata.
|
|
210
|
+
* Extends AI SDK's GenerateTextResult with a unified `result` field.
|
|
211
|
+
* @typeParam Tools - The tools available for the model to call (preserves typing on steps)
|
|
212
|
+
*/
|
|
213
|
+
export type GenerateTextResult<
|
|
214
|
+
Tools extends ToolSet = ToolSet,
|
|
215
|
+
Output extends AIOutput<unknown, unknown> = AIOutput<unknown, unknown>
|
|
216
|
+
> = AIGenerateTextResult<Tools, Output> & {
|
|
217
|
+
/** Unified field name alias for 'text' - provides consistency across all generate* functions */
|
|
218
|
+
result: string;
|
|
219
|
+
/** Sources extracted from search tool results, merged with any native provider sources */
|
|
220
|
+
sources: ExtractedSource[];
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Loads a prompt file and interpolates variables into its content.
|
|
225
|
+
*
|
|
226
|
+
* @param name - Name of the prompt file (without `.prompt` extension).
|
|
227
|
+
* @param variables - Variables to interpolate.
|
|
228
|
+
* @returns The loaded prompt object.
|
|
229
|
+
*/
|
|
230
|
+
export function loadPrompt(
|
|
231
|
+
name: string,
|
|
232
|
+
variables?: Record<string, string | number | boolean>
|
|
233
|
+
): Prompt;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Register a custom LLM provider for use in prompt files.
|
|
237
|
+
*
|
|
238
|
+
* @param name - Provider name (used in prompt config `provider` field)
|
|
239
|
+
* @param providerFn - Factory function that creates a model from a model name string
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { createDeepSeek } from '@ai-sdk/deepseek';
|
|
244
|
+
* import { registerProvider } from '@outputai/llm';
|
|
245
|
+
*
|
|
246
|
+
* registerProvider('deepseek', createDeepSeek({ apiKey: '...' }));
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
export function registerProvider(
|
|
250
|
+
name: string,
|
|
251
|
+
providerFn: ( modelName: string ) => unknown
|
|
252
|
+
): void;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get the list of all registered provider names (built-in and custom).
|
|
256
|
+
*
|
|
257
|
+
* @returns Array of provider name strings
|
|
258
|
+
*/
|
|
259
|
+
export function getRegisteredProviders(): string[];
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Use an LLM model to generate text.
|
|
263
|
+
*
|
|
264
|
+
* This function is a wrapper over the AI SDK's `generateText`.
|
|
265
|
+
* The prompt file sets `model`, `messages`, `temperature`, `maxTokens`, and `providerOptions`.
|
|
266
|
+
* Additional AI SDK options (tools, maxRetries, etc.) can be passed through.
|
|
267
|
+
*
|
|
268
|
+
* @param args - Generation arguments.
|
|
269
|
+
* @param args.prompt - Prompt file name.
|
|
270
|
+
* @param args.variables - Variables to interpolate.
|
|
271
|
+
* @param args.tools - Tools the model can call (optional).
|
|
272
|
+
* @param args.toolChoice - Tool selection strategy (optional).
|
|
273
|
+
* @returns AI SDK response with text and metadata.
|
|
274
|
+
*/
|
|
275
|
+
export function generateText<
|
|
276
|
+
Tools extends ToolSet = ToolSet,
|
|
277
|
+
Output extends AIOutput<unknown, unknown> = AIOutput<unknown, unknown>
|
|
278
|
+
>(
|
|
279
|
+
args: {
|
|
280
|
+
prompt: string,
|
|
281
|
+
variables?: Record<string, string | number | boolean>,
|
|
282
|
+
promptDir?: string
|
|
283
|
+
} & GenerateTextAiSdkOptions<Tools, Output>
|
|
284
|
+
): Promise<GenerateTextResult<Tools, Output>>;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Use an LLM model to stream text generation.
|
|
288
|
+
*
|
|
289
|
+
* This function is a wrapper over the AI SDK's `streamText`.
|
|
290
|
+
* The prompt file sets `model`, `messages`, `temperature`, `maxTokens`, and `providerOptions`.
|
|
291
|
+
* Additional AI SDK options (tools, onChunk, onFinish, onError, etc.) can be passed through.
|
|
292
|
+
*
|
|
293
|
+
* @param args - Generation arguments.
|
|
294
|
+
* @param args.prompt - Prompt file name.
|
|
295
|
+
* @param args.variables - Variables to interpolate.
|
|
296
|
+
* @param args.onChunk - Callback for each stream chunk (optional).
|
|
297
|
+
* @param args.onFinish - Callback when stream finishes (optional).
|
|
298
|
+
* @param args.onError - Callback when stream errors (optional).
|
|
299
|
+
* @returns AI SDK stream result with textStream, fullStream, and metadata promises.
|
|
300
|
+
*/
|
|
301
|
+
export function streamText<
|
|
302
|
+
Tools extends ToolSet = ToolSet,
|
|
303
|
+
Output extends AIOutput<unknown, unknown> = AIOutput<unknown, unknown>
|
|
304
|
+
>(
|
|
305
|
+
args: {
|
|
306
|
+
prompt: string,
|
|
307
|
+
variables?: Record<string, string | number | boolean>
|
|
308
|
+
} & StreamTextAiSdkOptions<Tools, Output>
|
|
309
|
+
): AIStreamTextResult<Tools, Output>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { generateText, streamText } from './ai_sdk.js';
|
|
2
|
+
export { loadPrompt } from './prompt_loader.js';
|
|
3
|
+
export { registerProvider, getRegisteredProviders } from './ai_model.js';
|
|
4
|
+
export { tavilySearch, tavilyExtract, tavilyCrawl, tavilyMap } from '@tavily/ai-sdk';
|
|
5
|
+
export { webSearch as exaSearch } from '@exalabs/ai-sdk';
|
|
6
|
+
export { perplexitySearch } from '@perplexity-ai/ai-sdk';
|
|
7
|
+
export { tool, Output, smoothStream, stepCountIs, hasToolCall } from 'ai';
|
|
8
|
+
export * as ai from 'ai';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolveInvocationDir } from '@outputai/core/sdk_utils';
|
|
4
|
+
import { FatalError } from '@outputai/core';
|
|
5
|
+
|
|
6
|
+
const scanDir = dir => {
|
|
7
|
+
try {
|
|
8
|
+
return readdirSync( dir, { withFileTypes: true } );
|
|
9
|
+
} catch ( error ) {
|
|
10
|
+
throw new FatalError( `Error scanning directory "${dir}"`, { cause: error } );
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const loadFile = path => {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync( path, 'utf-8' );
|
|
17
|
+
} catch ( error ) {
|
|
18
|
+
throw new FatalError( `Error reading file "${path}"`, { cause: error } );
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Recursively search for a file by its name and load its content.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} name - Name of the file load its content
|
|
26
|
+
* @param {string} [dir] - The directory to search for the file, defaults to invocation directory
|
|
27
|
+
* @returns {string | null} - File content or null if not found
|
|
28
|
+
*/
|
|
29
|
+
export const loadContent = ( name, dir = resolveInvocationDir() ) => {
|
|
30
|
+
for ( const entry of scanDir( dir ) ) {
|
|
31
|
+
if ( entry.name === name ) {
|
|
32
|
+
return loadFile( join( dir, entry.name ) );
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
36
|
+
const content = loadContent( name, join( dir, entry.name ) );
|
|
37
|
+
if ( content ) {
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// Hoisted state so mocks can read dynamic values set in tests
|
|
7
|
+
const state = vi.hoisted( () => ( { dir: '', entries: {} } ) );
|
|
8
|
+
|
|
9
|
+
// Mock core utils to control resolveInvocationDir
|
|
10
|
+
vi.mock( '@outputai/core/sdk_utils', () => ( {
|
|
11
|
+
resolveInvocationDir: () => state.dir
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
14
|
+
// Mock node:fs.readFileSync for directory scans while delegating file reads
|
|
15
|
+
vi.mock( 'node:fs', async importOriginal => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
readFileSync: vi.fn( ( path, options ) => {
|
|
20
|
+
if ( options && typeof options === 'object' && options.withFileTypes ) {
|
|
21
|
+
return state.entries[path] || [];
|
|
22
|
+
}
|
|
23
|
+
return actual.readFileSync( path, options );
|
|
24
|
+
} )
|
|
25
|
+
};
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
const dirEntry = ( name, { isDir = false, isLink = false } = {} ) => ( {
|
|
29
|
+
name,
|
|
30
|
+
isDirectory: () => isDir,
|
|
31
|
+
isSymbolicLink: () => isLink
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
beforeEach( () => {
|
|
35
|
+
state.dir = '';
|
|
36
|
+
state.entries = {};
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
describe( 'loadContent', () => {
|
|
40
|
+
it( 'loads file from root directory using mocked resolveInvocationDir', async () => {
|
|
41
|
+
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
42
|
+
state.dir = tempDir;
|
|
43
|
+
const testContent = 'test file content';
|
|
44
|
+
writeFileSync( join( tempDir, 'test.txt' ), testContent );
|
|
45
|
+
state.entries[tempDir] = [ dirEntry( 'test.txt' ) ];
|
|
46
|
+
|
|
47
|
+
const { loadContent } = await import( './load_content.js' );
|
|
48
|
+
const content = loadContent( 'test.txt' );
|
|
49
|
+
|
|
50
|
+
expect( content ).toBe( testContent );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'loads file from nested subdirectory via recursion', async () => {
|
|
54
|
+
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
55
|
+
const subDir = join( tempDir, 'subdir' );
|
|
56
|
+
mkdirSync( subDir );
|
|
57
|
+
state.dir = tempDir;
|
|
58
|
+
|
|
59
|
+
const testContent = 'nested file content';
|
|
60
|
+
writeFileSync( join( subDir, 'nested.txt' ), testContent );
|
|
61
|
+
|
|
62
|
+
state.entries[tempDir] = [ dirEntry( 'subdir', { isDir: true } ) ];
|
|
63
|
+
state.entries[subDir] = [ dirEntry( 'nested.txt' ) ];
|
|
64
|
+
|
|
65
|
+
const { loadContent } = await import( './load_content.js' );
|
|
66
|
+
const content = loadContent( 'nested.txt' );
|
|
67
|
+
|
|
68
|
+
expect( content ).toBe( testContent );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'returns null when file does not exist', async () => {
|
|
72
|
+
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
73
|
+
state.dir = tempDir;
|
|
74
|
+
state.entries[tempDir] = [ dirEntry( 'other.txt' ) ];
|
|
75
|
+
|
|
76
|
+
const { loadContent } = await import( './load_content.js' );
|
|
77
|
+
const content = loadContent( 'nonexistent.txt' );
|
|
78
|
+
|
|
79
|
+
expect( content ).toBeNull();
|
|
80
|
+
} );
|
|
81
|
+
} );
|