@output.ai/llm 0.2.1 → 0.2.3
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 +1 -1
- package/src/ai_sdk.js +53 -35
- package/src/load_content.js +26 -104
- package/src/load_content.spec.js +47 -63
- package/src/parser.js +7 -6
- package/src/parser.spec.js +1 -1
- package/src/prompt_loader.js +10 -25
- package/src/prompt_loader.spec.js +86 -10
package/package.json
CHANGED
package/src/ai_sdk.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { Tracing } from '@output.ai/core/tracing';
|
|
2
|
+
import { z } from '@output.ai/core';
|
|
2
3
|
import { loadModel } from './ai_model.js';
|
|
3
4
|
import * as AI from 'ai';
|
|
4
5
|
import { validateGenerateTextArgs, validateGenerateObjectArgs, validateGenerateArrayArgs, validateGenerateEnumArgs } from './validations.js';
|
|
5
6
|
import { loadPrompt } from './prompt_loader.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
/*
|
|
9
|
+
Word of wisdom:
|
|
10
|
+
We could retrieve the result object using the rest operator:
|
|
11
|
+
```js
|
|
12
|
+
const { usage, providerMetadata, ...rest } = response;
|
|
13
|
+
const result = rest[resultProperty];
|
|
14
|
+
```
|
|
15
|
+
But we CAN'T because the response of the generateText is an instance of `DefaultGenerateTextResult`
|
|
16
|
+
and 'text' is a getter (`get text()`).
|
|
17
|
+
Be aware of this when refactoring.
|
|
18
|
+
*/
|
|
19
|
+
const traceWrapper = async ( { traceId, resultProperty, fn } ) => {
|
|
8
20
|
try {
|
|
9
|
-
const
|
|
10
|
-
|
|
21
|
+
const response = await fn();
|
|
22
|
+
const { usage, providerMetadata } = response;
|
|
23
|
+
const result = response[resultProperty];
|
|
24
|
+
Tracing.addEventEnd( { id: traceId, details: { result, usage, providerMetadata } } );
|
|
11
25
|
return result;
|
|
12
26
|
} catch ( error ) {
|
|
13
27
|
Tracing.addEventError( { id: traceId, details: error } );
|
|
@@ -52,11 +66,12 @@ const extraAiSdkOptionsFromPrompt = prompt => {
|
|
|
52
66
|
export async function generateText( { prompt, variables } ) {
|
|
53
67
|
validateGenerateTextArgs( { prompt, variables } );
|
|
54
68
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
55
|
-
const traceId = startTrace( 'generateText', { prompt
|
|
69
|
+
const traceId = startTrace( 'generateText', { prompt, variables, loadedPrompt } );
|
|
56
70
|
|
|
57
|
-
return traceWrapper(
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
return traceWrapper( {
|
|
72
|
+
traceId, resultProperty: 'text', fn: async () =>
|
|
73
|
+
AI.generateText( extraAiSdkOptionsFromPrompt( loadedPrompt ) )
|
|
74
|
+
} );
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
/**
|
|
@@ -74,17 +89,18 @@ export async function generateObject( args ) {
|
|
|
74
89
|
validateGenerateObjectArgs( args );
|
|
75
90
|
const { prompt, variables, schema, schemaName, schemaDescription } = args;
|
|
76
91
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
77
|
-
const traceId = startTrace( 'generateObject', { ...args,
|
|
92
|
+
const traceId = startTrace( 'generateObject', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
|
|
78
93
|
|
|
79
|
-
return traceWrapper(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
return traceWrapper( {
|
|
95
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
96
|
+
AI.generateObject( {
|
|
97
|
+
output: 'object',
|
|
98
|
+
schema,
|
|
99
|
+
schemaName,
|
|
100
|
+
schemaDescription,
|
|
101
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
102
|
+
} )
|
|
103
|
+
} );
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/**
|
|
@@ -102,17 +118,18 @@ export async function generateArray( args ) {
|
|
|
102
118
|
validateGenerateArrayArgs( args );
|
|
103
119
|
const { prompt, variables, schema, schemaName, schemaDescription } = args;
|
|
104
120
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
105
|
-
const traceId = startTrace( 'generateArray', { ...args,
|
|
121
|
+
const traceId = startTrace( 'generateArray', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
|
|
106
122
|
|
|
107
|
-
return traceWrapper(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
return traceWrapper( {
|
|
124
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
125
|
+
AI.generateObject( {
|
|
126
|
+
output: 'array',
|
|
127
|
+
schema,
|
|
128
|
+
schemaName,
|
|
129
|
+
schemaDescription,
|
|
130
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
131
|
+
} )
|
|
132
|
+
} );
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
/**
|
|
@@ -128,13 +145,14 @@ export async function generateEnum( args ) {
|
|
|
128
145
|
validateGenerateEnumArgs( args );
|
|
129
146
|
const { prompt, variables, enum: _enum } = args;
|
|
130
147
|
const loadedPrompt = loadPrompt( prompt, variables );
|
|
131
|
-
const traceId = startTrace( 'generateEnum', { ...args,
|
|
148
|
+
const traceId = startTrace( 'generateEnum', { ...args, loadedPrompt } );
|
|
132
149
|
|
|
133
|
-
return traceWrapper(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
150
|
+
return traceWrapper( {
|
|
151
|
+
traceId, resultProperty: 'object', fn: async () =>
|
|
152
|
+
AI.generateObject( {
|
|
153
|
+
output: 'enum',
|
|
154
|
+
enum: _enum,
|
|
155
|
+
...extraAiSdkOptionsFromPrompt( loadedPrompt )
|
|
156
|
+
} )
|
|
157
|
+
} );
|
|
140
158
|
}
|
package/src/load_content.js
CHANGED
|
@@ -1,121 +1,43 @@
|
|
|
1
|
-
import { join
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolveInvocationDir } from '@output.ai/core/utils';
|
|
4
|
+
import { FatalError } from '@output.ai/core';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
return parenMatch[1];
|
|
11
|
-
}
|
|
12
|
-
if ( directMatch ) {
|
|
13
|
-
return directMatch[1];
|
|
14
|
-
}
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const getInvocationDir = _ => {
|
|
19
|
-
const stack = new Error().stack;
|
|
20
|
-
|
|
21
|
-
if ( !stack ) {
|
|
22
|
-
throw new Error( 'Stack trace is unavailable - cannot determine invocation directory' );
|
|
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 } );
|
|
23
11
|
}
|
|
12
|
+
};
|
|
24
13
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// Match file:// URLs (ESM) or regular file paths
|
|
31
|
-
// Pattern 1: file:///path/to/file.js:line:col
|
|
32
|
-
// Pattern 2: at func (/path/to/file.js:line:col)
|
|
33
|
-
// Pattern 3: /path/to/file.js:line:col
|
|
34
|
-
const fileUrlMatch = line.match( /file:\/\/([^:]+\.(?:js|mjs|ts|tsx|jsx))/ );
|
|
35
|
-
const parenMatch = line.match( /\(([^:)]+\.(?:js|mjs|ts|tsx|jsx)):\d+:\d+\)/ );
|
|
36
|
-
const directMatch = line.match( /^\s*at\s+[^(]*([^:]+\.(?:js|mjs|ts|tsx|jsx)):\d+:\d+/ );
|
|
37
|
-
|
|
38
|
-
// Determine the file path from different stack trace formats
|
|
39
|
-
const filePath = extractFilePath( fileUrlMatch, parenMatch, directMatch );
|
|
40
|
-
|
|
41
|
-
if ( filePath ) {
|
|
42
|
-
// Skip internal Node.js paths
|
|
43
|
-
if ( filePath.includes( 'node:' ) || filePath.includes( 'internal/' ) ) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Skip SDK internal files - we want the actual caller, not the SDK
|
|
48
|
-
// This includes @output.ai packages and sdk/ directory
|
|
49
|
-
if ( filePath.includes( '/sdk/' ) || filePath.includes( 'node_modules/@output.ai/' ) ) {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Found a valid caller file outside the SDK
|
|
54
|
-
return dirname( filePath );
|
|
55
|
-
}
|
|
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 } );
|
|
56
19
|
}
|
|
57
|
-
|
|
58
|
-
throw new Error(
|
|
59
|
-
'Unable to determine invocation directory from stack trace. ' +
|
|
60
|
-
`Stack preview:\n${lines.slice( 0, 10 ).join( '\n' )}`
|
|
61
|
-
);
|
|
62
20
|
};
|
|
63
21
|
|
|
64
22
|
/**
|
|
65
23
|
* Recursively search for a file by its name and load its content.
|
|
66
24
|
*
|
|
67
25
|
* @param {string} name - Name of the file load its content
|
|
68
|
-
* @param {string} [dir
|
|
69
|
-
* @param {number} [depth=0] - Current recursion depth
|
|
70
|
-
* @param {number} [maxDepth=5] - Maximum recursion depth
|
|
26
|
+
* @param {string} [dir] - The directory to search for the file, defaults to invocation directory
|
|
71
27
|
* @returns {string | null} - File content or null if not found
|
|
72
28
|
*/
|
|
73
|
-
export const loadContent = ( name, dir =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Validate filename doesn't contain path separators (prevent directory traversal)
|
|
80
|
-
if ( name.includes( '..' ) || name.includes( '/' ) || name.includes( '\\' ) ) {
|
|
81
|
-
throw new Error( `Invalid file name "${name}" - must not contain path separators or ".."` );
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
const entries = readdirSync( dir, { withFileTypes: true } );
|
|
86
|
-
|
|
87
|
-
for ( const entry of entries ) {
|
|
88
|
-
if ( entry.name === name ) {
|
|
89
|
-
try {
|
|
90
|
-
return readFileSync( join( dir, entry.name ), 'utf-8' );
|
|
91
|
-
} catch ( error ) {
|
|
92
|
-
throw new Error(
|
|
93
|
-
`Found file "${name}" in "${dir}" but failed to read it: ${error.message}`,
|
|
94
|
-
{ cause: error }
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Recurse into subdirectories, but skip symlinks to prevent infinite loops
|
|
100
|
-
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
101
|
-
const content = loadContent( name, join( dir, entry.name ), depth + 1, maxDepth );
|
|
102
|
-
if ( content !== null ) {
|
|
103
|
-
return content;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
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 ) );
|
|
106
33
|
}
|
|
107
34
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return null;
|
|
35
|
+
if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
|
|
36
|
+
const content = loadContent( name, join( dir, entry.name ) );
|
|
37
|
+
if ( content ) {
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
114
40
|
}
|
|
115
|
-
// Propagate all other errors (permission denied, I/O errors, etc.)
|
|
116
|
-
throw new Error(
|
|
117
|
-
`Failed to read directory "${dir}" while searching for "${name}": ${error.message}`,
|
|
118
|
-
{ cause: error }
|
|
119
|
-
);
|
|
120
41
|
}
|
|
42
|
+
return null;
|
|
121
43
|
};
|
package/src/load_content.spec.js
CHANGED
|
@@ -1,97 +1,81 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { getInvocationDir, loadContent } from './load_content.js';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
2
|
import { join } from 'path';
|
|
4
3
|
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
4
|
import { tmpdir } from 'node:os';
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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( '@output.ai/core/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
|
+
} );
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
const dirEntry = ( name, { isDir = false, isLink = false } = {} ) => ( {
|
|
29
|
+
name,
|
|
30
|
+
isDirectory: () => isDir,
|
|
31
|
+
isSymbolicLink: () => isLink
|
|
32
|
+
} );
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
expect( dir ).toBeTruthy();
|
|
26
|
-
expect( typeof dir ).toBe( 'string' );
|
|
27
|
-
expect( dir.length ).toBeGreaterThan( 0 );
|
|
28
|
-
} );
|
|
34
|
+
beforeEach( () => {
|
|
35
|
+
state.dir = '';
|
|
36
|
+
state.entries = {};
|
|
29
37
|
} );
|
|
30
38
|
|
|
31
39
|
describe( 'loadContent', () => {
|
|
32
|
-
it( 'loads file from root directory', () => {
|
|
40
|
+
it( 'loads file from root directory using mocked resolveInvocationDir', async () => {
|
|
33
41
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
42
|
+
state.dir = tempDir;
|
|
34
43
|
const testContent = 'test file content';
|
|
35
44
|
writeFileSync( join( tempDir, 'test.txt' ), testContent );
|
|
45
|
+
state.entries[tempDir] = [ dirEntry( 'test.txt' ) ];
|
|
36
46
|
|
|
37
|
-
const
|
|
47
|
+
const { loadContent } = await import( './load_content.js' );
|
|
48
|
+
const content = loadContent( 'test.txt' );
|
|
38
49
|
|
|
39
50
|
expect( content ).toBe( testContent );
|
|
40
51
|
} );
|
|
41
52
|
|
|
42
|
-
it( 'loads file from nested subdirectory', () => {
|
|
53
|
+
it( 'loads file from nested subdirectory via recursion', async () => {
|
|
43
54
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
44
55
|
const subDir = join( tempDir, 'subdir' );
|
|
45
56
|
mkdirSync( subDir );
|
|
57
|
+
state.dir = tempDir;
|
|
46
58
|
|
|
47
59
|
const testContent = 'nested file content';
|
|
48
60
|
writeFileSync( join( subDir, 'nested.txt' ), testContent );
|
|
49
61
|
|
|
50
|
-
|
|
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' );
|
|
51
67
|
|
|
52
68
|
expect( content ).toBe( testContent );
|
|
53
69
|
} );
|
|
54
70
|
|
|
55
|
-
it( 'returns null when file does not exist', () => {
|
|
71
|
+
it( 'returns null when file does not exist', async () => {
|
|
56
72
|
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
73
|
+
state.dir = tempDir;
|
|
74
|
+
state.entries[tempDir] = [ dirEntry( 'other.txt' ) ];
|
|
57
75
|
|
|
58
|
-
const
|
|
76
|
+
const { loadContent } = await import( './load_content.js' );
|
|
77
|
+
const content = loadContent( 'nonexistent.txt' );
|
|
59
78
|
|
|
60
79
|
expect( content ).toBeNull();
|
|
61
80
|
} );
|
|
62
|
-
|
|
63
|
-
it( 'returns null when starting directory does not exist', () => {
|
|
64
|
-
// ENOENT is suppressed and returns null (expected behavior)
|
|
65
|
-
const content = loadContent( 'test.txt', '/nonexistent-root-path-12345' );
|
|
66
|
-
expect( content ).toBeNull();
|
|
67
|
-
} );
|
|
68
|
-
|
|
69
|
-
it( 'respects depth limit', () => {
|
|
70
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
71
|
-
|
|
72
|
-
// Create deeply nested structure: dir/1/2/3/4/5/6/deep.txt (7 levels)
|
|
73
|
-
const levels = [ 1, 2, 3, 4, 5, 6 ];
|
|
74
|
-
const deepPath = levels.reduce( ( acc, level ) => {
|
|
75
|
-
const newPath = join( acc, `level${level}` );
|
|
76
|
-
mkdirSync( newPath );
|
|
77
|
-
return newPath;
|
|
78
|
-
}, tempDir );
|
|
79
|
-
writeFileSync( join( deepPath, 'deep.txt' ), 'deeply nested' );
|
|
80
|
-
|
|
81
|
-
// Should NOT find it with default maxDepth of 5
|
|
82
|
-
const content = loadContent( 'deep.txt', tempDir );
|
|
83
|
-
expect( content ).toBeNull();
|
|
84
|
-
} );
|
|
85
|
-
|
|
86
|
-
it( 'throws error when filename contains path separators', () => {
|
|
87
|
-
const tempDir = mkdtempSync( join( tmpdir(), 'load-content-test-' ) );
|
|
88
|
-
|
|
89
|
-
expect( () => {
|
|
90
|
-
loadContent( '../test.txt', tempDir );
|
|
91
|
-
} ).toThrow( /Invalid file name/ );
|
|
92
|
-
|
|
93
|
-
expect( () => {
|
|
94
|
-
loadContent( 'foo/bar.txt', tempDir );
|
|
95
|
-
} ).toThrow( /Invalid file name/ );
|
|
96
|
-
} );
|
|
97
81
|
} );
|
package/src/parser.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
|
+
import { FatalError } from '@output.ai/core';
|
|
2
3
|
|
|
3
4
|
export function parsePrompt( raw ) {
|
|
4
|
-
const { data, content } = matter( raw );
|
|
5
|
+
const { data: config, content } = matter( raw );
|
|
5
6
|
|
|
6
7
|
if ( !content || content.trim() === '' ) {
|
|
7
|
-
throw new
|
|
8
|
+
throw new FatalError( 'Prompt file has no content after frontmatter' );
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const infoExtractor = /<(system|user|assistant|tool)>([\s\S]*?)<\/\1>/gm;
|
|
@@ -16,12 +17,12 @@ export function parsePrompt( raw ) {
|
|
|
16
17
|
const contentPreview = content.substring( 0, 200 );
|
|
17
18
|
const ellipsis = content.length > 200 ? '...' : '';
|
|
18
19
|
|
|
19
|
-
throw new
|
|
20
|
-
`No valid message blocks found in prompt file.
|
|
21
|
-
Expected format: <system>...</system>, <user>...</user>, etc.
|
|
20
|
+
throw new FatalError(
|
|
21
|
+
`No valid message blocks found in prompt file.
|
|
22
|
+
Expected format: <system>...</system>, <user>...</user>, etc.
|
|
22
23
|
Content preview: ${contentPreview}${ellipsis}`
|
|
23
24
|
);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
return {
|
|
27
|
+
return { config, messages };
|
|
27
28
|
}
|
package/src/parser.spec.js
CHANGED
package/src/prompt_loader.js
CHANGED
|
@@ -2,30 +2,15 @@ import { parsePrompt } from './parser.js';
|
|
|
2
2
|
import { Liquid } from 'liquidjs';
|
|
3
3
|
import { loadContent } from './load_content.js';
|
|
4
4
|
import { validatePrompt } from './prompt_validations.js';
|
|
5
|
+
import { FatalError } from '@output.ai/core';
|
|
5
6
|
|
|
6
7
|
const liquid = new Liquid();
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
* Render a single message with template variables.
|
|
10
|
-
*
|
|
11
|
-
* @param {string} role - The message role
|
|
12
|
-
* @param {string} content - The message content template
|
|
13
|
-
* @param {Record<string, string | number>} values - Variables to interpolate
|
|
14
|
-
* @param {string} promptName - Name of the prompt (for error messages)
|
|
15
|
-
* @param {number} index - Message index (for error messages)
|
|
16
|
-
* @returns {{role: string, content: string}} Rendered message object
|
|
17
|
-
*/
|
|
18
|
-
const renderMessage = ( role, content, values, promptName, index ) => {
|
|
9
|
+
const renderPrompt = ( name, content, values ) => {
|
|
19
10
|
try {
|
|
20
|
-
return
|
|
21
|
-
role,
|
|
22
|
-
content: liquid.parseAndRenderSync( content, values )
|
|
23
|
-
};
|
|
11
|
+
return liquid.parseAndRenderSync( content, values );
|
|
24
12
|
} catch ( error ) {
|
|
25
|
-
throw new
|
|
26
|
-
`Failed to render template in message ${index + 1} (role: ${role}) of prompt "${promptName}": ${error.message}`,
|
|
27
|
-
{ cause: error }
|
|
28
|
-
);
|
|
13
|
+
throw new FatalError( `Failed to render template in prompt "${name}": ${error.message}`, { cause: error } );
|
|
29
14
|
}
|
|
30
15
|
};
|
|
31
16
|
|
|
@@ -36,20 +21,20 @@ const renderMessage = ( role, content, values, promptName, index ) => {
|
|
|
36
21
|
* @param {Record<string, string | number>} [values] - Variables to interpolate
|
|
37
22
|
* @returns {Prompt} Loaded and rendered prompt object
|
|
38
23
|
*/
|
|
39
|
-
export const loadPrompt = ( name, values ) => {
|
|
24
|
+
export const loadPrompt = ( name, values = {} ) => {
|
|
40
25
|
const promptContent = loadContent( `${name}.prompt` );
|
|
41
26
|
if ( !promptContent ) {
|
|
42
|
-
throw new
|
|
27
|
+
throw new FatalError( `Prompt ${name} not found.` );
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
const
|
|
30
|
+
const renderedContent = renderPrompt( name, promptContent, values );
|
|
31
|
+
|
|
32
|
+
const { config, messages } = parsePrompt( renderedContent );
|
|
46
33
|
|
|
47
34
|
const prompt = {
|
|
48
35
|
name,
|
|
49
36
|
config,
|
|
50
|
-
messages
|
|
51
|
-
renderMessage( role, content, values, name, index )
|
|
52
|
-
)
|
|
37
|
+
messages
|
|
53
38
|
};
|
|
54
39
|
|
|
55
40
|
validatePrompt( prompt );
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { loadPrompt } from './prompt_loader.js';
|
|
3
3
|
|
|
4
|
-
// Mock dependencies
|
|
4
|
+
// Mock dependencies that perform I/O or validation
|
|
5
5
|
vi.mock( './load_content.js', () => ( {
|
|
6
6
|
loadContent: vi.fn()
|
|
7
7
|
} ) );
|
|
8
8
|
|
|
9
|
-
vi.mock( './parser.js', () => ( {
|
|
10
|
-
parsePrompt: vi.fn()
|
|
11
|
-
} ) );
|
|
12
|
-
|
|
13
9
|
vi.mock( './prompt_validations.js', () => ( {
|
|
14
10
|
validatePrompt: vi.fn()
|
|
15
11
|
} ) );
|
|
16
12
|
|
|
17
13
|
import { loadContent } from './load_content.js';
|
|
18
|
-
import { parsePrompt } from './parser.js';
|
|
19
14
|
import { validatePrompt } from './prompt_validations.js';
|
|
20
15
|
|
|
21
16
|
describe( 'loadPrompt', () => {
|
|
@@ -31,10 +26,6 @@ model: claude-3-5-sonnet-20241022
|
|
|
31
26
|
<user>Hello {{ name }}!</user>`;
|
|
32
27
|
|
|
33
28
|
loadContent.mockReturnValue( promptContent );
|
|
34
|
-
parsePrompt.mockReturnValue( {
|
|
35
|
-
data: { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
|
|
36
|
-
messages: [ { role: 'user', content: 'Hello {{ name }}!' } ]
|
|
37
|
-
} );
|
|
38
29
|
|
|
39
30
|
const result = loadPrompt( 'test', { name: 'World' } );
|
|
40
31
|
|
|
@@ -54,3 +45,88 @@ model: claude-3-5-sonnet-20241022
|
|
|
54
45
|
} );
|
|
55
46
|
|
|
56
47
|
} );
|
|
48
|
+
|
|
49
|
+
describe( 'loadPrompt - template hydration in headers (integration tests)', () => {
|
|
50
|
+
beforeEach( () => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
it( 'should hydrate template variables in YAML headers', () => {
|
|
55
|
+
const promptContent = `---
|
|
56
|
+
provider: {{ provider_name }}
|
|
57
|
+
model: {{ model_id }}
|
|
58
|
+
temperature: 0.7
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
<user>Hello</user>`;
|
|
62
|
+
|
|
63
|
+
loadContent.mockReturnValue( promptContent );
|
|
64
|
+
|
|
65
|
+
const result = loadPrompt( 'test', {
|
|
66
|
+
provider_name: 'anthropic',
|
|
67
|
+
model_id: 'claude-sonnet-4'
|
|
68
|
+
} );
|
|
69
|
+
|
|
70
|
+
expect( result.config.provider ).toBe( 'anthropic' );
|
|
71
|
+
expect( result.config.model ).toBe( 'claude-sonnet-4' );
|
|
72
|
+
expect( result.config.temperature ).toBe( 0.7 );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'should hydrate template variables in both headers and messages', () => {
|
|
76
|
+
const promptContent = `---
|
|
77
|
+
provider: {{ provider }}
|
|
78
|
+
model: {{ model }}
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<user>Tell me about {{ topic }}</user>`;
|
|
82
|
+
|
|
83
|
+
loadContent.mockReturnValue( promptContent );
|
|
84
|
+
|
|
85
|
+
const result = loadPrompt( 'test', {
|
|
86
|
+
provider: 'openai',
|
|
87
|
+
model: 'gpt-4',
|
|
88
|
+
topic: 'testing'
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
expect( result.config.provider ).toBe( 'openai' );
|
|
92
|
+
expect( result.config.model ).toBe( 'gpt-4' );
|
|
93
|
+
expect( result.messages[0].content ).toBe( 'Tell me about testing' );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
it( 'should render undefined template variables as null', () => {
|
|
97
|
+
const promptContent = `---
|
|
98
|
+
provider: {{ undefined_var }}
|
|
99
|
+
model: claude-3-5-sonnet-20241022
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
<user>Hello</user>`;
|
|
103
|
+
|
|
104
|
+
loadContent.mockReturnValue( promptContent );
|
|
105
|
+
|
|
106
|
+
const result = loadPrompt( 'test', {} );
|
|
107
|
+
|
|
108
|
+
// Liquid renders undefined variables as empty, which becomes null in YAML
|
|
109
|
+
expect( result.config.provider ).toBe( null );
|
|
110
|
+
expect( result.config.model ).toBe( 'claude-3-5-sonnet-20241022' );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
it( 'should handle complex template expressions in headers', () => {
|
|
114
|
+
const promptContent = `---
|
|
115
|
+
provider: anthropic
|
|
116
|
+
model: {{ base_model }}-{{ version }}
|
|
117
|
+
temperature: 0.7
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
<user>Hello</user>`;
|
|
121
|
+
|
|
122
|
+
loadContent.mockReturnValue( promptContent );
|
|
123
|
+
|
|
124
|
+
const result = loadPrompt( 'test', {
|
|
125
|
+
base_model: 'claude-sonnet',
|
|
126
|
+
version: '4'
|
|
127
|
+
} );
|
|
128
|
+
|
|
129
|
+
expect( result.config.model ).toBe( 'claude-sonnet-4' );
|
|
130
|
+
} );
|
|
131
|
+
|
|
132
|
+
} );
|